6923 lines
218 KiB
Python
6923 lines
218 KiB
Python
from __future__ import annotations
|
|
|
|
from collections.abc import Mapping
|
|
from dataclasses import replace
|
|
from pathlib import Path
|
|
from typing import Any, Literal, cast
|
|
|
|
import pytest
|
|
from pydantic import BaseModel, Field, TypeAdapter, ValidationError
|
|
|
|
from tests.wf_mcp.test_support import echo_tool
|
|
from tests.wf_mcp.workflow_surface.conftest import echo_artifact
|
|
from wf_api.draft_authoring import RouteSource, WorkflowDraftAuthoringApi
|
|
from wf_api.draft_updates import CapabilityStepUpdate
|
|
from wf_api.drafts import WorkflowDraftApi
|
|
from wf_api.models import (
|
|
AuthoringContractInventoryPayload,
|
|
DraftWorkspaceResult,
|
|
RawWorkflowPlan,
|
|
)
|
|
from wf_api.service import WorkflowApi
|
|
from wf_artifacts import FileDraftWorkspaceStore, FileWorkflowArtifactStore
|
|
from wf_artifacts.drafts.models import DraftStep
|
|
from wf_authoring import node
|
|
from wf_core.models.steps import (
|
|
InputBinding,
|
|
InputExpressionBinding,
|
|
InputPathBinding,
|
|
InputValueBinding,
|
|
OutputBinding,
|
|
)
|
|
from wf_core.paths import GraphSourcePath, LocalPath, StatePath
|
|
from wf_mcp.broker import WfMcpService
|
|
from wf_mcp.broker.service.workflow_operation_context import context_from_service
|
|
from wf_mcp.models import ConnectionConfig
|
|
from wf_mcp.storage import FileStore
|
|
from wf_mcp.workflow_surface import WorkflowSurfaceHandlers
|
|
|
|
|
|
def _required_draft(result: Mapping[str, Any]) -> dict[str, Any]:
|
|
"""Return a draft from a test result whose success path includes one."""
|
|
draft = result.get("draft")
|
|
assert isinstance(draft, dict)
|
|
return draft
|
|
|
|
|
|
def _authoring_inventory(
|
|
result: AuthoringContractInventoryPayload | DraftWorkspaceResult,
|
|
) -> AuthoringContractInventoryPayload:
|
|
"""Narrow a successful authoring inspection away from its conflict payload."""
|
|
assert "entry_steps" in result
|
|
return cast(AuthoringContractInventoryPayload, result)
|
|
|
|
|
|
def _compiled_workspace(result: Mapping[str, Any]) -> dict[str, Any]:
|
|
"""Return a compiled plan from a test case that established valid status."""
|
|
compiled_plan = result.get("compiled_plan")
|
|
assert isinstance(compiled_plan, dict)
|
|
return compiled_plan
|
|
|
|
|
|
def test_capability_step_update_preserves_field_presence() -> None:
|
|
update = CapabilityStepUpdate.model_validate(
|
|
{"desc": None, "retry": 0, "input": []}
|
|
)
|
|
|
|
assert update.model_fields_set == {"desc", "retry", "input"}
|
|
assert update.desc is None
|
|
assert update.retry == 0
|
|
assert update.input == []
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"payload",
|
|
[
|
|
{},
|
|
{"input": None},
|
|
{"desc": ""},
|
|
{"retry": -1},
|
|
{"timeout_seconds": 0},
|
|
{"unknown": True},
|
|
],
|
|
)
|
|
def test_capability_step_update_rejects_invalid_patch(payload: object) -> None:
|
|
with pytest.raises(ValidationError):
|
|
CapabilityStepUpdate.model_validate(payload)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_update_capability_step_changes_metadata_and_inputs_atomically(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
draft_api, service, authoring = _draft_api(
|
|
FileWorkflowArtifactStore(tmp_path / "update_capability"),
|
|
register_echo=True,
|
|
)
|
|
draft = _echo_draft()
|
|
draft["steps"]["echo"].update(
|
|
{
|
|
"desc": "Old description",
|
|
"retry": 1,
|
|
"timeout_seconds": 10,
|
|
}
|
|
)
|
|
await draft_api.create_draft_workspace(workspace_id="echo", draft=draft)
|
|
|
|
result = await authoring.update_capability_step(
|
|
workspace_id="echo",
|
|
revision=1,
|
|
step_id="echo",
|
|
update=CapabilityStepUpdate.model_validate(
|
|
{
|
|
"desc": "New description",
|
|
"retry": 0,
|
|
"timeout_seconds": None,
|
|
"input": [{"value": "fixed", "target": "text"}],
|
|
}
|
|
),
|
|
)
|
|
inspected = await draft_api.get_draft_workspace(
|
|
workspace_id="echo",
|
|
include_draft=True,
|
|
)
|
|
step = inspected["draft"]["steps"]["echo"]
|
|
|
|
assert result["revision"] == 2
|
|
assert _required_draft(result)["steps"]["echo"]["desc"] == "New description"
|
|
assert step["use"] == "demo.personal.echo_tool"
|
|
assert step["desc"] == "New description"
|
|
assert step["retry"] == 0
|
|
assert "timeout_seconds" not in step
|
|
assert step["input"] == [{"value": "fixed", "target": "text"}]
|
|
assert step["output"] == [{"source": "echoed", "target": "state.echoed"}]
|
|
assert inspected["draft"]["routes"]["echo"] == {"ok": "__end__"}
|
|
|
|
compiled = await draft_api.compile_draft_workspace(workspace_id="echo")
|
|
run = await service.run_workflow_from_plan(
|
|
RawWorkflowPlan.model_validate(_compiled_workspace(compiled)),
|
|
{"text": "ignored"},
|
|
)
|
|
assert run.error is None
|
|
assert run.output == {"echoed": "fixed"}
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_inspect_draft_authoring_contract_projects_selected_capability(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
draft_api, _service, authoring = _draft_api(
|
|
FileWorkflowArtifactStore(tmp_path / "authoring_contract"),
|
|
register_echo=True,
|
|
)
|
|
draft = _echo_draft()
|
|
draft["input_schema"] = {
|
|
"type": "object",
|
|
"properties": {
|
|
"request": {
|
|
"type": "object",
|
|
"properties": {"text": {"type": "string"}},
|
|
"required": ["text"],
|
|
}
|
|
},
|
|
"required": ["request"],
|
|
}
|
|
draft["state_schema"] = {
|
|
"type": "object",
|
|
"properties": {"echoed": {"type": "string"}},
|
|
}
|
|
draft["output_schema"] = {
|
|
"type": "object",
|
|
"properties": {"echoed": {"type": "string"}},
|
|
}
|
|
await draft_api.create_draft_workspace(workspace_id="authoring", draft=draft)
|
|
api = WorkflowApi(authoring.context, drafts=True)
|
|
|
|
inventory = _authoring_inventory(
|
|
await api.inspect_draft_authoring_contract(
|
|
workspace_id="authoring",
|
|
revision=1,
|
|
selected_step_id="echo",
|
|
)
|
|
)
|
|
|
|
assert inventory["workspace_id"] == "authoring"
|
|
assert inventory["revision"] == 1
|
|
assert inventory["selected_step_id"] == "echo"
|
|
assert [step["step_id"] for step in inventory["entry_steps"]] == ["echo"]
|
|
assert inventory["workflow_outcomes"] == ["ok"]
|
|
assert {option["path"] for option in inventory["step_input_targets"]} == {
|
|
"step_input.text"
|
|
}
|
|
assert {option["path"] for option in inventory["step_output_sources"]} == {
|
|
"step_output.echoed"
|
|
}
|
|
assert {option["path"] for option in inventory["readable_sources"]} >= {
|
|
"input.request",
|
|
"state.echoed",
|
|
"context.prior_outcome",
|
|
}
|
|
assert "__end__" not in {step["step_id"] for step in inventory["entry_steps"]}
|
|
selected = inventory["entry_steps"][0]
|
|
assert selected.get("input_targets", [])[0]["schema"]["type"] == "string"
|
|
assert selected.get("output_sources", [])[0]["schema"]["type"] == "string"
|
|
assert selected.get("outcomes", []) == ["ok"]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_inspect_draft_authoring_contract_tolerates_invalid_workflow_schema(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
draft_api, _service, authoring = _draft_api(
|
|
FileWorkflowArtifactStore(tmp_path / "authoring_contract_invalid_schema"),
|
|
register_echo=True,
|
|
)
|
|
draft = _echo_draft()
|
|
draft["input_schema"] = {"type": 7}
|
|
draft["state_schema"] = {
|
|
"type": "object",
|
|
"properties": {"echoed": {"type": "string"}},
|
|
}
|
|
await draft_api.create_draft_workspace(workspace_id="authoring", draft=draft)
|
|
api = WorkflowApi(authoring.context, drafts=True)
|
|
|
|
inventory = _authoring_inventory(
|
|
await api.inspect_draft_authoring_contract(
|
|
workspace_id="authoring",
|
|
revision=1,
|
|
selected_step_id="echo",
|
|
)
|
|
)
|
|
|
|
assert inventory["readable_sources"]
|
|
assert all(
|
|
option["origin"] != "workflow_input" for option in inventory["readable_sources"]
|
|
)
|
|
assert {option["path"] for option in inventory["state_targets"]} == {"state.echoed"}
|
|
assert {option["path"] for option in inventory["workflow_output_targets"]} == {
|
|
"output.echoed"
|
|
}
|
|
assert any("input_schema" in warning for warning in inventory["warnings"])
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_inspect_draft_authoring_contract_resolves_saved_wrapper_capability(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
artifact_store = FileWorkflowArtifactStore(tmp_path / "authoring_contract_wrapper")
|
|
artifact_store.save_artifact(
|
|
echo_artifact().model_copy(update={"id": "echo_wrapper", "kind": "wrapper"})
|
|
)
|
|
draft_api, _service, authoring = _draft_api(artifact_store)
|
|
draft = _echo_draft()
|
|
draft["steps"]["echo"]["use"] = "workflow.echo_wrapper.v1"
|
|
draft["state_schema"] = {
|
|
"type": "object",
|
|
"properties": {"echoed": {"type": "string"}},
|
|
}
|
|
await draft_api.create_draft_workspace(workspace_id="authoring", draft=draft)
|
|
api = WorkflowApi(authoring.context, drafts=True)
|
|
|
|
inventory = _authoring_inventory(
|
|
await api.inspect_draft_authoring_contract(
|
|
workspace_id="authoring",
|
|
revision=1,
|
|
selected_step_id="echo",
|
|
)
|
|
)
|
|
|
|
assert [step["step_id"] for step in inventory["entry_steps"]] == ["echo"]
|
|
assert {option["path"] for option in inventory["step_input_targets"]} == {
|
|
"step_input.text"
|
|
}
|
|
assert {option["path"] for option in inventory["step_output_sources"]} == {
|
|
"step_output.echoed"
|
|
}
|
|
assert inventory["entry_steps"][0].get("outcomes", []) == ["completed"]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_inspect_draft_authoring_contract_preserves_empty_capability_schemas(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
draft_api, service, authoring = _draft_api(
|
|
FileWorkflowArtifactStore(tmp_path / "authoring_contract_empty_schema")
|
|
)
|
|
service.register_connection(
|
|
ConnectionConfig(id="demo.personal", server="demo", account="personal")
|
|
)
|
|
service.register_specs(
|
|
"demo.personal",
|
|
replace(echo_tool, input_schema_contract={}, output_schema_contract={}),
|
|
)
|
|
draft = _echo_draft()
|
|
draft["state_schema"] = {
|
|
"type": "object",
|
|
"properties": {"echoed": {"type": "string"}},
|
|
}
|
|
await draft_api.create_draft_workspace(workspace_id="authoring", draft=draft)
|
|
api = WorkflowApi(authoring.context, drafts=True)
|
|
|
|
inventory = _authoring_inventory(
|
|
await api.inspect_draft_authoring_contract(
|
|
workspace_id="authoring",
|
|
revision=1,
|
|
selected_step_id="echo",
|
|
)
|
|
)
|
|
|
|
assert inventory["entry_steps"][0].get("input_targets", []) == []
|
|
assert inventory["entry_steps"][0].get("output_sources", []) == []
|
|
assert inventory["step_input_targets"] == []
|
|
assert inventory["step_output_sources"] == []
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize(
|
|
"invalid_property_schema",
|
|
[
|
|
{"$ref": "https://example.com/request.json"},
|
|
{"$ref": 7},
|
|
],
|
|
)
|
|
async def test_inspect_draft_authoring_contract_warns_for_invalid_capability_schema(
|
|
tmp_path: Path,
|
|
invalid_property_schema: dict[str, object],
|
|
) -> None:
|
|
draft_api, service, authoring = _draft_api(
|
|
FileWorkflowArtifactStore(tmp_path / "authoring_contract_bad_capability")
|
|
)
|
|
service.register_connection(
|
|
ConnectionConfig(id="demo.personal", server="demo", account="personal")
|
|
)
|
|
service.register_specs(
|
|
"demo.personal",
|
|
replace(
|
|
echo_tool,
|
|
input_schema_contract={
|
|
"type": "object",
|
|
"properties": {"text": invalid_property_schema},
|
|
},
|
|
),
|
|
)
|
|
await draft_api.create_draft_workspace(
|
|
workspace_id="authoring",
|
|
draft=_echo_draft(),
|
|
)
|
|
api = WorkflowApi(authoring.context, drafts=True)
|
|
|
|
inventory = _authoring_inventory(
|
|
await api.inspect_draft_authoring_contract(
|
|
workspace_id="authoring",
|
|
revision=1,
|
|
selected_step_id="echo",
|
|
)
|
|
)
|
|
|
|
assert inventory["entry_steps"][0].get("input_targets", []) == []
|
|
assert inventory["entry_steps"][0].get("output_sources", [])
|
|
assert inventory["step_input_targets"] == []
|
|
assert inventory["step_output_sources"]
|
|
assert any(
|
|
"echo" in warning and "input schema" in warning
|
|
for warning in inventory["warnings"]
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_inspect_draft_authoring_contract_rejects_unknown_selected_step(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
draft_api, _service, authoring = _draft_api(
|
|
FileWorkflowArtifactStore(tmp_path / "authoring_contract_unknown"),
|
|
register_echo=True,
|
|
)
|
|
await draft_api.create_draft_workspace(
|
|
workspace_id="authoring", draft=_echo_draft()
|
|
)
|
|
api = WorkflowApi(authoring.context, drafts=True)
|
|
|
|
with pytest.raises(KeyError, match="unknown draft step"):
|
|
await api.inspect_draft_authoring_contract(
|
|
workspace_id="authoring",
|
|
revision=1,
|
|
selected_step_id="missing",
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_inspect_draft_authoring_contract_tolerates_invalid_selected_step(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
draft_api, _service, authoring = _draft_api(
|
|
FileWorkflowArtifactStore(tmp_path / "authoring_contract_invalid"),
|
|
register_echo=True,
|
|
)
|
|
draft = _echo_draft()
|
|
draft["steps"] = {"broken": {"unknown_kind": {}}}
|
|
draft["start"] = "broken"
|
|
await draft_api.create_draft_workspace(workspace_id="authoring", draft=draft)
|
|
api = WorkflowApi(authoring.context, drafts=True)
|
|
|
|
inventory = _authoring_inventory(
|
|
await api.inspect_draft_authoring_contract(
|
|
workspace_id="authoring",
|
|
revision=1,
|
|
selected_step_id="broken",
|
|
)
|
|
)
|
|
|
|
assert inventory["selected_step_id"] == "broken"
|
|
assert inventory["entry_steps"] == []
|
|
assert inventory["step_input_targets"] == []
|
|
assert inventory["step_output_sources"] == []
|
|
assert inventory["readable_sources"]
|
|
assert any("broken" in warning for warning in inventory["warnings"])
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_inspect_draft_authoring_contract_stale_revision_is_read_only(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
draft_api, _service, authoring = _draft_api(
|
|
FileWorkflowArtifactStore(tmp_path / "authoring_contract_stale"),
|
|
register_echo=True,
|
|
)
|
|
await draft_api.create_draft_workspace(
|
|
workspace_id="authoring", draft=_echo_draft()
|
|
)
|
|
api = WorkflowApi(authoring.context, drafts=True)
|
|
changed = await api.set_draft_name(
|
|
workspace_id="authoring",
|
|
revision=1,
|
|
name="changed",
|
|
)
|
|
before = await api.get_draft_workspace(
|
|
workspace_id="authoring",
|
|
include_draft=True,
|
|
)
|
|
|
|
conflict = await api.inspect_draft_authoring_contract(
|
|
workspace_id="authoring",
|
|
revision=1,
|
|
selected_step_id="echo",
|
|
)
|
|
after = await api.get_draft_workspace(
|
|
workspace_id="authoring",
|
|
include_draft=True,
|
|
)
|
|
|
|
assert changed["revision"] == 2
|
|
assert "status" in conflict
|
|
assert conflict["status"] == "conflict"
|
|
assert conflict["revision"] == 2
|
|
assert conflict["diagnostics"][0]["code"] == "revision_conflict"
|
|
assert after == before
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_update_capability_step_preserves_omitted_fields_and_exact_noop(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
draft_api, _service, authoring = _draft_api(
|
|
FileWorkflowArtifactStore(tmp_path / "update_capability_noop"),
|
|
register_echo=True,
|
|
)
|
|
draft = _echo_draft()
|
|
draft["steps"]["echo"].update({"desc": "Keep", "retry": 2, "timeout_seconds": 15})
|
|
await draft_api.create_draft_workspace(workspace_id="echo", draft=draft)
|
|
|
|
first = await authoring.update_capability_step(
|
|
workspace_id="echo",
|
|
revision=1,
|
|
step_id="echo",
|
|
update=CapabilityStepUpdate(
|
|
retry=2,
|
|
input=[
|
|
InputPathBinding(
|
|
path=GraphSourcePath.input("text"),
|
|
target=LocalPath.of("text"),
|
|
)
|
|
],
|
|
),
|
|
)
|
|
second = await authoring.update_capability_step(
|
|
workspace_id="echo",
|
|
revision=first["revision"],
|
|
step_id="echo",
|
|
update=CapabilityStepUpdate(desc=None),
|
|
)
|
|
inspected = await draft_api.get_draft_workspace(
|
|
workspace_id="echo",
|
|
include_draft=True,
|
|
)
|
|
|
|
assert first["revision"] == 1
|
|
assert second["revision"] == 2
|
|
step = inspected["draft"]["steps"]["echo"]
|
|
assert "desc" not in step
|
|
assert step["retry"] == 2
|
|
assert step["timeout_seconds"] == 15
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_update_capability_step_removes_stored_null_metadata(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
draft_api, _service, authoring = _draft_api(
|
|
FileWorkflowArtifactStore(tmp_path / "update_capability_null_noop"),
|
|
register_echo=True,
|
|
)
|
|
await draft_api.create_draft_workspace(workspace_id="echo", draft=_echo_draft())
|
|
before = await draft_api.get_draft_workspace(
|
|
workspace_id="echo",
|
|
include_draft=True,
|
|
)
|
|
assert before["draft"]["steps"]["echo"]["retry"] is None
|
|
|
|
result = await authoring.update_capability_step(
|
|
workspace_id="echo",
|
|
revision=1,
|
|
step_id="echo",
|
|
update=CapabilityStepUpdate(retry=None),
|
|
)
|
|
after = await draft_api.get_draft_workspace(
|
|
workspace_id="echo",
|
|
include_draft=True,
|
|
)
|
|
|
|
assert result["revision"] == 2
|
|
assert "retry" not in after["draft"]["steps"]["echo"]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_update_capability_step_input_preserves_omitted_null_metadata(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
draft_api, _service, authoring = _draft_api(
|
|
FileWorkflowArtifactStore(tmp_path / "update_capability_null_metadata"),
|
|
register_echo=True,
|
|
)
|
|
draft = _echo_draft()
|
|
draft["steps"]["echo"].update(
|
|
{
|
|
"desc": None,
|
|
"retry": None,
|
|
"timeout_seconds": None,
|
|
}
|
|
)
|
|
await draft_api.create_draft_workspace(workspace_id="echo", draft=draft)
|
|
|
|
result = await authoring.update_capability_step(
|
|
workspace_id="echo",
|
|
revision=1,
|
|
step_id="echo",
|
|
update=CapabilityStepUpdate(
|
|
input=[
|
|
InputValueBinding(
|
|
target=LocalPath.of("text"),
|
|
value="replacement",
|
|
)
|
|
]
|
|
),
|
|
)
|
|
inspected = await draft_api.get_draft_workspace(
|
|
workspace_id="echo",
|
|
include_draft=True,
|
|
)
|
|
step = inspected["draft"]["steps"]["echo"]
|
|
|
|
assert result["revision"] == 2
|
|
assert step["desc"] is None
|
|
assert step["retry"] is None
|
|
assert step["timeout_seconds"] is None
|
|
assert step["input"] == [{"value": "replacement", "target": "text"}]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_update_capability_step_metadata_does_not_resolve_capability(
|
|
tmp_path: Path,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
draft_api, _service, authoring = _draft_api(
|
|
FileWorkflowArtifactStore(tmp_path / "update_capability_metadata"),
|
|
register_echo=True,
|
|
)
|
|
await draft_api.create_draft_workspace(workspace_id="echo", draft=_echo_draft())
|
|
|
|
def fail_lookup(_provider: object, _capability_name: str) -> None:
|
|
raise AssertionError("metadata-only update resolved the capability")
|
|
|
|
monkeypatch.setattr(
|
|
type(authoring.context.specs),
|
|
"get_qualified_spec",
|
|
fail_lookup,
|
|
)
|
|
result = await authoring.update_capability_step(
|
|
workspace_id="echo",
|
|
revision=1,
|
|
step_id="echo",
|
|
update=CapabilityStepUpdate(desc="Metadata only"),
|
|
)
|
|
|
|
assert result["revision"] == 2
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_update_capability_step_metadata_preserves_invalid_draft_shape(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
draft_api, _service, authoring = _draft_api(
|
|
FileWorkflowArtifactStore(tmp_path / "update_invalid_draft_shape"),
|
|
register_echo=True,
|
|
)
|
|
draft = _echo_draft()
|
|
draft["routes"]["echo"]["ok"] = "missing"
|
|
await draft_api.create_draft_workspace(workspace_id="echo", draft=draft)
|
|
before = await draft_api.get_draft_workspace(
|
|
workspace_id="echo",
|
|
include_draft=True,
|
|
)
|
|
|
|
result = await authoring.update_capability_step(
|
|
workspace_id="echo",
|
|
revision=1,
|
|
step_id="echo",
|
|
update=CapabilityStepUpdate(desc="Explain the invalid draft"),
|
|
)
|
|
after = await draft_api.get_draft_workspace(
|
|
workspace_id="echo",
|
|
include_draft=True,
|
|
)
|
|
|
|
assert result["revision"] == 2
|
|
assert after["draft"]["steps"]["echo"]["desc"] == "Explain the invalid draft"
|
|
assert (
|
|
after["draft"]["steps"]["echo"]["input"]
|
|
== before["draft"]["steps"]["echo"]["input"]
|
|
)
|
|
assert (
|
|
after["draft"]["steps"]["echo"]["output"]
|
|
== before["draft"]["steps"]["echo"]["output"]
|
|
)
|
|
assert after["draft"]["routes"] == before["draft"]["routes"]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_update_capability_step_stale_revision_wins_over_semantic_errors(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
draft_api, _service, authoring = _draft_api(
|
|
FileWorkflowArtifactStore(tmp_path / "update_capability_stale"),
|
|
register_echo=True,
|
|
)
|
|
await draft_api.create_draft_workspace(workspace_id="echo", draft=_echo_draft())
|
|
|
|
result = await authoring.update_capability_step(
|
|
workspace_id="echo",
|
|
revision=2,
|
|
step_id="missing",
|
|
update=CapabilityStepUpdate(
|
|
input=[InputValueBinding(target=LocalPath.of("missing"), value="bad")]
|
|
),
|
|
)
|
|
|
|
assert result["status"] == "conflict"
|
|
assert result["diagnostics"][0]["code"] == "revision_conflict"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_update_capability_step_rejects_wrong_kind_and_invalid_input_atomically(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
draft_api, _service, authoring = _draft_api(
|
|
FileWorkflowArtifactStore(tmp_path / "update_capability_invalid"),
|
|
register_echo=True,
|
|
)
|
|
draft = _echo_draft()
|
|
draft["steps"]["joined"] = {"join": ["echo"]}
|
|
await draft_api.create_draft_workspace(workspace_id="echo", draft=draft)
|
|
before = await draft_api.get_draft_workspace(
|
|
workspace_id="echo",
|
|
include_draft=True,
|
|
)
|
|
|
|
with pytest.raises(ValueError, match="not capability-backed"):
|
|
await authoring.update_capability_step(
|
|
workspace_id="echo",
|
|
revision=1,
|
|
step_id="joined",
|
|
update=CapabilityStepUpdate(desc="No"),
|
|
)
|
|
with pytest.raises(ValueError, match=r"bindings\[0\]\.target"):
|
|
await authoring.update_capability_step(
|
|
workspace_id="echo",
|
|
revision=1,
|
|
step_id="echo",
|
|
update=CapabilityStepUpdate(
|
|
desc="Must not persist",
|
|
input=[
|
|
InputValueBinding(
|
|
target=LocalPath.of("missing"),
|
|
value="bad",
|
|
)
|
|
],
|
|
),
|
|
)
|
|
|
|
after = await draft_api.get_draft_workspace(
|
|
workspace_id="echo",
|
|
include_draft=True,
|
|
)
|
|
assert after == before
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_add_step_from_capability_accepts_metadata_and_canonical_inputs(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
draft_api, service, authoring = _draft_api(
|
|
FileWorkflowArtifactStore(tmp_path / "add_capability_parity"),
|
|
register_echo=True,
|
|
)
|
|
service.register_specs("demo.personal", _structured_report)
|
|
draft = _structured_report_draft()
|
|
draft["steps"] = {}
|
|
draft["routes"] = {}
|
|
await draft_api.create_draft_workspace(workspace_id="report", draft=draft)
|
|
|
|
result = await authoring.add_step_from_capability(
|
|
workspace_id="report",
|
|
revision=1,
|
|
step_id="report",
|
|
capability_name="demo.personal.structured_report",
|
|
routes={"ok": "__end__"},
|
|
desc="Publish report",
|
|
retry=0,
|
|
timeout_seconds=30,
|
|
input_bindings=[
|
|
InputPathBinding(
|
|
path=GraphSourcePath.state("report", "title"),
|
|
target=LocalPath.of("request", "title"),
|
|
),
|
|
InputValueBinding(
|
|
target=LocalPath.of("request", "format"),
|
|
value="markdown",
|
|
),
|
|
],
|
|
)
|
|
inspected = await draft_api.get_draft_workspace(
|
|
workspace_id="report",
|
|
include_draft=True,
|
|
)
|
|
step = inspected["draft"]["steps"]["report"]
|
|
|
|
assert result["revision"] == 2
|
|
assert _required_draft(result)["steps"]["report"]["desc"] == "Publish report"
|
|
assert step["desc"] == "Publish report"
|
|
assert step["retry"] == 0
|
|
assert step["timeout_seconds"] == 30
|
|
assert step["input"] == [
|
|
{"path": "state.report.title", "target": "request.title"},
|
|
{"value": "markdown", "target": "request.format"},
|
|
]
|
|
assert (
|
|
inspected["draft"]["state_schema"]["properties"]["report"]["properties"][
|
|
"title"
|
|
]["type"]
|
|
== "string"
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_add_step_from_capability_rejects_both_input_forms(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
draft_api, _service, authoring = _draft_api(
|
|
FileWorkflowArtifactStore(tmp_path / "add_capability_exclusive"),
|
|
register_echo=True,
|
|
)
|
|
await draft_api.create_draft_workspace(workspace_id="echo", draft=_echo_draft())
|
|
|
|
with pytest.raises(ValueError, match="mutually exclusive"):
|
|
await authoring.add_step_from_capability(
|
|
workspace_id="echo",
|
|
revision=1,
|
|
step_id="other",
|
|
capability_name="demo.personal.echo_tool",
|
|
routes={"ok": "__end__"},
|
|
input_map={},
|
|
input_bindings=[],
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize(
|
|
("desc", "retry", "timeout_seconds"),
|
|
[
|
|
("", None, None),
|
|
(None, -1, None),
|
|
(None, None, 0),
|
|
],
|
|
)
|
|
async def test_add_step_from_capability_rejects_invalid_metadata_atomically(
|
|
tmp_path: Path,
|
|
desc: str | None,
|
|
retry: int | None,
|
|
timeout_seconds: int | None,
|
|
) -> None:
|
|
workspace_id = f"invalid_add_metadata_{desc}_{retry}_{timeout_seconds}"
|
|
draft_api, _service, authoring = _draft_api(
|
|
FileWorkflowArtifactStore(tmp_path / workspace_id),
|
|
register_echo=True,
|
|
)
|
|
await draft_api.create_draft_workspace(
|
|
workspace_id=workspace_id,
|
|
draft=_echo_draft(),
|
|
)
|
|
before = await draft_api.get_draft_workspace(
|
|
workspace_id=workspace_id,
|
|
include_draft=True,
|
|
)
|
|
|
|
with pytest.raises(ValidationError):
|
|
await authoring.add_step_from_capability(
|
|
workspace_id=workspace_id,
|
|
revision=1,
|
|
step_id="other",
|
|
capability_name="demo.personal.echo_tool",
|
|
routes={"ok": "__end__"},
|
|
desc=desc,
|
|
retry=retry,
|
|
timeout_seconds=timeout_seconds,
|
|
)
|
|
|
|
after = await draft_api.get_draft_workspace(
|
|
workspace_id=workspace_id,
|
|
include_draft=True,
|
|
)
|
|
assert after == before
|
|
|
|
|
|
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__"}},
|
|
}
|
|
|
|
|
|
class _Snapshot(BaseModel):
|
|
clicked: bool
|
|
|
|
|
|
class _SnapshotOutput(BaseModel):
|
|
after: _Snapshot
|
|
|
|
|
|
class _SnapshotInput(BaseModel):
|
|
pass
|
|
|
|
|
|
@node(name="snapshot_tool", outcomes=("ok", "skipped"))
|
|
def _snapshot_tool(payload: _SnapshotInput) -> _SnapshotOutput:
|
|
return _SnapshotOutput(after=_Snapshot(clicked=True))
|
|
|
|
|
|
class _ReportInputValue(BaseModel):
|
|
title: str
|
|
|
|
|
|
class _NestedReportInput(BaseModel):
|
|
report: _ReportInputValue
|
|
|
|
|
|
class _ReportOutputValue(BaseModel):
|
|
title: str
|
|
markdown: str
|
|
|
|
|
|
class _NestedReportOutput(BaseModel):
|
|
report: _ReportOutputValue
|
|
|
|
|
|
@node(name="nested_report", outcomes=("ok",))
|
|
def _nested_report(payload: _NestedReportInput) -> _NestedReportOutput:
|
|
return _NestedReportOutput(
|
|
report=_ReportOutputValue(
|
|
title=payload.report.title,
|
|
markdown=f"# {payload.report.title}",
|
|
)
|
|
)
|
|
|
|
|
|
def _nested_report_draft() -> dict[str, Any]:
|
|
return {
|
|
"name": "nested_report",
|
|
"input_schema": {"type": "object", "properties": {}},
|
|
"state_schema": {"type": "object", "properties": {}},
|
|
"output_schema": {"type": "object", "properties": {}},
|
|
"start": "render",
|
|
"steps": {
|
|
"render": {
|
|
"use": "demo.personal.nested_report",
|
|
"input": [],
|
|
"output": [],
|
|
}
|
|
},
|
|
"routes": {"render": {"ok": "__end__"}},
|
|
}
|
|
|
|
|
|
class _StructuredRequest(BaseModel):
|
|
title: str
|
|
body: str
|
|
format: Literal["markdown", "json"]
|
|
note: str | None = None
|
|
|
|
|
|
class _StructuredAudit(BaseModel):
|
|
title: str = ""
|
|
|
|
|
|
class _StructuredReportInput(BaseModel):
|
|
request: _StructuredRequest
|
|
audit: _StructuredAudit = Field(default_factory=_StructuredAudit)
|
|
|
|
|
|
class _StructuredReportOutput(BaseModel):
|
|
rendered: str
|
|
|
|
|
|
@node(name="structured_report", outcomes=("ok",))
|
|
def _structured_report(payload: _StructuredReportInput) -> _StructuredReportOutput:
|
|
return _StructuredReportOutput(
|
|
rendered=(
|
|
f"{payload.request.title}|{payload.request.body}|{payload.request.format}"
|
|
)
|
|
)
|
|
|
|
|
|
def _structured_report_draft(
|
|
capability_name: str = "demo.personal.structured_report",
|
|
) -> dict[str, Any]:
|
|
return {
|
|
"name": "structured_report",
|
|
"input_schema": {"type": "object", "properties": {}},
|
|
"state_schema": {"type": "object", "properties": {}},
|
|
"output_schema": {"type": "object", "properties": {}},
|
|
"start": "report",
|
|
"steps": {
|
|
"report": {
|
|
"use": capability_name,
|
|
"input": [],
|
|
"output": [],
|
|
}
|
|
},
|
|
"routes": {"report": {"ok": "__end__"}},
|
|
}
|
|
|
|
|
|
def _draft_api(
|
|
artifact_store: FileWorkflowArtifactStore,
|
|
*,
|
|
register_echo: bool = False,
|
|
) -> tuple[WorkflowDraftApi, WfMcpService, WorkflowDraftAuthoringApi]:
|
|
mcp_root = artifact_store.root / "drafts_mcp" / str(id(artifact_store))
|
|
service = WfMcpService(
|
|
store=FileStore(mcp_root),
|
|
artifact_store=artifact_store,
|
|
draft_workspace_store=FileDraftWorkspaceStore(mcp_root),
|
|
)
|
|
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,
|
|
WorkflowDraftAuthoringApi(context, WorkflowDraftApi(context)),
|
|
)
|
|
|
|
|
|
async def _create_structured_binding_api(
|
|
tmp_path: Path,
|
|
workspace_id: str,
|
|
) -> tuple[WorkflowDraftApi, WfMcpService, WorkflowApi]:
|
|
draft_api, service, authoring = _draft_api(
|
|
FileWorkflowArtifactStore(tmp_path / workspace_id),
|
|
register_echo=True,
|
|
)
|
|
service.register_specs("demo.personal", _structured_report)
|
|
await draft_api.create_draft_workspace(
|
|
workspace_id=workspace_id,
|
|
draft=_structured_report_draft(),
|
|
)
|
|
return draft_api, service, WorkflowApi(authoring.context, drafts=True)
|
|
|
|
|
|
async def _create_nested_output_binding_api(
|
|
tmp_path: Path,
|
|
workspace_id: str,
|
|
) -> tuple[WorkflowDraftApi, WfMcpService, WorkflowApi]:
|
|
draft_api, service, authoring = _draft_api(
|
|
FileWorkflowArtifactStore(tmp_path / workspace_id),
|
|
register_echo=True,
|
|
)
|
|
service.register_specs(
|
|
"demo.personal",
|
|
replace(
|
|
_nested_report,
|
|
output_schema_contract={
|
|
"type": "object",
|
|
"properties": {
|
|
"report": {
|
|
"type": "object",
|
|
"properties": {
|
|
"title": {"type": "string"},
|
|
"markdown": {"type": "string"},
|
|
},
|
|
"required": ["title", "markdown"],
|
|
}
|
|
},
|
|
"required": ["report"],
|
|
},
|
|
),
|
|
)
|
|
await draft_api.create_draft_workspace(
|
|
workspace_id=workspace_id,
|
|
draft=_nested_report_draft(),
|
|
)
|
|
return draft_api, service, WorkflowApi(authoring.context, drafts=True)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_patch_draft_applies_json_patch(tmp_path: Path) -> None:
|
|
artifact_store = FileWorkflowArtifactStore(tmp_path / "drafts_patch")
|
|
api, _service, _authoring = _draft_api(artifact_store, register_echo=True)
|
|
|
|
result = await api.patch_draft(
|
|
draft=_echo_draft(),
|
|
patch=[
|
|
{
|
|
"op": "replace",
|
|
"path": "/steps/echo/input/0/target/parts/0",
|
|
"value": "message",
|
|
}
|
|
],
|
|
)
|
|
|
|
assert result["status"] == "invalid"
|
|
assert _required_draft(result)["steps"]["echo"]["input"][0]["target"] == {
|
|
"root": "local",
|
|
"parts": ["message"],
|
|
}
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_patch_draft_projects_valid_applied_document(tmp_path: Path) -> None:
|
|
artifact_store = FileWorkflowArtifactStore(tmp_path / "drafts_patch_valid")
|
|
api, _service, _authoring = _draft_api(artifact_store, register_echo=True)
|
|
|
|
result = await api.patch_draft(
|
|
draft=_echo_draft(),
|
|
patch=[{"op": "replace", "path": "/name", "value": "renamed_echo"}],
|
|
)
|
|
|
|
assert result["status"] == "valid"
|
|
assert result["draft"]["name"] == "renamed_echo"
|
|
assert result["compiled_plan"]["name"] == "renamed_echo"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_patch_draft_malformed_patch_has_no_draft(tmp_path: Path) -> None:
|
|
artifact_store = FileWorkflowArtifactStore(tmp_path / "drafts_patch_malformed")
|
|
api, _service, _authoring = _draft_api(artifact_store, register_echo=True)
|
|
|
|
result = await api.patch_draft(
|
|
draft=_echo_draft(),
|
|
patch=[{"op": "remove", "path": "/missing"}],
|
|
)
|
|
|
|
assert result["status"] == "invalid"
|
|
assert result["diagnostics"][0]["code"] == "patch_invalid"
|
|
assert "draft" not in result
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_create_draft_workspace_creates_workspace(tmp_path: Path) -> None:
|
|
artifact_store = FileWorkflowArtifactStore(tmp_path / "drafts_create_workspace")
|
|
api, _service, _authoring = _draft_api(artifact_store)
|
|
|
|
result = await api.create_draft_workspace(
|
|
workspace_id="echo_ws",
|
|
title="Echo Workspace",
|
|
draft=_echo_draft(),
|
|
)
|
|
|
|
assert result["workspace_id"] == "echo_ws"
|
|
assert result["revision"] == 1
|
|
assert _required_draft(result)["steps"]["echo"]["use"] == "demo.personal.echo_tool"
|
|
fetched = await api.get_draft_workspace(workspace_id="echo_ws", include_draft=True)
|
|
|
|
assert fetched["workspace_id"] == "echo_ws"
|
|
assert fetched["title"] == "Echo Workspace"
|
|
assert fetched["draft"]["steps"]["echo"]["use"] == "demo.personal.echo_tool"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_invalid_summary_values_remain_inspectable_and_listable(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
artifact_store = FileWorkflowArtifactStore(tmp_path / "invalid_summary_values")
|
|
api, _service, _authoring = _draft_api(artifact_store)
|
|
draft = _echo_draft()
|
|
draft["name"] = 123
|
|
draft["start"] = ["echo"]
|
|
|
|
created = await api.create_draft_workspace(
|
|
workspace_id="repairable",
|
|
draft=draft,
|
|
)
|
|
listed = await api.list_draft_workspaces()
|
|
|
|
assert created["status"] == "invalid"
|
|
assert created["summary"]["name"] == 123
|
|
assert created["summary"]["start"] == ["echo"]
|
|
assert listed["workspaces"][0]["workspace_id"] == "repairable"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_create_empty_draft_workspace_persists_invalid_skeleton(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
artifact_store = FileWorkflowArtifactStore(tmp_path / "drafts_create_empty")
|
|
_drafts, _service, authoring = _draft_api(artifact_store)
|
|
facade = WorkflowApi(authoring.context, drafts=True)
|
|
|
|
created = await facade.create_empty_draft_workspace(
|
|
workspace_id="control_first",
|
|
name="control_first",
|
|
title="Control First",
|
|
)
|
|
stored = await facade.get_draft_workspace(
|
|
workspace_id="control_first",
|
|
include_draft=True,
|
|
)
|
|
|
|
assert created["revision"] == 1
|
|
assert created["status"] == "invalid"
|
|
assert created["diagnostics"]
|
|
assert stored["title"] == "Control First"
|
|
assert stored["draft"] == {
|
|
"name": "control_first",
|
|
"input_schema": {"type": "object", "properties": {}},
|
|
"state_schema": {"type": "object", "properties": {}},
|
|
"output_schema": {"type": "object", "properties": {}},
|
|
"outcomes": ["ok"],
|
|
"output": [],
|
|
"start": "",
|
|
"steps": {},
|
|
"routes": {},
|
|
}
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_create_empty_draft_workspace_preserves_custom_contract(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
artifact_store = FileWorkflowArtifactStore(tmp_path / "drafts_create_contract")
|
|
_drafts, _service, authoring = _draft_api(artifact_store)
|
|
facade = WorkflowApi(authoring.context, drafts=True)
|
|
input_schema = {
|
|
"type": "object",
|
|
"properties": {"topic": {"type": "string"}},
|
|
}
|
|
state_schema = {
|
|
"type": "object",
|
|
"properties": {
|
|
"issues": {
|
|
"type": "array",
|
|
"items": {"type": "string"},
|
|
"x-reducer": "append",
|
|
}
|
|
},
|
|
}
|
|
output_schema = {
|
|
"type": "object",
|
|
"properties": {"report": {"type": "string"}},
|
|
}
|
|
|
|
await facade.create_empty_draft_workspace(
|
|
workspace_id="custom_contract",
|
|
name="custom_contract",
|
|
title="Custom Contract",
|
|
input_schema=input_schema,
|
|
state_schema=state_schema,
|
|
output_schema=output_schema,
|
|
outcomes=("submitted", "cancelled"),
|
|
)
|
|
input_schema["properties"]["late"] = {"type": "boolean"}
|
|
stored = await facade.get_draft_workspace(
|
|
workspace_id="custom_contract",
|
|
include_draft=True,
|
|
)
|
|
|
|
assert stored["title"] == "Custom Contract"
|
|
assert stored["draft"]["input_schema"] == {
|
|
"type": "object",
|
|
"properties": {"topic": {"type": "string"}},
|
|
}
|
|
assert stored["draft"]["state_schema"] == state_schema
|
|
assert stored["draft"]["output_schema"] == output_schema
|
|
assert stored["draft"]["outcomes"] == ["submitted", "cancelled"]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_create_empty_draft_workspace_isolates_default_schemas(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
artifact_store = FileWorkflowArtifactStore(tmp_path / "drafts_schema_isolation")
|
|
_drafts, _service, authoring = _draft_api(artifact_store)
|
|
facade = WorkflowApi(authoring.context, drafts=True)
|
|
input_schema = {
|
|
"type": "object",
|
|
"properties": {"topic": {"type": "string"}},
|
|
}
|
|
|
|
await facade.create_empty_draft_workspace(
|
|
workspace_id="isolated",
|
|
name="isolated",
|
|
input_schema=input_schema,
|
|
)
|
|
input_schema["properties"]["late"] = {"type": "boolean"}
|
|
stored = await facade.get_draft_workspace(
|
|
workspace_id="isolated",
|
|
include_draft=True,
|
|
)
|
|
stored["draft"]["state_schema"]["properties"]["state_only"] = {"type": "string"}
|
|
|
|
assert stored["draft"]["input_schema"] == {
|
|
"type": "object",
|
|
"properties": {"topic": {"type": "string"}},
|
|
}
|
|
assert stored["draft"]["output_schema"] == {
|
|
"type": "object",
|
|
"properties": {},
|
|
}
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_create_empty_draft_workspace_reports_duplicate_conflict(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
artifact_store = FileWorkflowArtifactStore(tmp_path / "drafts_create_conflict")
|
|
_drafts, _service, authoring = _draft_api(artifact_store)
|
|
facade = WorkflowApi(authoring.context, drafts=True)
|
|
await facade.create_empty_draft_workspace(
|
|
workspace_id="control_first",
|
|
name="control_first",
|
|
)
|
|
|
|
duplicate = await facade.create_empty_draft_workspace(
|
|
workspace_id="control_first",
|
|
name="replacement",
|
|
)
|
|
|
|
assert duplicate["status"] == "conflict"
|
|
assert duplicate["diagnostics"][0]["code"] == "workspace_exists"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize(
|
|
"contract",
|
|
[
|
|
{"input_schema": cast(Any, [])},
|
|
{"input_schema": {"type": 5}},
|
|
{"outcomes": ()},
|
|
{"outcomes": cast(Any, (1,))},
|
|
{"outcomes": ("ok", " ")},
|
|
{"outcomes": ("ok", "ok")},
|
|
{"outcomes": ("ok", " ok ")},
|
|
],
|
|
)
|
|
async def test_create_empty_draft_workspace_rejects_invalid_contract_before_mutation(
|
|
tmp_path: Path,
|
|
contract: dict[str, Any],
|
|
) -> None:
|
|
artifact_store = FileWorkflowArtifactStore(tmp_path / "drafts_create_rejected")
|
|
_drafts, _service, authoring = _draft_api(artifact_store)
|
|
facade = WorkflowApi(authoring.context, drafts=True)
|
|
|
|
with pytest.raises(ValueError):
|
|
await facade.create_empty_draft_workspace(
|
|
workspace_id="rejected",
|
|
name="rejected",
|
|
**contract,
|
|
)
|
|
|
|
assert await facade.list_draft_workspaces() == {"workspaces": []}
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_set_draft_start_and_contract_replace_top_level_fields_atomically(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
artifact_store = FileWorkflowArtifactStore(tmp_path / "drafts_set_lifecycle")
|
|
_drafts, _service, authoring = _draft_api(artifact_store)
|
|
facade = WorkflowApi(authoring.context, drafts=True)
|
|
await facade.create_empty_draft_workspace(
|
|
workspace_id="control_first",
|
|
name="control_first",
|
|
input_schema={
|
|
"type": "object",
|
|
"properties": {"topic": {"type": "string"}},
|
|
},
|
|
)
|
|
state_schema = {
|
|
"type": "object",
|
|
"properties": {
|
|
"issues": {
|
|
"type": "array",
|
|
"items": {"type": "string"},
|
|
"x-reducer": "append",
|
|
}
|
|
},
|
|
}
|
|
output_schema = {
|
|
"type": "object",
|
|
"properties": {"report": {"type": "string"}},
|
|
}
|
|
|
|
forward = await facade.set_draft_start(
|
|
workspace_id="control_first",
|
|
revision=1,
|
|
step_id="gate",
|
|
)
|
|
contract = await facade.set_draft_contract(
|
|
workspace_id="control_first",
|
|
revision=2,
|
|
state_schema=state_schema,
|
|
output_schema=output_schema,
|
|
outcomes=("submitted", "cancelled"),
|
|
)
|
|
stored = await facade.get_draft_workspace(
|
|
workspace_id="control_first",
|
|
include_draft=True,
|
|
)
|
|
|
|
assert forward["revision"] == 2
|
|
assert forward["status"] == "invalid"
|
|
assert contract["revision"] == 3
|
|
assert stored["draft"]["start"] == "gate"
|
|
assert stored["draft"]["input_schema"] == {
|
|
"type": "object",
|
|
"properties": {"topic": {"type": "string"}},
|
|
}
|
|
assert stored["draft"]["state_schema"] == state_schema
|
|
assert stored["draft"]["output_schema"] == output_schema
|
|
assert stored["draft"]["outcomes"] == ["submitted", "cancelled"]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize(
|
|
("operation", "arguments"),
|
|
[
|
|
("start", {"step_id": " "}),
|
|
("start", {"step_id": cast(Any, 1)}),
|
|
("contract", {}),
|
|
("contract", {"state_schema": cast(Any, [])}),
|
|
("contract", {"outcomes": ()}),
|
|
("contract", {"outcomes": cast(Any, (1,))}),
|
|
("contract", {"outcomes": ("ok", " ")}),
|
|
("contract", {"outcomes": ("ok", "ok")}),
|
|
],
|
|
)
|
|
async def test_lifecycle_edits_reject_invalid_envelopes_without_mutation(
|
|
tmp_path: Path,
|
|
operation: str,
|
|
arguments: dict[str, Any],
|
|
) -> None:
|
|
artifact_store = FileWorkflowArtifactStore(
|
|
tmp_path / f"drafts_lifecycle_rejected_{operation}"
|
|
)
|
|
_drafts, _service, authoring = _draft_api(artifact_store)
|
|
facade = WorkflowApi(authoring.context, drafts=True)
|
|
await facade.create_empty_draft_workspace(
|
|
workspace_id="control_first",
|
|
name="control_first",
|
|
)
|
|
before = await facade.get_draft_workspace(
|
|
workspace_id="control_first",
|
|
include_draft=True,
|
|
)
|
|
|
|
with pytest.raises(ValueError):
|
|
if operation == "start":
|
|
await facade.set_draft_start(
|
|
workspace_id="control_first",
|
|
revision=2,
|
|
**arguments,
|
|
)
|
|
else:
|
|
await facade.set_draft_contract(
|
|
workspace_id="control_first",
|
|
revision=2,
|
|
**arguments,
|
|
)
|
|
|
|
after = await facade.get_draft_workspace(
|
|
workspace_id="control_first",
|
|
include_draft=True,
|
|
)
|
|
assert after == before
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize("operation", ["start", "contract"])
|
|
async def test_lifecycle_edits_report_stale_revision_without_mutation(
|
|
tmp_path: Path,
|
|
operation: str,
|
|
) -> None:
|
|
artifact_store = FileWorkflowArtifactStore(
|
|
tmp_path / f"drafts_lifecycle_stale_{operation}"
|
|
)
|
|
_drafts, _service, authoring = _draft_api(artifact_store)
|
|
facade = WorkflowApi(authoring.context, drafts=True)
|
|
await facade.create_empty_draft_workspace(
|
|
workspace_id="control_first",
|
|
name="control_first",
|
|
)
|
|
before = await facade.get_draft_workspace(
|
|
workspace_id="control_first",
|
|
include_draft=True,
|
|
)
|
|
|
|
if operation == "start":
|
|
result = await facade.set_draft_start(
|
|
workspace_id="control_first",
|
|
revision=2,
|
|
step_id="gate",
|
|
)
|
|
else:
|
|
result = await facade.set_draft_contract(
|
|
workspace_id="control_first",
|
|
revision=2,
|
|
outcomes=("submitted", "cancelled"),
|
|
)
|
|
|
|
after = await facade.get_draft_workspace(
|
|
workspace_id="control_first",
|
|
include_draft=True,
|
|
)
|
|
assert result["status"] == "conflict"
|
|
assert result["diagnostics"][0]["code"] == "revision_conflict"
|
|
assert after == before
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_list_draft_workspaces_returns_sorted_summaries_without_drafts(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
artifact_store = FileWorkflowArtifactStore(tmp_path / "drafts_list_workspaces")
|
|
api, _service, _authoring = _draft_api(artifact_store)
|
|
await api.create_draft_workspace(
|
|
workspace_id="b_draft",
|
|
title="B Draft",
|
|
draft=_echo_draft(),
|
|
)
|
|
|
|
await api.create_draft_workspace(
|
|
workspace_id="a_draft",
|
|
title="A Draft",
|
|
draft=_echo_draft(),
|
|
)
|
|
|
|
result = await api.list_draft_workspaces()
|
|
|
|
assert [workspace["workspace_id"] for workspace in result["workspaces"]] == [
|
|
"a_draft",
|
|
"b_draft",
|
|
]
|
|
assert result["workspaces"][0]["title"] == "A Draft"
|
|
assert "draft" not in result["workspaces"][0]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_delete_draft_workspace_is_idempotent(tmp_path: Path) -> None:
|
|
artifact_store = FileWorkflowArtifactStore(tmp_path / "drafts_delete_workspace")
|
|
api, _service, _authoring = _draft_api(artifact_store)
|
|
await api.create_draft_workspace(
|
|
workspace_id="echo_ws",
|
|
draft=_echo_draft(),
|
|
)
|
|
|
|
deleted = await api.delete_draft_workspace(workspace_id="echo_ws")
|
|
deleted_again = await api.delete_draft_workspace(workspace_id="echo_ws")
|
|
listed = await api.list_draft_workspaces()
|
|
|
|
assert deleted["workspace_id"] == "echo_ws"
|
|
assert deleted["deleted"] is True
|
|
assert deleted["status"] == "deleted"
|
|
assert deleted_again["workspace_id"] == "echo_ws"
|
|
assert deleted_again["deleted"] is False
|
|
assert deleted_again["status"] == "not_found"
|
|
assert listed["workspaces"] == []
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_patch_draft_workspace_updates_revision(tmp_path: Path) -> None:
|
|
artifact_store = FileWorkflowArtifactStore(tmp_path / "drafts_patch_workspace")
|
|
api, _service, _authoring = _draft_api(artifact_store, register_echo=True)
|
|
await api.create_draft_workspace(
|
|
workspace_id="echo_ws",
|
|
draft=_echo_draft(),
|
|
)
|
|
|
|
patched = await 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"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_patch_draft_workspace_stale_revision_does_not_mutate(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
artifact_store = FileWorkflowArtifactStore(
|
|
tmp_path / "drafts_patch_workspace_stale"
|
|
)
|
|
api, _service, _authoring = _draft_api(artifact_store, register_echo=True)
|
|
await api.create_draft_workspace(
|
|
workspace_id="echo_ws",
|
|
draft=_echo_draft(),
|
|
)
|
|
before = await api.get_draft_workspace(
|
|
workspace_id="echo_ws",
|
|
include_draft=True,
|
|
)
|
|
|
|
result = await api.patch_draft_workspace(
|
|
workspace_id="echo_ws",
|
|
revision=2,
|
|
patch=[{"op": "replace", "path": "/name", "value": "must_not_apply"}],
|
|
)
|
|
|
|
after = await api.get_draft_workspace(
|
|
workspace_id="echo_ws",
|
|
include_draft=True,
|
|
)
|
|
assert result["status"] == "conflict"
|
|
assert result["diagnostics"][0]["code"] == "revision_conflict"
|
|
assert after == before
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_draft_workspace_patch_helpers_update_revision_and_bindings(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
artifact_store = FileWorkflowArtifactStore(tmp_path / "drafts_patch_helpers")
|
|
api, _service, _authoring = _draft_api(artifact_store)
|
|
await api.create_draft_workspace(
|
|
workspace_id="echo_ws",
|
|
draft=_echo_draft(),
|
|
)
|
|
|
|
named = await api.set_draft_name(
|
|
workspace_id="echo_ws",
|
|
revision=1,
|
|
name="echo_v2",
|
|
)
|
|
routed = await api.set_draft_route(
|
|
workspace_id="echo_ws",
|
|
revision=2,
|
|
step_id="echo",
|
|
outcome="error",
|
|
target="__end__",
|
|
)
|
|
input_mapped = await api.set_step_input_map(
|
|
workspace_id="echo_ws",
|
|
revision=3,
|
|
step_id="echo",
|
|
input_map={"input.text": "message"},
|
|
)
|
|
|
|
output_mapped = await api.set_step_output_map(
|
|
workspace_id="echo_ws",
|
|
revision=4,
|
|
step_id="echo",
|
|
output_map={"echoed": "state.echoed"},
|
|
)
|
|
fetched = await api.get_draft_workspace(workspace_id="echo_ws", include_draft=True)
|
|
|
|
assert named["revision"] == 2
|
|
assert routed["revision"] == 3
|
|
assert input_mapped["revision"] == 4
|
|
assert output_mapped["revision"] == 5
|
|
assert _required_draft(routed)["routes"]["echo"]["error"] == "__end__"
|
|
assert fetched["draft"]["name"] == "echo_v2"
|
|
assert fetched["draft"]["routes"]["echo"]["error"] == "__end__"
|
|
assert fetched["draft"]["steps"]["echo"]["input"] == [
|
|
{
|
|
"target": "message",
|
|
"path": "input.text",
|
|
}
|
|
]
|
|
assert fetched["draft"]["steps"]["echo"]["output"] == [
|
|
{
|
|
"source": "echoed",
|
|
"target": "state.echoed",
|
|
}
|
|
]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_step_map_helpers_merge_with_existing_bindings(tmp_path: Path) -> None:
|
|
artifact_store = FileWorkflowArtifactStore(tmp_path / "drafts_patch_helper_merge")
|
|
api, _service, _authoring = _draft_api(artifact_store)
|
|
await api.create_draft_workspace(
|
|
workspace_id="echo_ws",
|
|
draft=_echo_draft(),
|
|
)
|
|
|
|
input_mapped = await api.set_step_input_map(
|
|
workspace_id="echo_ws",
|
|
revision=1,
|
|
step_id="echo",
|
|
input_map={"input.extra": "extra"},
|
|
merge=True,
|
|
)
|
|
output_mapped = await api.set_step_output_map(
|
|
workspace_id="echo_ws",
|
|
revision=2,
|
|
step_id="echo",
|
|
output_map={"extra": "state.extra"},
|
|
merge=True,
|
|
)
|
|
replaced = await api.set_step_input_map(
|
|
workspace_id="echo_ws",
|
|
revision=3,
|
|
step_id="echo",
|
|
input_map={"input.final": "final"},
|
|
)
|
|
fetched = await api.get_draft_workspace(workspace_id="echo_ws", include_draft=True)
|
|
|
|
assert input_mapped["revision"] == 2
|
|
assert output_mapped["revision"] == 3
|
|
assert replaced["revision"] == 4
|
|
assert fetched["draft"]["steps"]["echo"]["input"] == [
|
|
{
|
|
"target": "final",
|
|
"path": "input.final",
|
|
}
|
|
]
|
|
assert fetched["draft"]["steps"]["echo"]["output"] == [
|
|
{
|
|
"source": "echoed",
|
|
"target": "state.echoed",
|
|
},
|
|
{
|
|
"source": "extra",
|
|
"target": "state.extra",
|
|
},
|
|
]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_step_input_map_merge_preserves_literal_prefix(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
api, _service, _authoring = _draft_api(
|
|
FileWorkflowArtifactStore(tmp_path / "input_merge_literal_prefix")
|
|
)
|
|
draft = _echo_draft()
|
|
draft["steps"]["echo"]["input"] = [
|
|
{"value": "seed", "target": "fallback"},
|
|
{"path": "input.text", "target": "text"},
|
|
]
|
|
await api.create_draft_workspace(workspace_id="echo_ws", draft=draft)
|
|
|
|
result = await api.set_step_input_map(
|
|
workspace_id="echo_ws",
|
|
revision=1,
|
|
step_id="echo",
|
|
input_map={"input.extra": "extra"},
|
|
merge=True,
|
|
)
|
|
|
|
fetched = await api.get_draft_workspace(
|
|
workspace_id="echo_ws",
|
|
include_draft=True,
|
|
)
|
|
assert result["revision"] == 2
|
|
assert fetched["draft"]["steps"]["echo"]["input"] == [
|
|
{"value": "seed", "target": "fallback"},
|
|
{"path": "input.text", "target": "text"},
|
|
{"path": "input.extra", "target": "extra"},
|
|
]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_step_input_map_merge_rejects_canonical_source_fan_out(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
artifact_store = FileWorkflowArtifactStore(tmp_path / "input_merge_fan_out")
|
|
api, _service, _authoring = _draft_api(artifact_store)
|
|
draft = _echo_draft()
|
|
draft["steps"]["echo"]["input"] = [
|
|
{"path": "input.text", "target": "message"},
|
|
{"path": "input.text", "target": "audit.message"},
|
|
]
|
|
await api.create_draft_workspace(workspace_id="echo_ws", draft=draft)
|
|
before = await api.get_draft_workspace(
|
|
workspace_id="echo_ws",
|
|
include_draft=True,
|
|
)
|
|
|
|
with pytest.raises(ValueError, match="complete canonical binding list"):
|
|
await api.set_step_input_map(
|
|
workspace_id="echo_ws",
|
|
revision=1,
|
|
step_id="echo",
|
|
input_map={"input.extra": "extra"},
|
|
merge=True,
|
|
)
|
|
|
|
after = await api.get_draft_workspace(
|
|
workspace_id="echo_ws",
|
|
include_draft=True,
|
|
)
|
|
assert after == before
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_step_input_map_merge_rejects_path_before_literal(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
artifact_store = FileWorkflowArtifactStore(tmp_path / "input_merge_order")
|
|
api, _service, _authoring = _draft_api(artifact_store)
|
|
draft = _echo_draft()
|
|
draft["steps"]["echo"]["input"] = [
|
|
{"path": "input.text", "target": "message"},
|
|
{"value": "markdown", "target": "format"},
|
|
]
|
|
await api.create_draft_workspace(workspace_id="echo_ws", draft=draft)
|
|
before = await api.get_draft_workspace(
|
|
workspace_id="echo_ws",
|
|
include_draft=True,
|
|
)
|
|
|
|
with pytest.raises(ValueError, match="complete canonical binding list"):
|
|
await api.set_step_input_map(
|
|
workspace_id="echo_ws",
|
|
revision=1,
|
|
step_id="echo",
|
|
input_map={"input.extra": "extra"},
|
|
merge=True,
|
|
)
|
|
|
|
after = await api.get_draft_workspace(
|
|
workspace_id="echo_ws",
|
|
include_draft=True,
|
|
)
|
|
assert after == before
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_step_input_map_merge_checks_revision_before_lossless_preflight(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
artifact_store = FileWorkflowArtifactStore(tmp_path / "input_merge_stale")
|
|
api, _service, _authoring = _draft_api(artifact_store)
|
|
draft = _echo_draft()
|
|
draft["steps"]["echo"]["input"] = [
|
|
{"path": "input.text", "target": "message"},
|
|
{"path": "input.text", "target": "audit.message"},
|
|
]
|
|
await api.create_draft_workspace(workspace_id="echo_ws", draft=draft)
|
|
await api.set_draft_name(
|
|
workspace_id="echo_ws",
|
|
revision=1,
|
|
name="echo_v2",
|
|
)
|
|
before = await api.get_draft_workspace(
|
|
workspace_id="echo_ws",
|
|
include_draft=True,
|
|
)
|
|
|
|
result = await api.set_step_input_map(
|
|
workspace_id="echo_ws",
|
|
revision=1,
|
|
step_id="echo",
|
|
input_map={"input.extra": "extra"},
|
|
merge=True,
|
|
)
|
|
|
|
after = await api.get_draft_workspace(
|
|
workspace_id="echo_ws",
|
|
include_draft=True,
|
|
)
|
|
assert result["status"] == "conflict"
|
|
assert result["diagnostics"][0]["code"] == "revision_conflict"
|
|
assert after == before
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_step_output_map_merge_rejects_canonical_source_fan_out(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
artifact_store = FileWorkflowArtifactStore(tmp_path / "output_merge_fan_out")
|
|
api, _service, _authoring = _draft_api(artifact_store)
|
|
draft = _echo_draft()
|
|
draft["state_schema"] = {
|
|
"fields": {
|
|
"echoed": {"type": "string"},
|
|
"other": {"type": "string"},
|
|
}
|
|
}
|
|
draft["steps"]["echo"]["output"] = [
|
|
{"source": "echoed", "target": "state.echoed"},
|
|
{"source": "echoed", "target": "state.other"},
|
|
]
|
|
await api.create_draft_workspace(workspace_id="echo_ws", draft=draft)
|
|
before = await api.get_draft_workspace(
|
|
workspace_id="echo_ws",
|
|
include_draft=True,
|
|
)
|
|
|
|
with pytest.raises(ValueError, match="complete canonical binding list"):
|
|
await api.set_step_output_map(
|
|
workspace_id="echo_ws",
|
|
revision=1,
|
|
step_id="echo",
|
|
output_map={"extra": "state.extra"},
|
|
merge=True,
|
|
)
|
|
|
|
after = await api.get_draft_workspace(
|
|
workspace_id="echo_ws",
|
|
include_draft=True,
|
|
)
|
|
assert after == before
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_step_output_map_merge_checks_revision_before_lossless_preflight(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
artifact_store = FileWorkflowArtifactStore(tmp_path / "output_merge_stale")
|
|
api, _service, _authoring = _draft_api(artifact_store)
|
|
draft = _echo_draft()
|
|
draft["steps"]["echo"]["output"] = [
|
|
{"source": "echoed", "target": "state.echoed"},
|
|
{"source": "echoed", "target": "state.other"},
|
|
]
|
|
await api.create_draft_workspace(workspace_id="echo_ws", draft=draft)
|
|
await api.set_draft_name(
|
|
workspace_id="echo_ws",
|
|
revision=1,
|
|
name="echo_v2",
|
|
)
|
|
before = await api.get_draft_workspace(
|
|
workspace_id="echo_ws",
|
|
include_draft=True,
|
|
)
|
|
|
|
result = await api.set_step_output_map(
|
|
workspace_id="echo_ws",
|
|
revision=1,
|
|
step_id="echo",
|
|
output_map={"extra": "state.extra"},
|
|
merge=True,
|
|
)
|
|
|
|
after = await api.get_draft_workspace(
|
|
workspace_id="echo_ws",
|
|
include_draft=True,
|
|
)
|
|
assert result["status"] == "conflict"
|
|
assert result["diagnostics"][0]["code"] == "revision_conflict"
|
|
assert after == before
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_validate_draft_workspace_refreshes_status(tmp_path: Path) -> None:
|
|
artifact_store = FileWorkflowArtifactStore(tmp_path / "drafts_validate_workspace")
|
|
api, service, authoring = _draft_api(artifact_store, register_echo=True)
|
|
draft = _echo_draft()
|
|
draft["routes"]["echo"] = {"typo": "__end__"}
|
|
await api.create_draft_workspace(
|
|
workspace_id="echo_ws",
|
|
draft=draft,
|
|
)
|
|
|
|
payload = await api.validate_draft_workspace(workspace_id="echo_ws")
|
|
fetched = await api.get_draft_workspace(workspace_id="echo_ws")
|
|
|
|
assert payload["revision"] == 1
|
|
assert payload["status"] == "invalid"
|
|
assert _required_draft(payload)["routes"]["echo"] == {"typo": "__end__"}
|
|
assert payload["diagnostics"][0]["code"] in (
|
|
"unknown_outcome",
|
|
"undeclared_edge_outcome",
|
|
)
|
|
assert fetched["status"] == "invalid"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_replace_draft_workspace_document_uses_current_capability_definitions(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
api, _service, _authoring = _draft_api(
|
|
FileWorkflowArtifactStore(tmp_path / "replace_document_capability_defs"),
|
|
register_echo=True,
|
|
)
|
|
await api.create_draft_workspace(
|
|
workspace_id="report",
|
|
draft=_echo_draft(),
|
|
)
|
|
replacement = _echo_draft()
|
|
replacement["name"] = "replacement"
|
|
replacement["steps"]["echo"]["output"] = [
|
|
{"source": "missing", "target": "state.echoed"}
|
|
]
|
|
|
|
result = await api.replace_draft_workspace_document(
|
|
workspace_id="report",
|
|
revision=1,
|
|
draft=replacement,
|
|
)
|
|
|
|
assert result["revision"] == 2
|
|
assert result["status"] == "invalid"
|
|
assert any(
|
|
"missing" in str(diagnostic.get("message", ""))
|
|
for diagnostic in result["diagnostics"]
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_replace_draft_workspace_document_refreshes_semantic_diagnostics(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
api, _service, _authoring = _draft_api(
|
|
FileWorkflowArtifactStore(tmp_path / "replace_document_diagnostics"),
|
|
register_echo=True,
|
|
)
|
|
await api.create_draft_workspace(
|
|
workspace_id="report",
|
|
draft=_echo_draft(),
|
|
)
|
|
before = await api.get_draft_workspace(
|
|
workspace_id="report",
|
|
include_draft=True,
|
|
)
|
|
replacement = _echo_draft()
|
|
replacement["routes"] = {"echo": {"ok": "missing_step"}}
|
|
|
|
result = await api.replace_draft_workspace_document(
|
|
workspace_id="report",
|
|
revision=1,
|
|
draft=replacement,
|
|
)
|
|
after = await api.get_draft_workspace(
|
|
workspace_id="report",
|
|
include_draft=True,
|
|
)
|
|
|
|
assert before["diagnostics"] == []
|
|
assert result["status"] == "invalid"
|
|
assert result["diagnostics"]
|
|
assert after["diagnostics"] == result["diagnostics"]
|
|
assert after["draft"]["routes"] == replacement["routes"]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_validate_draft_workspace_suggests_bind(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
artifact_store = FileWorkflowArtifactStore(tmp_path / "drafts_repair_hint")
|
|
api, service, authoring = _draft_api(artifact_store)
|
|
service.register_connection(
|
|
ConnectionConfig(id="demo.personal", server="demo", account="personal")
|
|
)
|
|
service.register_specs("demo.personal", _snapshot_tool)
|
|
await api.create_draft_workspace(
|
|
workspace_id="snapshot_ws",
|
|
draft={
|
|
"name": "snapshot",
|
|
"input_schema": {"type": "object", "properties": {}},
|
|
"state_schema": {"type": "object", "properties": {}},
|
|
"output_schema": {"type": "object", "properties": {}},
|
|
"start": "snap",
|
|
"steps": {
|
|
"snap": {
|
|
"use": "demo.personal.snapshot_tool",
|
|
"input": [],
|
|
"output": [
|
|
{
|
|
"source": {"root": "local", "parts": ["after"]},
|
|
"target": {"root": "state", "parts": ["after"]},
|
|
}
|
|
],
|
|
}
|
|
},
|
|
"routes": {"snap": {"ok": "__end__"}},
|
|
},
|
|
)
|
|
|
|
payload = await api.validate_draft_workspace(workspace_id="snapshot_ws")
|
|
|
|
diagnostic = payload["diagnostics"][0]
|
|
assert diagnostic["code"] == "invalid_destination_path"
|
|
assert diagnostic.get("step_id") == "snap"
|
|
assert diagnostic.get("repair_hint") == (
|
|
"wf draft bind snapshot_ws --revision 1 "
|
|
"--step snap --from local.after --to state.after"
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_patch_draft_workspace_validates_new_use_step_with_context_specs(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
artifact_store = FileWorkflowArtifactStore(tmp_path / "drafts_patch_new_use")
|
|
api, service, authoring = _draft_api(artifact_store, register_echo=True)
|
|
service.register_specs("demo.personal", echo_tool, _snapshot_tool)
|
|
await api.create_draft_workspace(
|
|
workspace_id="echo_ws",
|
|
draft=_echo_draft(),
|
|
)
|
|
|
|
patched = await api.patch_draft_workspace(
|
|
workspace_id="echo_ws",
|
|
revision=1,
|
|
patch=[
|
|
{
|
|
"op": "add",
|
|
"path": "/steps/snap",
|
|
"value": {
|
|
"use": "demo.personal.snapshot_tool",
|
|
"input": [],
|
|
"output": [
|
|
{
|
|
"source": {"root": "local", "parts": ["after"]},
|
|
"target": {"root": "state", "parts": ["after"]},
|
|
}
|
|
],
|
|
},
|
|
},
|
|
{
|
|
"op": "replace",
|
|
"path": "/routes/echo/ok",
|
|
"value": "snap",
|
|
},
|
|
{
|
|
"op": "add",
|
|
"path": "/routes/snap",
|
|
"value": {"ok": "__end__"},
|
|
},
|
|
],
|
|
)
|
|
|
|
diagnostic = patched["diagnostics"][0]
|
|
assert patched["status"] == "invalid"
|
|
assert diagnostic["code"] == "invalid_destination_path"
|
|
assert diagnostic.get("step_id") == "snap"
|
|
assert diagnostic.get("details") == {
|
|
"output_field": "after",
|
|
"state_path": "state.after",
|
|
}
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_create_minimal_draft_workspace_minimal_success_path(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
artifact_store = FileWorkflowArtifactStore(tmp_path / "drafts_minimal_workspace")
|
|
api, _service, authoring = _draft_api(artifact_store, register_echo=True)
|
|
|
|
result = await authoring.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 = await 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"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_delegation_smoke_validate_draft_equivalence(tmp_path: Path) -> None:
|
|
"""WorkflowSurfaceHandlers.validate_draft delegates to WorkflowDraftApi."""
|
|
artifact_store = FileWorkflowArtifactStore(tmp_path / "drafts_delegation_smoke")
|
|
mcp_root = artifact_store.root / "delegation_mcp"
|
|
service = WfMcpService(
|
|
store=FileStore(mcp_root),
|
|
artifact_store=artifact_store,
|
|
draft_workspace_store=FileDraftWorkspaceStore(mcp_root),
|
|
)
|
|
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 = await h.validate_draft(draft=draft)
|
|
api_result = await api.validate_draft(draft=draft)
|
|
|
|
assert handler_result["status"] == api_result["status"]
|
|
assert handler_result["diagnostics"] == api_result["diagnostics"]
|
|
assert (
|
|
_compiled_workspace(handler_result)["nodes"]
|
|
== _compiled_workspace(api_result)["nodes"]
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_facade_delegates_semantic_authoring_to_authoring_service(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
"""WorkflowApi constructs a sibling WorkflowDraftAuthoringApi."""
|
|
artifact_store = FileWorkflowArtifactStore(tmp_path / "drafts_facade_delegation")
|
|
mcp_root = artifact_store.root / "facade_mcp"
|
|
service = WfMcpService(
|
|
store=FileStore(mcp_root),
|
|
artifact_store=artifact_store,
|
|
draft_workspace_store=FileDraftWorkspaceStore(mcp_root),
|
|
)
|
|
service.register_connection(
|
|
ConnectionConfig(id="demo.personal", server="demo", account="personal")
|
|
)
|
|
service.register_specs("demo.personal", echo_tool, _snapshot_tool)
|
|
|
|
context = context_from_service(service)
|
|
facade = WorkflowApi(context, drafts=True)
|
|
|
|
assert facade.draft_authoring is not None
|
|
assert isinstance(facade.draft_authoring, WorkflowDraftAuthoringApi)
|
|
|
|
await facade.create_draft_workspace(
|
|
workspace_id="ws1",
|
|
draft=_echo_draft(),
|
|
)
|
|
result = await facade.bind_draft(
|
|
workspace_id="ws1",
|
|
revision=1,
|
|
step_id="echo",
|
|
source_path="local.echoed",
|
|
target_path="state.echoed",
|
|
)
|
|
assert result["revision"] == 2
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_bind_draft_workflow_input_to_step_input_projects_input_schema(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
artifact_store = FileWorkflowArtifactStore(tmp_path / "drafts_bind_input")
|
|
api, service, authoring = _draft_api(artifact_store, register_echo=True)
|
|
draft = {**_echo_draft(), "input_schema": {"type": "object", "properties": {}}}
|
|
await api.create_draft_workspace(
|
|
workspace_id="bind_ws",
|
|
draft=draft,
|
|
)
|
|
|
|
result = await authoring.bind_draft(
|
|
workspace_id="bind_ws",
|
|
revision=1,
|
|
step_id="echo",
|
|
source_path="input.text",
|
|
target_path="local.text",
|
|
)
|
|
workspace = await api.get_draft_workspace(
|
|
workspace_id="bind_ws", include_draft=True
|
|
)
|
|
|
|
assert result["revision"] == 2
|
|
assert workspace["draft"]["input_schema"]["properties"]["text"]["type"] == "string"
|
|
assert workspace["draft"]["steps"]["echo"]["input"] == [
|
|
{"target": "text", "path": "input.text"}
|
|
]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_bind_draft_workflow_input_to_step_input_reuses_existing_schema(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
artifact_store = FileWorkflowArtifactStore(
|
|
tmp_path / "drafts_bind_existing_input_schema"
|
|
)
|
|
api, _service, authoring = _draft_api(artifact_store, register_echo=True)
|
|
await api.create_draft_workspace(
|
|
workspace_id="bind_ws",
|
|
draft=_echo_draft(),
|
|
)
|
|
|
|
result = await authoring.bind_draft(
|
|
workspace_id="bind_ws",
|
|
revision=1,
|
|
step_id="echo",
|
|
source_path="input.text",
|
|
target_path="local.text",
|
|
)
|
|
workspace = await api.get_draft_workspace(
|
|
workspace_id="bind_ws", include_draft=True
|
|
)
|
|
|
|
assert result["status"] == "valid", result["diagnostics"]
|
|
assert result["revision"] == 2
|
|
assert workspace["draft"]["steps"]["echo"]["input"] == [
|
|
{"target": "text", "path": "input.text"}
|
|
]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_bind_draft_workflow_input_to_step_input_can_repeat(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
artifact_store = FileWorkflowArtifactStore(tmp_path / "drafts_bind_repeat")
|
|
api, _service, authoring = _draft_api(artifact_store, register_echo=True)
|
|
await api.create_draft_workspace(workspace_id="bind_ws", draft=_echo_draft())
|
|
|
|
first = await authoring.bind_draft(
|
|
workspace_id="bind_ws",
|
|
revision=1,
|
|
step_id="echo",
|
|
source_path="input.text",
|
|
target_path="local.text",
|
|
)
|
|
second = await authoring.bind_draft(
|
|
workspace_id="bind_ws",
|
|
revision=first["revision"],
|
|
step_id="echo",
|
|
source_path="input.text",
|
|
target_path="local.text",
|
|
)
|
|
workspace = await api.get_draft_workspace(
|
|
workspace_id="bind_ws", include_draft=True
|
|
)
|
|
|
|
assert first["status"] == "valid"
|
|
assert second["status"] == "valid"
|
|
assert second["revision"] == 3
|
|
assert workspace["draft"]["steps"]["echo"]["input"] == [
|
|
{"target": "text", "path": "input.text"}
|
|
]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_bind_draft_input_preserves_literals_and_unrelated_fan_out(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
api, service, authoring = _draft_api(
|
|
FileWorkflowArtifactStore(tmp_path / "bind_preserves_canonical_inputs"),
|
|
register_echo=True,
|
|
)
|
|
service.register_specs("demo.personal", _structured_report)
|
|
draft = _structured_report_draft()
|
|
draft["state_schema"] = {
|
|
"type": "object",
|
|
"properties": {"shared": {"type": "string"}},
|
|
}
|
|
draft["steps"]["report"]["input"] = [
|
|
{"path": "state.shared", "target": "request.title"},
|
|
{"path": "state.shared", "target": "request.format"},
|
|
{"value": "audit", "target": "audit.note"},
|
|
]
|
|
await api.create_draft_workspace(workspace_id="report", draft=draft)
|
|
|
|
result = await authoring.bind_draft(
|
|
workspace_id="report",
|
|
revision=1,
|
|
step_id="report",
|
|
source_path="input.body",
|
|
target_path="local.request.body",
|
|
)
|
|
workspace = await api.get_draft_workspace(workspace_id="report", include_draft=True)
|
|
|
|
assert result["revision"] == 2
|
|
assert workspace["draft"]["steps"]["report"]["input"] == [
|
|
{"path": "state.shared", "target": "request.title"},
|
|
{"path": "state.shared", "target": "request.format"},
|
|
{"value": "audit", "target": "audit.note"},
|
|
{"path": "input.body", "target": "request.body"},
|
|
]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_bind_draft_workflow_state_to_step_input_reuses_existing_schema(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
artifact_store = FileWorkflowArtifactStore(
|
|
tmp_path / "drafts_bind_existing_state_schema"
|
|
)
|
|
api, _service, authoring = _draft_api(artifact_store, register_echo=True)
|
|
draft = {
|
|
**_echo_draft(),
|
|
"state_schema": {
|
|
"type": "object",
|
|
"properties": {"text": {"type": "string"}},
|
|
},
|
|
"steps": {
|
|
"echo": {
|
|
"use": "demo.personal.echo_tool",
|
|
"input": [],
|
|
"output": [],
|
|
}
|
|
},
|
|
}
|
|
await api.create_draft_workspace(workspace_id="bind_ws", draft=draft)
|
|
|
|
result = await authoring.bind_draft(
|
|
workspace_id="bind_ws",
|
|
revision=1,
|
|
step_id="echo",
|
|
source_path="state.text",
|
|
target_path="local.text",
|
|
)
|
|
workspace = await api.get_draft_workspace(
|
|
workspace_id="bind_ws", include_draft=True
|
|
)
|
|
|
|
assert result["status"] == "valid", result["diagnostics"]
|
|
assert result["revision"] == 2
|
|
assert workspace["draft"]["steps"]["echo"]["input"] == [
|
|
{"target": "text", "path": "state.text"}
|
|
]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_bind_draft_output_to_nested_state_projects_state_schema(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
artifact_store = FileWorkflowArtifactStore(tmp_path / "drafts_bind_output_nested")
|
|
api, service, authoring = _draft_api(artifact_store, register_echo=True)
|
|
service.register_specs("demo.personal", _snapshot_tool)
|
|
await api.create_draft_workspace(
|
|
workspace_id="snapshot_ws",
|
|
draft={
|
|
"name": "snapshot",
|
|
"input_schema": {"type": "object", "properties": {}},
|
|
"state_schema": {"type": "object", "properties": {}},
|
|
"output_schema": {"type": "object", "properties": {}},
|
|
"start": "snap",
|
|
"steps": {
|
|
"snap": {
|
|
"use": "demo.personal.snapshot_tool",
|
|
"input": [],
|
|
"output": [],
|
|
}
|
|
},
|
|
"routes": {"snap": {"ok": "__end__"}},
|
|
},
|
|
)
|
|
|
|
result = await authoring.bind_draft(
|
|
workspace_id="snapshot_ws",
|
|
revision=1,
|
|
step_id="snap",
|
|
source_path="local.after",
|
|
target_path="state.session.after",
|
|
)
|
|
workspace = await api.get_draft_workspace(
|
|
workspace_id="snapshot_ws", include_draft=True
|
|
)
|
|
|
|
assert result["revision"] == 2
|
|
assert (
|
|
workspace["draft"]["state_schema"]["properties"]["session"]["properties"][
|
|
"after"
|
|
]["$ref"]
|
|
== "#/$defs/_Snapshot"
|
|
)
|
|
assert workspace["draft"]["steps"]["snap"]["output"] == [
|
|
{"source": "after", "target": "state.session.after"}
|
|
]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_bind_draft_projects_nested_local_input_schema(tmp_path: Path) -> None:
|
|
api, service, authoring = _draft_api(
|
|
FileWorkflowArtifactStore(tmp_path / "nested_local_input"),
|
|
register_echo=True,
|
|
)
|
|
service.register_specs("demo.personal", _nested_report)
|
|
await api.create_draft_workspace(
|
|
workspace_id="nested",
|
|
draft=_nested_report_draft(),
|
|
)
|
|
|
|
result = await authoring.bind_draft(
|
|
workspace_id="nested",
|
|
revision=1,
|
|
step_id="render",
|
|
source_path="input.title",
|
|
target_path="local.report.title",
|
|
)
|
|
workspace = await api.get_draft_workspace(workspace_id="nested", include_draft=True)
|
|
draft = workspace["draft"]
|
|
|
|
assert result["revision"] == 2
|
|
assert draft["input_schema"]["properties"]["title"]["type"] == "string"
|
|
assert draft["steps"]["render"]["input"] == [
|
|
{"target": "report.title", "path": "input.title"}
|
|
]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_bind_draft_reuses_nested_state_for_nested_local_input(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
api, service, authoring = _draft_api(
|
|
FileWorkflowArtifactStore(tmp_path / "nested_local_state_input"),
|
|
register_echo=True,
|
|
)
|
|
service.register_specs("demo.personal", _nested_report)
|
|
draft = _nested_report_draft()
|
|
draft["state_schema"] = {
|
|
"type": "object",
|
|
"properties": {
|
|
"report": {
|
|
"type": "object",
|
|
"properties": {"title": {"type": "string"}},
|
|
}
|
|
},
|
|
}
|
|
await api.create_draft_workspace(workspace_id="nested", draft=draft)
|
|
|
|
result = await authoring.bind_draft(
|
|
workspace_id="nested",
|
|
revision=1,
|
|
step_id="render",
|
|
source_path="state.report.title",
|
|
target_path="local.report.title",
|
|
)
|
|
workspace = await api.get_draft_workspace(workspace_id="nested", include_draft=True)
|
|
|
|
assert result["revision"] == 2
|
|
assert workspace["draft"]["steps"]["render"]["input"] == [
|
|
{"target": "report.title", "path": "state.report.title"}
|
|
]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_bind_draft_projects_nested_local_output_to_state(tmp_path: Path) -> None:
|
|
api, service, authoring = _draft_api(
|
|
FileWorkflowArtifactStore(tmp_path / "nested_local_output_state"),
|
|
register_echo=True,
|
|
)
|
|
service.register_specs("demo.personal", _nested_report)
|
|
await api.create_draft_workspace(
|
|
workspace_id="nested",
|
|
draft=_nested_report_draft(),
|
|
)
|
|
|
|
result = await authoring.bind_draft(
|
|
workspace_id="nested",
|
|
revision=1,
|
|
step_id="render",
|
|
source_path="local.report.markdown",
|
|
target_path="state.report.markdown",
|
|
)
|
|
workspace = await api.get_draft_workspace(workspace_id="nested", include_draft=True)
|
|
draft = workspace["draft"]
|
|
|
|
assert result["revision"] == 2
|
|
assert draft["steps"]["render"]["output"] == [
|
|
{"source": "report.markdown", "target": "state.report.markdown"}
|
|
]
|
|
assert (
|
|
draft["state_schema"]["properties"]["report"]["properties"]["markdown"]["type"]
|
|
== "string"
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_bind_draft_lowers_nested_local_output_to_public_output(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
api, service, authoring = _draft_api(
|
|
FileWorkflowArtifactStore(tmp_path / "nested_local_public_output"),
|
|
register_echo=True,
|
|
)
|
|
service.register_specs("demo.personal", _nested_report)
|
|
await api.create_draft_workspace(
|
|
workspace_id="nested",
|
|
draft=_nested_report_draft(),
|
|
)
|
|
|
|
result = await authoring.bind_draft(
|
|
workspace_id="nested",
|
|
revision=1,
|
|
step_id="render",
|
|
source_path="local.report.markdown",
|
|
target_path="output.report.markdown",
|
|
)
|
|
workspace = await api.get_draft_workspace(workspace_id="nested", include_draft=True)
|
|
draft = workspace["draft"]
|
|
|
|
assert result["revision"] == 2
|
|
assert draft["steps"]["render"]["output"] == [
|
|
{"source": "report.markdown", "target": "state.report.markdown"}
|
|
]
|
|
assert draft["output"] == [
|
|
{"path": "state.report.markdown", "target": "report.markdown"}
|
|
]
|
|
assert (
|
|
draft["state_schema"]["properties"]["report"]["properties"]["markdown"]["type"]
|
|
== "string"
|
|
)
|
|
assert (
|
|
draft["output_schema"]["properties"]["report"]["properties"]["markdown"]["type"]
|
|
== "string"
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_bind_draft_output_preserves_unrelated_source_fan_out(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
api, service, authoring = _draft_api(
|
|
FileWorkflowArtifactStore(tmp_path / "bind_preserves_canonical_outputs"),
|
|
register_echo=True,
|
|
)
|
|
service.register_specs("demo.personal", _nested_report)
|
|
draft = _nested_report_draft()
|
|
draft["state_schema"] = {
|
|
"type": "object",
|
|
"properties": {
|
|
"report": {
|
|
"type": "object",
|
|
"properties": {
|
|
"title": {"type": "string"},
|
|
"audit_title": {"type": "string"},
|
|
},
|
|
}
|
|
},
|
|
}
|
|
draft["steps"]["render"]["output"] = [
|
|
{"source": "report.title", "target": "state.report.title"},
|
|
{"source": "report.title", "target": "state.report.audit_title"},
|
|
]
|
|
await api.create_draft_workspace(workspace_id="nested", draft=draft)
|
|
|
|
result = await authoring.bind_draft(
|
|
workspace_id="nested",
|
|
revision=1,
|
|
step_id="render",
|
|
source_path="local.report.markdown",
|
|
target_path="state.report.markdown",
|
|
)
|
|
workspace = await api.get_draft_workspace(workspace_id="nested", include_draft=True)
|
|
|
|
assert result["revision"] == 2
|
|
assert workspace["draft"]["steps"]["render"]["output"] == [
|
|
{"source": "report.title", "target": "state.report.title"},
|
|
{"source": "report.title", "target": "state.report.audit_title"},
|
|
{"source": "report.markdown", "target": "state.report.markdown"},
|
|
]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_bind_draft_rebinds_nested_local_public_output(tmp_path: Path) -> None:
|
|
api, service, authoring = _draft_api(
|
|
FileWorkflowArtifactStore(tmp_path / "rebind_nested_local_public_output"),
|
|
register_echo=True,
|
|
)
|
|
service.register_specs("demo.personal", _nested_report)
|
|
await api.create_draft_workspace(
|
|
workspace_id="nested",
|
|
draft=_nested_report_draft(),
|
|
)
|
|
first = await authoring.bind_draft(
|
|
workspace_id="nested",
|
|
revision=1,
|
|
step_id="render",
|
|
source_path="local.report.markdown",
|
|
target_path="output.report.markdown",
|
|
)
|
|
|
|
second = await authoring.bind_draft(
|
|
workspace_id="nested",
|
|
revision=first["revision"],
|
|
step_id="render",
|
|
source_path="local.report.markdown",
|
|
target_path="output.published.markdown",
|
|
)
|
|
workspace = await api.get_draft_workspace(workspace_id="nested", include_draft=True)
|
|
|
|
assert second["revision"] == 3
|
|
assert workspace["draft"]["steps"]["render"]["output"] == [
|
|
{"source": "report.markdown", "target": "state.published.markdown"}
|
|
]
|
|
assert workspace["draft"]["output"] == [
|
|
{"path": "state.published.markdown", "target": "published.markdown"}
|
|
]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_bind_draft_rejects_missing_nested_local_output_without_mutation(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
api, service, authoring = _draft_api(
|
|
FileWorkflowArtifactStore(tmp_path / "missing_nested_local_output"),
|
|
register_echo=True,
|
|
)
|
|
service.register_specs("demo.personal", _nested_report)
|
|
await api.create_draft_workspace(
|
|
workspace_id="nested",
|
|
draft=_nested_report_draft(),
|
|
)
|
|
|
|
with pytest.raises(
|
|
ValueError,
|
|
match="source schema path 'report.missing' is not declared",
|
|
):
|
|
await authoring.bind_draft(
|
|
workspace_id="nested",
|
|
revision=1,
|
|
step_id="render",
|
|
source_path="local.report.missing",
|
|
target_path="state.report.missing",
|
|
)
|
|
workspace = await api.get_draft_workspace(workspace_id="nested", include_draft=True)
|
|
|
|
assert workspace["revision"] == 1
|
|
assert workspace["draft"]["steps"]["render"]["output"] == []
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_bind_draft_stale_revision_precedes_nested_local_path_error(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
api, service, authoring = _draft_api(
|
|
FileWorkflowArtifactStore(tmp_path / "stale_nested_local_output"),
|
|
register_echo=True,
|
|
)
|
|
service.register_specs("demo.personal", _nested_report)
|
|
await api.create_draft_workspace(
|
|
workspace_id="nested",
|
|
draft=_nested_report_draft(),
|
|
)
|
|
await api.patch_draft_workspace(
|
|
workspace_id="nested",
|
|
revision=1,
|
|
patch=[{"op": "replace", "path": "/name", "value": "nested_v2"}],
|
|
)
|
|
|
|
result = await authoring.bind_draft(
|
|
workspace_id="nested",
|
|
revision=1,
|
|
step_id="render",
|
|
source_path="local.report.missing",
|
|
target_path="state.report.missing",
|
|
)
|
|
|
|
assert result["status"] == "conflict"
|
|
assert result["diagnostics"][0]["code"] == "revision_conflict"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_set_step_input_bindings_replaces_structured_inputs_in_order(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
draft_api, _service, api = await _create_structured_binding_api(
|
|
tmp_path,
|
|
"structured_input",
|
|
)
|
|
|
|
result = await api.set_step_input_bindings(
|
|
workspace_id="structured_input",
|
|
revision=1,
|
|
step_id="report",
|
|
bindings=[
|
|
InputPathBinding(
|
|
path=GraphSourcePath.state("report", "title"),
|
|
target=LocalPath.of("request", "title"),
|
|
),
|
|
InputPathBinding(
|
|
path=GraphSourcePath.state("report", "markdown"),
|
|
target=LocalPath.of("request", "body"),
|
|
),
|
|
InputValueBinding(
|
|
target=LocalPath.of("request", "format"),
|
|
value="markdown",
|
|
),
|
|
],
|
|
)
|
|
inspected = await draft_api.get_draft_workspace(
|
|
workspace_id="structured_input",
|
|
include_draft=True,
|
|
)
|
|
|
|
assert result["revision"] == 2
|
|
assert inspected["draft"]["steps"]["report"]["input"] == [
|
|
{"target": "request.title", "path": "state.report.title"},
|
|
{"target": "request.body", "path": "state.report.markdown"},
|
|
{"target": "request.format", "value": "markdown"},
|
|
]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_set_step_input_bindings_preserves_source_fan_out(tmp_path: Path) -> None:
|
|
draft_api, _service, api = await _create_structured_binding_api(
|
|
tmp_path,
|
|
"input_fan_out",
|
|
)
|
|
|
|
await api.set_step_input_bindings(
|
|
workspace_id="input_fan_out",
|
|
revision=1,
|
|
step_id="report",
|
|
bindings=[
|
|
InputPathBinding(
|
|
path=GraphSourcePath.state("report", "title"),
|
|
target=LocalPath.of("request", "title"),
|
|
),
|
|
InputPathBinding(
|
|
path=GraphSourcePath.state("report", "title"),
|
|
target=LocalPath.of("audit", "title"),
|
|
),
|
|
],
|
|
)
|
|
inspected = await draft_api.get_draft_workspace(
|
|
workspace_id="input_fan_out",
|
|
include_draft=True,
|
|
)
|
|
|
|
assert inspected["draft"]["steps"]["report"]["input"] == [
|
|
{"target": "request.title", "path": "state.report.title"},
|
|
{"target": "audit.title", "path": "state.report.title"},
|
|
]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_set_step_output_bindings_replaces_in_order_and_preserves_source_fan_out(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
draft_api, _service, api = await _create_nested_output_binding_api(
|
|
tmp_path,
|
|
"draft-output-bindings",
|
|
)
|
|
|
|
result = await api.set_step_output_bindings(
|
|
workspace_id="draft-output-bindings",
|
|
revision=1,
|
|
step_id="render",
|
|
bindings=[
|
|
OutputBinding(
|
|
source=LocalPath.parse("report.title"),
|
|
target=StatePath.parse("state.report.title"),
|
|
),
|
|
OutputBinding(
|
|
source=LocalPath.parse("report.title"),
|
|
target=StatePath.parse("state.audit.title"),
|
|
),
|
|
],
|
|
)
|
|
inspected = await draft_api.get_draft_workspace(
|
|
workspace_id="draft-output-bindings",
|
|
include_draft=True,
|
|
)
|
|
|
|
assert result["revision"] == 2
|
|
assert inspected["draft"]["steps"]["render"]["output"] == [
|
|
{"source": "report.title", "target": "state.report.title"},
|
|
{"source": "report.title", "target": "state.audit.title"},
|
|
]
|
|
state_schema = inspected["draft"]["state_schema"]
|
|
assert state_schema["properties"]["report"]["properties"]["title"] == {
|
|
"type": "string"
|
|
}
|
|
assert state_schema["properties"]["audit"]["properties"]["title"] == {
|
|
"type": "string"
|
|
}
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_set_step_output_bindings_projects_whole_capability_payload(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
draft_api, _service, api = await _create_nested_output_binding_api(
|
|
tmp_path,
|
|
"whole-output-binding",
|
|
)
|
|
|
|
await api.set_step_output_bindings(
|
|
workspace_id="whole-output-binding",
|
|
revision=1,
|
|
step_id="render",
|
|
bindings=[
|
|
OutputBinding(
|
|
source=LocalPath.root(),
|
|
target=StatePath.parse("state.raw_result"),
|
|
)
|
|
],
|
|
)
|
|
inspected = await draft_api.get_draft_workspace(
|
|
workspace_id="whole-output-binding",
|
|
include_draft=True,
|
|
)
|
|
|
|
raw_result_schema = inspected["draft"]["state_schema"]["properties"]["raw_result"]
|
|
assert raw_result_schema["type"] == "object"
|
|
assert raw_result_schema["properties"]["report"]["properties"]["title"] == {
|
|
"type": "string"
|
|
}
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_set_step_output_bindings_accepts_exact_existing_target_and_is_noop(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
draft_api, _service, api = await _create_nested_output_binding_api(
|
|
tmp_path,
|
|
"equivalent-output-binding",
|
|
)
|
|
binding = OutputBinding(
|
|
source=LocalPath.parse("report.title"),
|
|
target=StatePath.parse("state.report.title"),
|
|
)
|
|
first = await api.set_step_output_bindings(
|
|
workspace_id="equivalent-output-binding",
|
|
revision=1,
|
|
step_id="render",
|
|
bindings=[binding],
|
|
)
|
|
second = await api.set_step_output_bindings(
|
|
workspace_id="equivalent-output-binding",
|
|
revision=first["revision"],
|
|
step_id="render",
|
|
bindings=[binding],
|
|
)
|
|
|
|
assert first["revision"] == 2
|
|
assert second["revision"] == 2
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize(
|
|
("bindings", "message"),
|
|
[
|
|
(
|
|
[
|
|
OutputBinding(
|
|
source=LocalPath.parse("report.missing"),
|
|
target=StatePath.parse("state.report.missing"),
|
|
)
|
|
],
|
|
r"bindings\[0\]\.source 'report\.missing' is not declared",
|
|
),
|
|
(
|
|
[
|
|
OutputBinding(
|
|
source=LocalPath.parse("report.title"),
|
|
target=StatePath.parse("state.report.title"),
|
|
),
|
|
OutputBinding(
|
|
source=LocalPath.parse("report.markdown"),
|
|
target=StatePath.parse("state.report.title"),
|
|
),
|
|
],
|
|
r"bindings\[0\]\.target 'state\.report\.title' overlaps "
|
|
r"bindings\[1\]\.target 'state\.report\.title'",
|
|
),
|
|
(
|
|
[
|
|
OutputBinding(
|
|
source=LocalPath.parse("report.title"),
|
|
target=StatePath.parse("state.report"),
|
|
),
|
|
OutputBinding(
|
|
source=LocalPath.parse("report.markdown"),
|
|
target=StatePath.parse("state.report.title"),
|
|
),
|
|
],
|
|
r"bindings\[0\]\.target 'state\.report' overlaps "
|
|
r"bindings\[1\]\.target 'state\.report\.title'",
|
|
),
|
|
],
|
|
)
|
|
async def test_set_step_output_bindings_rejects_semantic_errors_without_mutation(
|
|
tmp_path: Path,
|
|
bindings: list[OutputBinding],
|
|
message: str,
|
|
) -> None:
|
|
workspace_id = f"invalid_output_bindings_{len(message)}"
|
|
draft_api, _service, api = await _create_nested_output_binding_api(
|
|
tmp_path,
|
|
workspace_id,
|
|
)
|
|
before = await draft_api.get_draft_workspace(
|
|
workspace_id=workspace_id,
|
|
include_draft=True,
|
|
)
|
|
|
|
with pytest.raises(ValueError, match=message):
|
|
await api.set_step_output_bindings(
|
|
workspace_id=workspace_id,
|
|
revision=1,
|
|
step_id="render",
|
|
bindings=bindings,
|
|
)
|
|
|
|
after = await draft_api.get_draft_workspace(
|
|
workspace_id=workspace_id,
|
|
include_draft=True,
|
|
)
|
|
assert after == before
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_set_step_output_bindings_reports_invalid_source_before_overlap(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
_draft_api_instance, _service, api = await _create_nested_output_binding_api(
|
|
tmp_path,
|
|
"invalid_source_before_overlap",
|
|
)
|
|
|
|
with pytest.raises(
|
|
ValueError,
|
|
match=r"bindings\[0\]\.source 'report\.missing' is not declared",
|
|
):
|
|
await api.set_step_output_bindings(
|
|
workspace_id="invalid_source_before_overlap",
|
|
revision=1,
|
|
step_id="render",
|
|
bindings=[
|
|
OutputBinding(
|
|
source=LocalPath.parse("report.missing"),
|
|
target=StatePath.parse("state.report.title"),
|
|
),
|
|
OutputBinding(
|
|
source=LocalPath.parse("report.title"),
|
|
target=StatePath.parse("state.report.title"),
|
|
),
|
|
],
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_set_step_output_bindings_rejects_incompatible_existing_target(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
draft_api, _service, api = await _create_nested_output_binding_api(
|
|
tmp_path,
|
|
"incompatible-output-binding",
|
|
)
|
|
await draft_api.patch_draft_workspace(
|
|
workspace_id="incompatible-output-binding",
|
|
revision=1,
|
|
patch=[
|
|
{
|
|
"op": "replace",
|
|
"path": "/state_schema",
|
|
"value": {
|
|
"type": "object",
|
|
"properties": {
|
|
"report": {
|
|
"type": "object",
|
|
"properties": {"title": {"type": "integer"}},
|
|
}
|
|
},
|
|
},
|
|
}
|
|
],
|
|
)
|
|
before = await draft_api.get_draft_workspace(
|
|
workspace_id="incompatible-output-binding",
|
|
include_draft=True,
|
|
)
|
|
|
|
with pytest.raises(
|
|
ValueError,
|
|
match=(
|
|
r"bindings\[0\]\.target 'state\.report\.title' cannot receive "
|
|
r"source 'report\.title'"
|
|
),
|
|
):
|
|
await api.set_step_output_bindings(
|
|
workspace_id="incompatible-output-binding",
|
|
revision=2,
|
|
step_id="render",
|
|
bindings=[
|
|
OutputBinding(
|
|
source=LocalPath.parse("report.title"),
|
|
target=StatePath.parse("state.report.title"),
|
|
)
|
|
],
|
|
)
|
|
|
|
after = await draft_api.get_draft_workspace(
|
|
workspace_id="incompatible-output-binding",
|
|
include_draft=True,
|
|
)
|
|
assert after == before
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_set_step_output_bindings_clears_outputs_without_removing_projection(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
draft_api, _service, api = await _create_nested_output_binding_api(
|
|
tmp_path,
|
|
"clear-output-bindings",
|
|
)
|
|
first = await api.set_step_output_bindings(
|
|
workspace_id="clear-output-bindings",
|
|
revision=1,
|
|
step_id="render",
|
|
bindings=[
|
|
OutputBinding(
|
|
source=LocalPath.parse("report.title"),
|
|
target=StatePath.parse("state.report.title"),
|
|
)
|
|
],
|
|
)
|
|
cleared = await api.set_step_output_bindings(
|
|
workspace_id="clear-output-bindings",
|
|
revision=first["revision"],
|
|
step_id="render",
|
|
bindings=[],
|
|
)
|
|
inspected = await draft_api.get_draft_workspace(
|
|
workspace_id="clear-output-bindings",
|
|
include_draft=True,
|
|
)
|
|
|
|
assert cleared["revision"] == 3
|
|
assert inspected["draft"]["steps"]["render"]["output"] == []
|
|
assert (
|
|
inspected["draft"]["state_schema"]["properties"]["report"]["properties"][
|
|
"title"
|
|
]["type"]
|
|
== "string"
|
|
)
|
|
assert set(
|
|
inspected["draft"]["state_schema"]["properties"]["report"]["properties"][
|
|
"title"
|
|
]
|
|
) == {"type"}
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_set_step_output_bindings_rejects_missing_step_without_mutation(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
draft_api, _service, api = await _create_nested_output_binding_api(
|
|
tmp_path,
|
|
"missing-output-step",
|
|
)
|
|
before = await draft_api.get_draft_workspace(
|
|
workspace_id="missing-output-step",
|
|
include_draft=True,
|
|
)
|
|
|
|
with pytest.raises(KeyError, match="missing"):
|
|
await api.set_step_output_bindings(
|
|
workspace_id="missing-output-step",
|
|
revision=1,
|
|
step_id="missing",
|
|
bindings=[],
|
|
)
|
|
|
|
after = await draft_api.get_draft_workspace(
|
|
workspace_id="missing-output-step",
|
|
include_draft=True,
|
|
)
|
|
assert after == before
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_set_step_output_bindings_rejects_non_capability_step_without_mutation(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
draft_api, _service, api = await _create_nested_output_binding_api(
|
|
tmp_path,
|
|
"non-capability-output-step",
|
|
)
|
|
await draft_api.patch_draft_workspace(
|
|
workspace_id="non-capability-output-step",
|
|
revision=1,
|
|
patch=[{"op": "replace", "path": "/steps/render", "value": {"join": {}}}],
|
|
)
|
|
before = await draft_api.get_draft_workspace(
|
|
workspace_id="non-capability-output-step",
|
|
include_draft=True,
|
|
)
|
|
|
|
with pytest.raises(ValueError, match="does not declare a capability use"):
|
|
await api.set_step_output_bindings(
|
|
workspace_id="non-capability-output-step",
|
|
revision=2,
|
|
step_id="render",
|
|
bindings=[],
|
|
)
|
|
|
|
after = await draft_api.get_draft_workspace(
|
|
workspace_id="non-capability-output-step",
|
|
include_draft=True,
|
|
)
|
|
assert after == before
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize(
|
|
"bindings",
|
|
[
|
|
[
|
|
OutputBinding(
|
|
source=LocalPath.parse("report.missing"),
|
|
target=StatePath.parse("state.report.missing"),
|
|
)
|
|
],
|
|
[
|
|
OutputBinding(
|
|
source=LocalPath.parse("report.title"),
|
|
target=StatePath.parse("state.report"),
|
|
),
|
|
OutputBinding(
|
|
source=LocalPath.parse("report.markdown"),
|
|
target=StatePath.parse("state.report.title"),
|
|
),
|
|
],
|
|
],
|
|
)
|
|
async def test_set_step_output_bindings_stale_revision_precedes_semantic_errors(
|
|
tmp_path: Path,
|
|
bindings: list[OutputBinding],
|
|
) -> None:
|
|
draft_api, _service, api = await _create_nested_output_binding_api(
|
|
tmp_path,
|
|
f"stale-output-binding-{len(bindings)}",
|
|
)
|
|
workspace_id = f"stale-output-binding-{len(bindings)}"
|
|
|
|
result = await api.set_step_output_bindings(
|
|
workspace_id=workspace_id,
|
|
revision=2,
|
|
step_id="render",
|
|
bindings=bindings,
|
|
)
|
|
|
|
assert result["status"] == "conflict"
|
|
assert result["diagnostics"][0]["code"] == "revision_conflict"
|
|
inspected = await draft_api.get_draft_workspace(
|
|
workspace_id=workspace_id,
|
|
include_draft=True,
|
|
)
|
|
assert inspected["revision"] == 1
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_set_step_output_bindings_stale_revision_precedes_missing_step(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
draft_api, _service, api = await _create_nested_output_binding_api(
|
|
tmp_path,
|
|
"stale-output-missing-step",
|
|
)
|
|
before = await draft_api.get_draft_workspace(
|
|
workspace_id="stale-output-missing-step",
|
|
include_draft=True,
|
|
)
|
|
|
|
result = await api.set_step_output_bindings(
|
|
workspace_id="stale-output-missing-step",
|
|
revision=2,
|
|
step_id="missing",
|
|
bindings=[],
|
|
)
|
|
|
|
after = await draft_api.get_draft_workspace(
|
|
workspace_id="stale-output-missing-step",
|
|
include_draft=True,
|
|
)
|
|
assert result["status"] == "conflict"
|
|
assert result["diagnostics"][0]["code"] == "revision_conflict"
|
|
assert after == before
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_set_step_output_bindings_stale_revision_precedes_non_capability_step(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
draft_api, _service, api = await _create_nested_output_binding_api(
|
|
tmp_path,
|
|
"stale-output-non-capability",
|
|
)
|
|
await draft_api.patch_draft_workspace(
|
|
workspace_id="stale-output-non-capability",
|
|
revision=1,
|
|
patch=[{"op": "replace", "path": "/steps/render", "value": {"join": {}}}],
|
|
)
|
|
before = await draft_api.get_draft_workspace(
|
|
workspace_id="stale-output-non-capability",
|
|
include_draft=True,
|
|
)
|
|
|
|
result = await api.set_step_output_bindings(
|
|
workspace_id="stale-output-non-capability",
|
|
revision=1,
|
|
step_id="render",
|
|
bindings=[],
|
|
)
|
|
|
|
after = await draft_api.get_draft_workspace(
|
|
workspace_id="stale-output-non-capability",
|
|
include_draft=True,
|
|
)
|
|
assert result["status"] == "conflict"
|
|
assert result["diagnostics"][0]["code"] == "revision_conflict"
|
|
assert after == before
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_set_step_output_bindings_stale_revision_precedes_duplicate_target(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
draft_api, _service, api = await _create_nested_output_binding_api(
|
|
tmp_path,
|
|
"stale-output-duplicate-target",
|
|
)
|
|
before = await draft_api.get_draft_workspace(
|
|
workspace_id="stale-output-duplicate-target",
|
|
include_draft=True,
|
|
)
|
|
|
|
result = await api.set_step_output_bindings(
|
|
workspace_id="stale-output-duplicate-target",
|
|
revision=2,
|
|
step_id="render",
|
|
bindings=[
|
|
OutputBinding(
|
|
source=LocalPath.parse("report.title"),
|
|
target=StatePath.parse("state.report.title"),
|
|
),
|
|
OutputBinding(
|
|
source=LocalPath.parse("report.markdown"),
|
|
target=StatePath.parse("state.report.title"),
|
|
),
|
|
],
|
|
)
|
|
|
|
after = await draft_api.get_draft_workspace(
|
|
workspace_id="stale-output-duplicate-target",
|
|
include_draft=True,
|
|
)
|
|
assert result["status"] == "conflict"
|
|
assert result["diagnostics"][0]["code"] == "revision_conflict"
|
|
assert after == before
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_set_step_output_bindings_stale_revision_precedes_incompatible_schema(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
draft_api, _service, api = await _create_nested_output_binding_api(
|
|
tmp_path,
|
|
"stale-output-incompatible-schema",
|
|
)
|
|
await draft_api.patch_draft_workspace(
|
|
workspace_id="stale-output-incompatible-schema",
|
|
revision=1,
|
|
patch=[
|
|
{
|
|
"op": "replace",
|
|
"path": "/state_schema",
|
|
"value": {
|
|
"type": "object",
|
|
"properties": {
|
|
"report": {
|
|
"type": "object",
|
|
"properties": {"title": {"type": "integer"}},
|
|
}
|
|
},
|
|
},
|
|
}
|
|
],
|
|
)
|
|
before = await draft_api.get_draft_workspace(
|
|
workspace_id="stale-output-incompatible-schema",
|
|
include_draft=True,
|
|
)
|
|
|
|
result = await api.set_step_output_bindings(
|
|
workspace_id="stale-output-incompatible-schema",
|
|
revision=1,
|
|
step_id="render",
|
|
bindings=[
|
|
OutputBinding(
|
|
source=LocalPath.parse("report.title"),
|
|
target=StatePath.parse("state.report.title"),
|
|
)
|
|
],
|
|
)
|
|
|
|
after = await draft_api.get_draft_workspace(
|
|
workspace_id="stale-output-incompatible-schema",
|
|
include_draft=True,
|
|
)
|
|
assert result["status"] == "conflict"
|
|
assert result["diagnostics"][0]["code"] == "revision_conflict"
|
|
assert after == before
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize(
|
|
("bindings", "message"),
|
|
[
|
|
(
|
|
[
|
|
InputValueBinding(
|
|
target=LocalPath.of("request", "missing"),
|
|
value="value",
|
|
)
|
|
],
|
|
r"bindings\[0\]\.target 'request.missing' is not declared",
|
|
),
|
|
(
|
|
[
|
|
InputValueBinding(
|
|
target=LocalPath.of("request", "title"),
|
|
value="first",
|
|
),
|
|
InputValueBinding(
|
|
target=LocalPath.of("request", "title"),
|
|
value="second",
|
|
),
|
|
],
|
|
r"bindings\[0\]\.target 'request.title' overlaps bindings\[1\]",
|
|
),
|
|
(
|
|
[
|
|
InputValueBinding(
|
|
target=LocalPath.of("request"),
|
|
value={
|
|
"title": "Report",
|
|
"body": "Body",
|
|
"format": "markdown",
|
|
},
|
|
),
|
|
InputValueBinding(
|
|
target=LocalPath.of("request", "title"),
|
|
value="Report",
|
|
),
|
|
],
|
|
r"bindings\[0\]\.target 'request' overlaps bindings\[1\]",
|
|
),
|
|
(
|
|
[
|
|
InputValueBinding(
|
|
target=LocalPath.of("request", "format"),
|
|
value="html",
|
|
)
|
|
],
|
|
r"bindings\[0\]\.value does not satisfy schema at 'request.format'",
|
|
),
|
|
(
|
|
[InputValueBinding(target=LocalPath.root(), value="not-an-object")],
|
|
r"bindings\[0\]\.value for target '\.' must be a JSON object",
|
|
),
|
|
],
|
|
)
|
|
async def test_set_step_input_bindings_rejects_semantic_errors_without_mutation(
|
|
tmp_path: Path,
|
|
bindings: list[InputPathBinding | InputValueBinding],
|
|
message: str,
|
|
) -> None:
|
|
workspace_id = f"invalid_bindings_{len(message)}"
|
|
draft_api, _service, api = await _create_structured_binding_api(
|
|
tmp_path,
|
|
workspace_id,
|
|
)
|
|
before = await draft_api.get_draft_workspace(
|
|
workspace_id=workspace_id,
|
|
include_draft=True,
|
|
)
|
|
|
|
with pytest.raises(ValueError, match=message):
|
|
await api.set_step_input_bindings(
|
|
workspace_id=workspace_id,
|
|
revision=1,
|
|
step_id="report",
|
|
bindings=bindings,
|
|
)
|
|
|
|
after = await draft_api.get_draft_workspace(
|
|
workspace_id=workspace_id,
|
|
include_draft=True,
|
|
)
|
|
assert after == before
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_set_step_input_bindings_rejects_remote_target_reference_without_mutation(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
draft_api, service, authoring = _draft_api(
|
|
FileWorkflowArtifactStore(tmp_path / "remote_binding_target"),
|
|
register_echo=True,
|
|
)
|
|
remote_spec = replace(
|
|
_structured_report,
|
|
name="remote_structured_report",
|
|
input_schema_contract={
|
|
"type": "object",
|
|
"properties": {"request": {"$ref": "https://example.com/request.json"}},
|
|
},
|
|
)
|
|
service.register_specs("demo.personal", remote_spec)
|
|
await draft_api.create_draft_workspace(
|
|
workspace_id="remote_target",
|
|
draft=_structured_report_draft("demo.personal.remote_structured_report"),
|
|
)
|
|
api = WorkflowApi(authoring.context, drafts=True)
|
|
before = await draft_api.get_draft_workspace(
|
|
workspace_id="remote_target",
|
|
include_draft=True,
|
|
)
|
|
|
|
with pytest.raises(ValueError, match="unsupported reference"):
|
|
await api.set_step_input_bindings(
|
|
workspace_id="remote_target",
|
|
revision=1,
|
|
step_id="report",
|
|
bindings=[
|
|
InputValueBinding(
|
|
target=LocalPath.of("request"),
|
|
value={},
|
|
)
|
|
],
|
|
)
|
|
|
|
after = await draft_api.get_draft_workspace(
|
|
workspace_id="remote_target",
|
|
include_draft=True,
|
|
)
|
|
assert after == before
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_set_step_input_bindings_rejects_non_capability_step_without_mutation(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
draft_api, _service, authoring = _draft_api(
|
|
FileWorkflowArtifactStore(tmp_path / "non_capability_binding"),
|
|
register_echo=True,
|
|
)
|
|
draft = _structured_report_draft()
|
|
draft["steps"]["report"] = {"join": {}}
|
|
await draft_api.create_draft_workspace(workspace_id="non_capability", draft=draft)
|
|
api = WorkflowApi(authoring.context, drafts=True)
|
|
before = await draft_api.get_draft_workspace(
|
|
workspace_id="non_capability",
|
|
include_draft=True,
|
|
)
|
|
|
|
with pytest.raises(ValueError, match="does not declare a capability use"):
|
|
await api.set_step_input_bindings(
|
|
workspace_id="non_capability",
|
|
revision=1,
|
|
step_id="report",
|
|
bindings=[],
|
|
)
|
|
|
|
after = await draft_api.get_draft_workspace(
|
|
workspace_id="non_capability",
|
|
include_draft=True,
|
|
)
|
|
assert after == before
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize(
|
|
"binding",
|
|
[
|
|
InputValueBinding(target=LocalPath.of("missing"), value="value"),
|
|
InputValueBinding(
|
|
target=LocalPath.of("request", "format"),
|
|
value="html",
|
|
),
|
|
],
|
|
)
|
|
async def test_set_step_input_bindings_stale_revision_wins_over_semantic_errors(
|
|
tmp_path: Path,
|
|
binding: InputValueBinding,
|
|
) -> None:
|
|
draft_api, _service, api = await _create_structured_binding_api(
|
|
tmp_path,
|
|
f"stale_binding_{len(str(binding.target))}",
|
|
)
|
|
workspace_id = f"stale_binding_{len(str(binding.target))}"
|
|
|
|
result = await api.set_step_input_bindings(
|
|
workspace_id=workspace_id,
|
|
revision=2,
|
|
step_id="report",
|
|
bindings=[binding],
|
|
)
|
|
|
|
assert result["status"] == "conflict"
|
|
assert result["diagnostics"][0]["code"] == "revision_conflict"
|
|
inspected = await draft_api.get_draft_workspace(
|
|
workspace_id=workspace_id,
|
|
include_draft=True,
|
|
)
|
|
assert inspected["revision"] == 1
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_set_step_input_bindings_projects_whole_capability_payload(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
draft_api, _service, api = await _create_structured_binding_api(
|
|
tmp_path,
|
|
"whole_payload_binding",
|
|
)
|
|
|
|
await api.set_step_input_bindings(
|
|
workspace_id="whole_payload_binding",
|
|
revision=1,
|
|
step_id="report",
|
|
bindings=[
|
|
InputPathBinding(
|
|
path=GraphSourcePath.input("payload"),
|
|
target=LocalPath.root(),
|
|
)
|
|
],
|
|
)
|
|
inspected = await draft_api.get_draft_workspace(
|
|
workspace_id="whole_payload_binding",
|
|
include_draft=True,
|
|
)
|
|
|
|
payload_schema = inspected["draft"]["input_schema"]["properties"]["payload"]
|
|
assert payload_schema["properties"]["request"]["$ref"].endswith(
|
|
"/_StructuredRequest"
|
|
)
|
|
assert inspected["draft"]["steps"]["report"]["input"] == [
|
|
{"target": ".", "path": "input.payload"}
|
|
]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_set_step_input_bindings_projects_input_and_state_but_not_context(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
draft_api, _service, api = await _create_structured_binding_api(
|
|
tmp_path,
|
|
"multi_schema_binding",
|
|
)
|
|
|
|
await api.set_step_input_bindings(
|
|
workspace_id="multi_schema_binding",
|
|
revision=1,
|
|
step_id="report",
|
|
bindings=[
|
|
InputPathBinding(
|
|
path=GraphSourcePath.input("title"),
|
|
target=LocalPath.of("request", "title"),
|
|
),
|
|
InputPathBinding(
|
|
path=GraphSourcePath.state("body"),
|
|
target=LocalPath.of("request", "body"),
|
|
),
|
|
InputValueBinding(
|
|
target=LocalPath.of("request", "format"),
|
|
value="markdown",
|
|
),
|
|
InputPathBinding(
|
|
path=GraphSourcePath.context("prior_outcome"),
|
|
target=LocalPath.of("request", "note"),
|
|
),
|
|
],
|
|
)
|
|
inspected = await draft_api.get_draft_workspace(
|
|
workspace_id="multi_schema_binding",
|
|
include_draft=True,
|
|
)
|
|
draft = inspected["draft"]
|
|
|
|
assert draft["input_schema"]["properties"]["title"]["type"] == "string"
|
|
assert draft["state_schema"]["properties"]["body"]["type"] == "string"
|
|
assert set(draft["input_schema"]["properties"]) == {"title"}
|
|
assert set(draft["state_schema"]["properties"]) == {"body"}
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_set_step_input_bindings_accepts_explicit_null_and_exact_noop(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
draft_api, _service, api = await _create_structured_binding_api(
|
|
tmp_path,
|
|
"null_and_noop_binding",
|
|
)
|
|
bindings = [
|
|
InputValueBinding(
|
|
target=LocalPath.of("request", "note"),
|
|
value=None,
|
|
)
|
|
]
|
|
|
|
first = await api.set_step_input_bindings(
|
|
workspace_id="null_and_noop_binding",
|
|
revision=1,
|
|
step_id="report",
|
|
bindings=bindings,
|
|
)
|
|
second = await api.set_step_input_bindings(
|
|
workspace_id="null_and_noop_binding",
|
|
revision=first["revision"],
|
|
step_id="report",
|
|
bindings=bindings,
|
|
)
|
|
|
|
assert first["revision"] == 2
|
|
assert second["revision"] == 2
|
|
inspected = await draft_api.get_draft_workspace(
|
|
workspace_id="null_and_noop_binding",
|
|
include_draft=True,
|
|
)
|
|
assert inspected["draft"]["steps"]["report"]["input"] == [
|
|
{"target": "request.note", "value": None}
|
|
]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_set_step_input_bindings_compiles_and_assembles_nested_payload(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
draft_api, service, api = await _create_structured_binding_api(
|
|
tmp_path,
|
|
"execute_structured_binding",
|
|
)
|
|
bindings = [
|
|
InputPathBinding(
|
|
path=GraphSourcePath.input("title"),
|
|
target=LocalPath.of("request", "title"),
|
|
),
|
|
InputPathBinding(
|
|
path=GraphSourcePath.input("body"),
|
|
target=LocalPath.of("request", "body"),
|
|
),
|
|
InputValueBinding(
|
|
target=LocalPath.of("request", "format"),
|
|
value="markdown",
|
|
),
|
|
]
|
|
await api.set_step_input_bindings(
|
|
workspace_id="execute_structured_binding",
|
|
revision=1,
|
|
step_id="report",
|
|
bindings=bindings,
|
|
)
|
|
|
|
compiled = await draft_api.compile_draft_workspace(
|
|
workspace_id="execute_structured_binding"
|
|
)
|
|
plan = RawWorkflowPlan.model_validate(_compiled_workspace(compiled))
|
|
run = await service.run_workflow_from_plan(
|
|
plan,
|
|
{"title": "Thesis", "body": "Evidence"},
|
|
)
|
|
|
|
assert run.error is None
|
|
assert run.trace[0].output == {"rendered": "Thesis|Evidence|markdown"}
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_set_step_output_bindings_compile_and_execute_source_fan_out(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
draft_api, service, api = await _create_structured_binding_api(
|
|
tmp_path,
|
|
"execute_output_fan_out",
|
|
)
|
|
input_result = await api.set_step_input_bindings(
|
|
workspace_id="execute_output_fan_out",
|
|
revision=1,
|
|
step_id="report",
|
|
bindings=[
|
|
InputPathBinding(
|
|
path=GraphSourcePath.input("title"),
|
|
target=LocalPath.of("request", "title"),
|
|
),
|
|
InputPathBinding(
|
|
path=GraphSourcePath.input("body"),
|
|
target=LocalPath.of("request", "body"),
|
|
),
|
|
InputValueBinding(
|
|
target=LocalPath.of("request", "format"),
|
|
value="markdown",
|
|
),
|
|
],
|
|
)
|
|
await api.set_step_output_bindings(
|
|
workspace_id="execute_output_fan_out",
|
|
revision=input_result["revision"],
|
|
step_id="report",
|
|
bindings=[
|
|
OutputBinding(
|
|
source=LocalPath.parse("rendered"),
|
|
target=StatePath.parse("state.report"),
|
|
),
|
|
OutputBinding(
|
|
source=LocalPath.parse("rendered"),
|
|
target=StatePath.parse("state.audit"),
|
|
),
|
|
],
|
|
)
|
|
|
|
compiled = await draft_api.compile_draft_workspace(
|
|
workspace_id="execute_output_fan_out"
|
|
)
|
|
plan = RawWorkflowPlan.model_validate(_compiled_workspace(compiled))
|
|
run = await service.run_workflow_from_plan(
|
|
plan,
|
|
{"title": "Thesis", "body": "Evidence"},
|
|
)
|
|
|
|
assert run.error is None
|
|
assert run.state["report"] == "Thesis|Evidence|markdown"
|
|
assert run.state["audit"] == "Thesis|Evidence|markdown"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_bind_draft_rejects_unsupported_direction(tmp_path: Path) -> None:
|
|
artifact_store = FileWorkflowArtifactStore(tmp_path / "drafts_bind_bad_direction")
|
|
api, _service, authoring = _draft_api(artifact_store, register_echo=True)
|
|
await api.create_draft_workspace(workspace_id="bind_ws", draft=_echo_draft())
|
|
|
|
with pytest.raises(ValueError, match="unsupported bind direction"):
|
|
await authoring.bind_draft(
|
|
workspace_id="bind_ws",
|
|
revision=1,
|
|
step_id="echo",
|
|
source_path="input.message",
|
|
target_path="state.message",
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_add_step_from_capability_wires_route_inputs_and_state_outputs(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
artifact_store = FileWorkflowArtifactStore(tmp_path / "drafts_add_step")
|
|
api, service, authoring = _draft_api(artifact_store, register_echo=True)
|
|
service.register_specs("demo.personal", echo_tool, _snapshot_tool)
|
|
await api.create_draft_workspace(
|
|
workspace_id="echo_ws",
|
|
draft=_echo_draft(),
|
|
)
|
|
|
|
result = await authoring.add_step_from_capability(
|
|
workspace_id="echo_ws",
|
|
revision=1,
|
|
step_id="snap",
|
|
capability_name="demo.personal.snapshot_tool",
|
|
route_from_step="echo",
|
|
route_from_outcome="ok",
|
|
routes={"ok": "__end__", "skipped": "__end__"},
|
|
input_map={},
|
|
bind_outputs={"after": "state.after"},
|
|
)
|
|
|
|
assert result["revision"] == 2
|
|
assert result["status"] == "valid"
|
|
fetched = await api.get_draft_workspace(workspace_id="echo_ws", include_draft=True)
|
|
draft = fetched["draft"]
|
|
assert draft["steps"]["snap"]["use"] == "demo.personal.snapshot_tool"
|
|
assert draft["routes"]["echo"]["ok"] == "snap"
|
|
assert draft["routes"]["snap"]["ok"] == "__end__"
|
|
assert draft["steps"]["snap"]["output"] == [
|
|
{
|
|
"source": "after",
|
|
"target": "state.after",
|
|
}
|
|
]
|
|
assert draft["state_schema"]["properties"]["after"]["$ref"] == "#/$defs/_Snapshot"
|
|
assert draft["state_schema"]["$defs"]["_Snapshot"]["properties"]["clicked"] == {
|
|
"title": "Clicked",
|
|
"type": "boolean",
|
|
}
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_add_step_from_capability_projects_nested_local_input(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
api, service, authoring = _draft_api(
|
|
FileWorkflowArtifactStore(tmp_path / "nested_capability_input"),
|
|
register_echo=True,
|
|
)
|
|
service.register_specs("demo.personal", _nested_report)
|
|
draft = _nested_report_draft()
|
|
draft["steps"] = {}
|
|
draft["routes"] = {}
|
|
await api.create_draft_workspace(workspace_id="nested_add", draft=draft)
|
|
|
|
result = await authoring.add_step_from_capability(
|
|
workspace_id="nested_add",
|
|
revision=1,
|
|
step_id="render",
|
|
capability_name="demo.personal.nested_report",
|
|
routes={"ok": "__end__"},
|
|
input_map={"input.title": "report.title"},
|
|
bind_outputs={},
|
|
)
|
|
workspace = await api.get_draft_workspace(
|
|
workspace_id="nested_add", include_draft=True
|
|
)
|
|
validated = await api.validate_draft_workspace(workspace_id="nested_add")
|
|
|
|
assert result["revision"] == 2
|
|
assert workspace["draft"]["input_schema"]["properties"]["title"]["type"] == (
|
|
"string"
|
|
)
|
|
assert workspace["draft"]["steps"]["render"]["input"] == [
|
|
{"target": "report.title", "path": "input.title"}
|
|
]
|
|
assert validated["status"] == "valid", validated["diagnostics"]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_add_step_from_capability_preserves_whole_payload_input(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
api, service, authoring = _draft_api(
|
|
FileWorkflowArtifactStore(tmp_path / "whole_payload_capability_input"),
|
|
register_echo=True,
|
|
)
|
|
service.register_specs("demo.personal", _nested_report)
|
|
draft = _nested_report_draft()
|
|
draft["steps"] = {}
|
|
draft["routes"] = {}
|
|
await api.create_draft_workspace(workspace_id="whole_payload", draft=draft)
|
|
|
|
result = await authoring.add_step_from_capability(
|
|
workspace_id="whole_payload",
|
|
revision=1,
|
|
step_id="render",
|
|
capability_name="demo.personal.nested_report",
|
|
routes={"ok": "__end__"},
|
|
input_map={"input.payload": "."},
|
|
bind_outputs={},
|
|
)
|
|
workspace = await api.get_draft_workspace(
|
|
workspace_id="whole_payload", include_draft=True
|
|
)
|
|
|
|
assert result["revision"] == 2
|
|
assert workspace["draft"]["steps"]["render"]["input"] == [
|
|
{"target": ".", "path": "input.payload"}
|
|
]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_add_step_from_capability_rejects_existing_step_id(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
artifact_store = FileWorkflowArtifactStore(tmp_path / "drafts_add_step_duplicate")
|
|
api, _service, authoring = _draft_api(artifact_store, register_echo=True)
|
|
await api.create_draft_workspace(
|
|
workspace_id="echo_ws",
|
|
draft=_echo_draft(),
|
|
)
|
|
|
|
with pytest.raises(ValueError, match="draft step 'echo' already exists"):
|
|
await authoring.add_step_from_capability(
|
|
workspace_id="echo_ws",
|
|
revision=1,
|
|
step_id="echo",
|
|
capability_name="demo.personal.echo_tool",
|
|
route_from_step=None,
|
|
route_from_outcome="ok",
|
|
routes={"ok": "__end__"},
|
|
input_map={},
|
|
bind_outputs={},
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize(
|
|
("step_name", "step_payload"),
|
|
[
|
|
(
|
|
"use",
|
|
{
|
|
"use": "demo.personal.echo_tool",
|
|
"input": [],
|
|
"output": [],
|
|
},
|
|
),
|
|
(
|
|
"foreach",
|
|
{"foreach": {"over": "state.items", "as": "item"}},
|
|
),
|
|
(
|
|
"interrupt",
|
|
{"interrupt": {"kind": "review", "outcomes": ["submitted"]}},
|
|
),
|
|
("join", {"join": {}}),
|
|
("end", {"end": {"outcome": "ok"}}),
|
|
(
|
|
"when",
|
|
{
|
|
"when": {
|
|
"if": {"op": "exists", "path": "state.ready"},
|
|
"then": "echo",
|
|
}
|
|
},
|
|
),
|
|
(
|
|
"choose",
|
|
{
|
|
"choose": {
|
|
"clauses": [
|
|
{
|
|
"if": {"op": "exists", "path": "state.ready"},
|
|
"then": "echo",
|
|
}
|
|
]
|
|
}
|
|
},
|
|
),
|
|
(
|
|
"match",
|
|
{
|
|
"match": {
|
|
"value": "state.status",
|
|
"cases": [{"equals": "ready", "then": "echo"}],
|
|
}
|
|
},
|
|
),
|
|
(
|
|
"subgraph",
|
|
{
|
|
"subgraph": {
|
|
"workflow": {"name": "child"},
|
|
"outcomes": ["ok"],
|
|
}
|
|
},
|
|
),
|
|
],
|
|
)
|
|
async def test_add_step_accepts_every_typed_draft_step(
|
|
tmp_path: Path,
|
|
step_name: str,
|
|
step_payload: dict[str, Any],
|
|
) -> None:
|
|
artifact_store = FileWorkflowArtifactStore(tmp_path / f"draft_add_{step_name}")
|
|
draft_api, _service, authoring = _draft_api(artifact_store, register_echo=True)
|
|
api = WorkflowApi(authoring.context, drafts=True)
|
|
await draft_api.create_draft_workspace(workspace_id="draft_ws", draft=_echo_draft())
|
|
|
|
step = TypeAdapter(DraftStep).validate_python(step_payload)
|
|
result = await api.add_step(
|
|
workspace_id="draft_ws",
|
|
revision=1,
|
|
step_id="new_step",
|
|
step=step,
|
|
)
|
|
|
|
assert result["revision"] == 2
|
|
workspace = await draft_api.get_draft_workspace(
|
|
workspace_id="draft_ws", include_draft=True
|
|
)
|
|
assert workspace["draft"]["steps"]["new_step"] == step.model_dump(
|
|
mode="json", by_alias=True
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_add_step_routes_incoming_and_outgoing_edges_atomically(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
artifact_store = FileWorkflowArtifactStore(tmp_path / "draft_add_routes")
|
|
draft_api, _service, authoring = _draft_api(artifact_store, register_echo=True)
|
|
api = WorkflowApi(authoring.context, drafts=True)
|
|
await draft_api.create_draft_workspace(workspace_id="draft_ws", draft=_echo_draft())
|
|
|
|
step = TypeAdapter(DraftStep).validate_python(
|
|
{"use": "demo.personal.echo_tool", "input": [], "output": []}
|
|
)
|
|
result = await api.add_step(
|
|
workspace_id="draft_ws",
|
|
revision=1,
|
|
step_id="new_step",
|
|
step=step,
|
|
incoming=RouteSource("echo", "ok"),
|
|
routes={"ok": "__end__"},
|
|
)
|
|
|
|
assert result["revision"] == 2
|
|
workspace = await draft_api.get_draft_workspace(
|
|
workspace_id="draft_ws", include_draft=True
|
|
)
|
|
assert workspace["draft"]["routes"]["echo"]["ok"] == "new_step"
|
|
assert workspace["draft"]["routes"]["new_step"] == {"ok": "__end__"}
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_add_step_stale_revision_wins_over_content_preflight(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
artifact_store = FileWorkflowArtifactStore(tmp_path / "draft_add_stale")
|
|
draft_api, _service, authoring = _draft_api(artifact_store, register_echo=True)
|
|
api = WorkflowApi(authoring.context, drafts=True)
|
|
await draft_api.create_draft_workspace(workspace_id="draft_ws", draft=_echo_draft())
|
|
before = await draft_api.get_draft_workspace(
|
|
workspace_id="draft_ws", include_draft=True
|
|
)
|
|
step = TypeAdapter(DraftStep).validate_python({"join": {}})
|
|
|
|
result = await api.add_step(
|
|
workspace_id="draft_ws",
|
|
revision=2,
|
|
step_id="echo",
|
|
step=step,
|
|
)
|
|
|
|
after = await draft_api.get_draft_workspace(
|
|
workspace_id="draft_ws", include_draft=True
|
|
)
|
|
assert result["status"] == "conflict"
|
|
assert result["diagnostics"][0]["code"] == "revision_conflict"
|
|
assert after == before
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize("operation", ["bind", "capability_add"])
|
|
async def test_capability_aware_edits_stale_revision_wins_over_semantic_errors(
|
|
tmp_path: Path,
|
|
operation: str,
|
|
) -> None:
|
|
artifact_store = FileWorkflowArtifactStore(
|
|
tmp_path / f"draft_capability_stale_{operation}"
|
|
)
|
|
api, _service, authoring = _draft_api(artifact_store, register_echo=True)
|
|
await api.create_draft_workspace(
|
|
workspace_id="draft_ws",
|
|
draft=_echo_draft(),
|
|
)
|
|
before = await api.get_draft_workspace(
|
|
workspace_id="draft_ws",
|
|
include_draft=True,
|
|
)
|
|
|
|
if operation == "bind":
|
|
result = await authoring.bind_draft(
|
|
workspace_id="draft_ws",
|
|
revision=2,
|
|
step_id="missing",
|
|
source_path="input.text",
|
|
target_path="local.text",
|
|
)
|
|
else:
|
|
result = await authoring.add_step_from_capability(
|
|
workspace_id="draft_ws",
|
|
revision=2,
|
|
step_id="new_step",
|
|
capability_name="missing.connection.unknown_tool",
|
|
)
|
|
|
|
after = await api.get_draft_workspace(
|
|
workspace_id="draft_ws",
|
|
include_draft=True,
|
|
)
|
|
assert result["status"] == "conflict"
|
|
assert result["revision"] == before["revision"]
|
|
assert result["diagnostics"][0]["code"] == "revision_conflict"
|
|
assert after == before
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_add_step_adds_missing_incoming_route_parent_atomically(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
artifact_store = FileWorkflowArtifactStore(tmp_path / "draft_add_missing_parent")
|
|
draft_api, _service, authoring = _draft_api(artifact_store, register_echo=True)
|
|
api = WorkflowApi(authoring.context, drafts=True)
|
|
draft = _echo_draft()
|
|
draft["routes"] = {}
|
|
await draft_api.create_draft_workspace(workspace_id="draft_ws", draft=draft)
|
|
|
|
step = TypeAdapter(DraftStep).validate_python(
|
|
{"use": "demo.personal.echo_tool", "input": [], "output": []}
|
|
)
|
|
result = await api.add_step(
|
|
workspace_id="draft_ws",
|
|
revision=1,
|
|
step_id="new_step",
|
|
step=step,
|
|
incoming=RouteSource("echo", "ok"),
|
|
)
|
|
|
|
assert result["revision"] == 2
|
|
workspace = await draft_api.get_draft_workspace(
|
|
workspace_id="draft_ws", include_draft=True
|
|
)
|
|
assert workspace["draft"]["routes"]["echo"] == {"ok": "new_step"}
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_add_step_distinguishes_missing_and_explicit_empty_routes(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
artifact_store = FileWorkflowArtifactStore(tmp_path / "draft_add_empty_routes")
|
|
draft_api, _service, authoring = _draft_api(artifact_store, register_echo=True)
|
|
api = WorkflowApi(authoring.context, drafts=True)
|
|
await draft_api.create_draft_workspace(workspace_id="draft_ws", draft=_echo_draft())
|
|
|
|
step_adapter = TypeAdapter(DraftStep)
|
|
step_without_routes = step_adapter.validate_python(
|
|
{"use": "demo.personal.echo_tool", "input": [], "output": []}
|
|
)
|
|
first = await api.add_step(
|
|
workspace_id="draft_ws",
|
|
revision=1,
|
|
step_id="no_routes",
|
|
step=step_without_routes,
|
|
routes=None,
|
|
)
|
|
assert first["revision"] == 2
|
|
after_none = await draft_api.get_draft_workspace(
|
|
workspace_id="draft_ws", include_draft=True
|
|
)
|
|
assert "no_routes" not in after_none["draft"]["routes"]
|
|
|
|
step_with_empty_routes = step_adapter.validate_python(
|
|
{"use": "demo.personal.echo_tool", "input": [], "output": []}
|
|
)
|
|
second = await api.add_step(
|
|
workspace_id="draft_ws",
|
|
revision=2,
|
|
step_id="empty_routes",
|
|
step=step_with_empty_routes,
|
|
routes={},
|
|
)
|
|
assert second["revision"] == 3
|
|
after_empty = await draft_api.get_draft_workspace(
|
|
workspace_id="draft_ws", include_draft=True
|
|
)
|
|
assert after_empty["draft"]["routes"]["empty_routes"] == {}
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_add_step_rejects_unknown_incoming_outcome_without_mutation(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
artifact_store = FileWorkflowArtifactStore(tmp_path / "draft_add_bad_incoming")
|
|
draft_api, _service, authoring = _draft_api(artifact_store, register_echo=True)
|
|
api = WorkflowApi(authoring.context, drafts=True)
|
|
await draft_api.create_draft_workspace(workspace_id="draft_ws", draft=_echo_draft())
|
|
|
|
draft_store = authoring.drafts._draft_store()
|
|
assert isinstance(draft_store, FileDraftWorkspaceStore)
|
|
workspace_path = draft_store._workspace_path("draft_ws")
|
|
before_bytes = workspace_path.read_bytes()
|
|
before = await draft_api.get_draft_workspace(
|
|
workspace_id="draft_ws", include_draft=True
|
|
)
|
|
step = TypeAdapter(DraftStep).validate_python(
|
|
{"use": "demo.personal.echo_tool", "input": [], "output": []}
|
|
)
|
|
|
|
with pytest.raises(ValueError, match="unknown incoming route outcome"):
|
|
await api.add_step(
|
|
workspace_id="draft_ws",
|
|
revision=1,
|
|
step_id="new_step",
|
|
step=step,
|
|
incoming=RouteSource("echo", "missing"),
|
|
)
|
|
|
|
after = await draft_api.get_draft_workspace(
|
|
workspace_id="draft_ws", include_draft=True
|
|
)
|
|
assert workspace_path.read_bytes() == before_bytes
|
|
assert after["revision"] == before["revision"]
|
|
assert after["draft"] == before["draft"]
|
|
|
|
|
|
async def _assert_add_step_rejected_without_mutation(
|
|
api: WorkflowApi,
|
|
draft_api: WorkflowDraftApi,
|
|
*,
|
|
step_id: str = "new_step",
|
|
incoming: RouteSource | None = None,
|
|
routes: dict[str, str] | None = None,
|
|
message: str,
|
|
) -> None:
|
|
before = await draft_api.get_draft_workspace(
|
|
workspace_id="draft_ws", include_draft=True
|
|
)
|
|
|
|
step = TypeAdapter(DraftStep).validate_python(
|
|
{"use": "demo.personal.echo_tool", "input": [], "output": []}
|
|
)
|
|
with pytest.raises(ValueError, match=message):
|
|
await api.add_step(
|
|
workspace_id="draft_ws",
|
|
revision=1,
|
|
step_id=step_id,
|
|
step=step,
|
|
incoming=incoming,
|
|
routes=routes,
|
|
)
|
|
|
|
after = await draft_api.get_draft_workspace(
|
|
workspace_id="draft_ws", include_draft=True
|
|
)
|
|
assert after["revision"] == before["revision"]
|
|
assert after["draft"] == before["draft"]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_add_step_rejects_invalid_routing_inputs_atomically(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
artifact_store = FileWorkflowArtifactStore(tmp_path / "draft_add_errors")
|
|
draft_api, _service, authoring = _draft_api(artifact_store, register_echo=True)
|
|
api = WorkflowApi(authoring.context, drafts=True)
|
|
await draft_api.create_draft_workspace(workspace_id="draft_ws", draft=_echo_draft())
|
|
|
|
await _assert_add_step_rejected_without_mutation(
|
|
api,
|
|
draft_api,
|
|
incoming=RouteSource("missing", "ok"),
|
|
message="unknown incoming source",
|
|
)
|
|
|
|
await _assert_add_step_rejected_without_mutation(
|
|
api,
|
|
draft_api,
|
|
step_id="echo",
|
|
routes={"ok": "__end__"},
|
|
message="already exists",
|
|
)
|
|
|
|
await _assert_add_step_rejected_without_mutation(
|
|
api,
|
|
draft_api,
|
|
routes={"typo": "__end__"},
|
|
message="unknown route outcome",
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize(
|
|
"step_payload",
|
|
[
|
|
{"end": {"outcome": "ok"}},
|
|
{
|
|
"when": {
|
|
"if": {"op": "exists", "path": "state.ready"},
|
|
"then": "echo",
|
|
}
|
|
},
|
|
{
|
|
"choose": {
|
|
"clauses": [
|
|
{
|
|
"if": {"op": "exists", "path": "state.ready"},
|
|
"then": "echo",
|
|
}
|
|
]
|
|
}
|
|
},
|
|
{
|
|
"match": {
|
|
"value": "state.status",
|
|
"cases": [{"equals": "ready", "then": "echo"}],
|
|
}
|
|
},
|
|
],
|
|
)
|
|
async def test_add_step_rejects_routes_for_non_routable_steps_atomically(
|
|
tmp_path: Path,
|
|
step_payload: dict[str, Any],
|
|
) -> None:
|
|
artifact_store = FileWorkflowArtifactStore(tmp_path / "draft_add_forbidden_routes")
|
|
draft_api, _service, authoring = _draft_api(artifact_store, register_echo=True)
|
|
api = WorkflowApi(authoring.context, drafts=True)
|
|
await draft_api.create_draft_workspace(workspace_id="draft_ws", draft=_echo_draft())
|
|
|
|
before = await draft_api.get_draft_workspace(
|
|
workspace_id="draft_ws", include_draft=True
|
|
)
|
|
step = TypeAdapter(DraftStep).validate_python(step_payload)
|
|
with pytest.raises(ValueError, match="routes are not allowed"):
|
|
await api.add_step(
|
|
workspace_id="draft_ws",
|
|
revision=1,
|
|
step_id="new_step",
|
|
step=step,
|
|
routes={"ok": "__end__"},
|
|
)
|
|
after = await draft_api.get_draft_workspace(
|
|
workspace_id="draft_ws", include_draft=True
|
|
)
|
|
assert after["revision"] == before["revision"]
|
|
assert after["draft"] == before["draft"]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_add_step_accepts_incomplete_declared_route_subset(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
artifact_store = FileWorkflowArtifactStore(tmp_path / "draft_add_partial_routes")
|
|
draft_api, service, authoring = _draft_api(artifact_store, register_echo=True)
|
|
service.register_specs("demo.personal", echo_tool, _snapshot_tool)
|
|
api = WorkflowApi(authoring.context, drafts=True)
|
|
await draft_api.create_draft_workspace(workspace_id="draft_ws", draft=_echo_draft())
|
|
|
|
step = TypeAdapter(DraftStep).validate_python(
|
|
{"use": "demo.personal.snapshot_tool", "input": [], "output": []}
|
|
)
|
|
result = await api.add_step(
|
|
workspace_id="draft_ws",
|
|
revision=1,
|
|
step_id="new_step",
|
|
step=step,
|
|
routes={"ok": "__end__"},
|
|
)
|
|
|
|
assert result["revision"] == 2
|
|
workspace = await draft_api.get_draft_workspace(
|
|
workspace_id="draft_ws", include_draft=True
|
|
)
|
|
assert workspace["draft"]["routes"]["new_step"] == {"ok": "__end__"}
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_branch_draft_updates_routes_atomically(tmp_path: Path) -> None:
|
|
artifact_store = FileWorkflowArtifactStore(tmp_path / "drafts_branch")
|
|
api, _service, authoring = _draft_api(artifact_store, register_echo=True)
|
|
await api.create_draft_workspace(
|
|
workspace_id="branching",
|
|
draft={
|
|
"name": "branching",
|
|
"input_schema": {"type": "object", "properties": {}},
|
|
"state_schema": {"type": "object", "properties": {}},
|
|
"output_schema": {"type": "object", "properties": {}},
|
|
"start": "classify",
|
|
"steps": {
|
|
"classify": {
|
|
"use": "demo.personal.echo_tool",
|
|
"input": [],
|
|
"output": [],
|
|
},
|
|
"tool_error": {
|
|
"use": "demo.personal.echo_tool",
|
|
"input": [],
|
|
"output": [],
|
|
},
|
|
},
|
|
"routes": {
|
|
"classify": {"ok": "classify"},
|
|
"tool_error": {"ok": "__end__"},
|
|
},
|
|
},
|
|
)
|
|
|
|
result = await authoring.branch_draft(
|
|
workspace_id="branching",
|
|
revision=1,
|
|
step_id="classify",
|
|
routes={"ok": "classify", "error": "tool_error"},
|
|
)
|
|
assert result["revision"] == 2
|
|
workspace = await api.get_draft_workspace(
|
|
workspace_id="branching", include_draft=True
|
|
)
|
|
assert workspace["draft"]["routes"]["classify"] == {
|
|
"ok": "classify",
|
|
"error": "tool_error",
|
|
}
|
|
assert "tool_error" in workspace["draft"]["steps"]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_handle_draft_updates_multiple_source_outcomes(tmp_path: Path) -> None:
|
|
artifact_store = FileWorkflowArtifactStore(tmp_path / "drafts_handle")
|
|
api, _service, authoring = _draft_api(artifact_store, register_echo=True)
|
|
await api.create_draft_workspace(
|
|
workspace_id="handling",
|
|
draft={
|
|
"name": "handling",
|
|
"input_schema": {"type": "object", "properties": {}},
|
|
"state_schema": {"type": "object", "properties": {}},
|
|
"output_schema": {"type": "object", "properties": {}},
|
|
"start": "lookup",
|
|
"steps": {
|
|
"lookup": {
|
|
"use": "demo.personal.echo_tool",
|
|
"input": [],
|
|
"output": [],
|
|
},
|
|
"transform": {
|
|
"use": "demo.personal.echo_tool",
|
|
"input": [],
|
|
"output": [],
|
|
},
|
|
},
|
|
"routes": {
|
|
"lookup": {"ok": "transform", "error": "lookup"},
|
|
"transform": {"ok": "__end__", "error": "transform"},
|
|
},
|
|
},
|
|
)
|
|
|
|
result = await authoring.handle_draft(
|
|
workspace_id="handling",
|
|
revision=1,
|
|
branches=[
|
|
RouteSource(step_id="lookup", outcome="error"),
|
|
RouteSource(step_id="transform", outcome="error"),
|
|
],
|
|
target="__end__",
|
|
)
|
|
assert result["revision"] == 2
|
|
workspace = await api.get_draft_workspace(
|
|
workspace_id="handling", include_draft=True
|
|
)
|
|
assert workspace["draft"]["routes"]["lookup"]["error"] == "__end__"
|
|
assert workspace["draft"]["routes"]["transform"]["error"] == "__end__"
|
|
assert workspace["draft"]["routes"]["lookup"]["ok"] == "transform"
|
|
assert workspace["draft"]["routes"]["transform"]["ok"] == "__end__"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_branch_draft_no_change_when_routes_unchanged(tmp_path: Path) -> None:
|
|
artifact_store = FileWorkflowArtifactStore(tmp_path / "drafts_branch_noop")
|
|
api, _service, authoring = _draft_api(artifact_store, register_echo=True)
|
|
await api.create_draft_workspace(
|
|
workspace_id="noop_ws",
|
|
draft=_echo_draft(),
|
|
)
|
|
before = await api.get_draft_workspace(workspace_id="noop_ws", include_draft=True)
|
|
|
|
result = await authoring.branch_draft(
|
|
workspace_id="noop_ws",
|
|
revision=1,
|
|
step_id="echo",
|
|
routes={"ok": "__end__"},
|
|
)
|
|
after = await api.get_draft_workspace(workspace_id="noop_ws", include_draft=True)
|
|
assert result["revision"] == 1
|
|
assert after == before
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_branch_draft_no_change_still_checks_revision(tmp_path: Path) -> None:
|
|
artifact_store = FileWorkflowArtifactStore(tmp_path / "drafts_branch_noop_stale")
|
|
api, _service, authoring = _draft_api(artifact_store, register_echo=True)
|
|
await api.create_draft_workspace(
|
|
workspace_id="noop_ws",
|
|
draft=_echo_draft(),
|
|
)
|
|
|
|
result = await authoring.branch_draft(
|
|
workspace_id="noop_ws",
|
|
revision=2,
|
|
step_id="echo",
|
|
routes={"ok": "__end__"},
|
|
)
|
|
|
|
assert result["status"] == "conflict"
|
|
assert result["diagnostics"][0]["code"] == "revision_conflict"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_handle_draft_empty_branches_noop(tmp_path: Path) -> None:
|
|
artifact_store = FileWorkflowArtifactStore(tmp_path / "drafts_handle_noop")
|
|
api, _service, authoring = _draft_api(artifact_store, register_echo=True)
|
|
await api.create_draft_workspace(
|
|
workspace_id="noop_ws",
|
|
draft=_echo_draft(),
|
|
)
|
|
before = await api.get_draft_workspace(workspace_id="noop_ws", include_draft=True)
|
|
|
|
result = await authoring.handle_draft(
|
|
workspace_id="noop_ws",
|
|
revision=1,
|
|
branches=[],
|
|
target="fail",
|
|
)
|
|
after = await api.get_draft_workspace(workspace_id="noop_ws", include_draft=True)
|
|
assert result["revision"] == 1
|
|
assert after == before
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_handle_draft_no_change_still_checks_revision(tmp_path: Path) -> None:
|
|
artifact_store = FileWorkflowArtifactStore(tmp_path / "drafts_handle_noop_stale")
|
|
api, _service, authoring = _draft_api(artifact_store, register_echo=True)
|
|
await api.create_draft_workspace(
|
|
workspace_id="noop_ws",
|
|
draft=_echo_draft(),
|
|
)
|
|
|
|
result = await authoring.handle_draft(
|
|
workspace_id="noop_ws",
|
|
revision=2,
|
|
branches=[],
|
|
target="fail",
|
|
)
|
|
|
|
assert result["status"] == "conflict"
|
|
assert result["diagnostics"][0]["code"] == "revision_conflict"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize(
|
|
"operation",
|
|
["branch", "handle", "remove_route", "remove_step", "remove_binding"],
|
|
)
|
|
async def test_route_and_remove_edits_stale_revision_wins_over_semantic_errors(
|
|
tmp_path: Path,
|
|
operation: str,
|
|
) -> None:
|
|
artifact_store = FileWorkflowArtifactStore(
|
|
tmp_path / f"draft_route_remove_stale_{operation}"
|
|
)
|
|
api, _service, authoring = _draft_api(artifact_store, register_echo=True)
|
|
draft = _echo_draft()
|
|
if operation in {"branch", "handle", "remove_route"}:
|
|
draft["routes"] = "not-an-object"
|
|
elif operation == "remove_step":
|
|
draft["steps"] = "not-an-object"
|
|
await api.create_draft_workspace(workspace_id="draft_ws", draft=draft)
|
|
before = await api.get_draft_workspace(
|
|
workspace_id="draft_ws",
|
|
include_draft=True,
|
|
)
|
|
|
|
if operation == "branch":
|
|
result = await authoring.branch_draft(
|
|
workspace_id="draft_ws",
|
|
revision=2,
|
|
step_id="echo",
|
|
routes={"error": "__end__"},
|
|
)
|
|
elif operation == "handle":
|
|
result = await authoring.handle_draft(
|
|
workspace_id="draft_ws",
|
|
revision=2,
|
|
branches=[RouteSource(step_id="echo", outcome="error")],
|
|
target="__end__",
|
|
)
|
|
elif operation == "remove_route":
|
|
result = await authoring.remove_draft_route(
|
|
workspace_id="draft_ws",
|
|
revision=2,
|
|
step_id="echo",
|
|
outcome="ok",
|
|
)
|
|
elif operation == "remove_step":
|
|
result = await authoring.remove_draft_step(
|
|
workspace_id="draft_ws",
|
|
revision=2,
|
|
step_id="echo",
|
|
)
|
|
else:
|
|
result = await authoring.remove_draft_binding(
|
|
workspace_id="draft_ws",
|
|
revision=2,
|
|
step_id="missing",
|
|
inputs=("text",),
|
|
)
|
|
|
|
after = await api.get_draft_workspace(
|
|
workspace_id="draft_ws",
|
|
include_draft=True,
|
|
)
|
|
assert result["status"] == "conflict"
|
|
assert result["revision"] == before["revision"]
|
|
assert result["diagnostics"][0]["code"] == "revision_conflict"
|
|
assert after == before
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_add_step_from_capability_infers_single_outcome_route(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
"""One declared outcome named 'done', no routes supplied -> routes to __end__."""
|
|
artifact_store = FileWorkflowArtifactStore(tmp_path / "drafts_single_outcome")
|
|
api, service, authoring = _draft_api(artifact_store, register_echo=True)
|
|
service.register_specs("demo.personal", echo_tool)
|
|
await api.create_draft_workspace(
|
|
workspace_id="single",
|
|
draft=_echo_draft(),
|
|
)
|
|
|
|
result = await authoring.add_step_from_capability(
|
|
workspace_id="single",
|
|
revision=1,
|
|
step_id="done_step",
|
|
capability_name="demo.personal.echo_tool",
|
|
)
|
|
assert result["revision"] == 2
|
|
workspace = await api.get_draft_workspace(workspace_id="single", include_draft=True)
|
|
assert workspace["draft"]["routes"]["done_step"] == {"ok": "__end__"}
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_add_step_from_capability_requires_complete_routes_for_multi_outcome(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
"""Multiple declared outcomes with incomplete explicit routes raises ValueError."""
|
|
artifact_store = FileWorkflowArtifactStore(tmp_path / "drafts_multi_outcome")
|
|
api, service, authoring = _draft_api(artifact_store, register_echo=True)
|
|
service.register_specs("demo.personal", echo_tool, _snapshot_tool)
|
|
await api.create_draft_workspace(
|
|
workspace_id="multi",
|
|
draft=_echo_draft(),
|
|
)
|
|
|
|
with pytest.raises(ValueError, match="missing routes"):
|
|
await authoring.add_step_from_capability(
|
|
workspace_id="multi",
|
|
revision=1,
|
|
step_id="snap",
|
|
capability_name="demo.personal.snapshot_tool",
|
|
routes={"ok": "__end__"},
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_add_step_from_capability_rejects_unknown_routes_for_multi_outcome(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
artifact_store = FileWorkflowArtifactStore(tmp_path / "drafts_unknown_outcome")
|
|
api, service, authoring = _draft_api(artifact_store, register_echo=True)
|
|
service.register_specs("demo.personal", _snapshot_tool)
|
|
await api.create_draft_workspace(
|
|
workspace_id="unknown_multi",
|
|
draft=_echo_draft(),
|
|
)
|
|
|
|
with pytest.raises(ValueError, match="unknown routes") as exc_info:
|
|
await authoring.add_step_from_capability(
|
|
workspace_id="unknown_multi",
|
|
revision=1,
|
|
step_id="snap",
|
|
capability_name="demo.personal.snapshot_tool",
|
|
routes={"ok": "__end__", "skipped": "__end__", "typo": "__end__"},
|
|
)
|
|
|
|
message = str(exc_info.value)
|
|
assert "declares outcomes ('ok', 'skipped')" in message
|
|
assert "unknown routes ['typo']" in message
|
|
assert "remove --route entries for ['typo']" in message
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_add_step_rejects_unknown_single_outcome_route_with_repair(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
artifact_store = FileWorkflowArtifactStore(
|
|
tmp_path / "drafts_unknown_single_outcome"
|
|
)
|
|
api, _service, authoring = _draft_api(artifact_store, register_echo=True)
|
|
await api.create_draft_workspace(
|
|
workspace_id="unknown_single",
|
|
draft=_echo_draft(),
|
|
)
|
|
|
|
with pytest.raises(ValueError) as exc_info:
|
|
await authoring.add_step_from_capability(
|
|
workspace_id="unknown_single",
|
|
revision=1,
|
|
step_id="second",
|
|
capability_name="demo.personal.echo_tool",
|
|
routes={"ok": "__end__", "error": "fail"},
|
|
)
|
|
|
|
message = str(exc_info.value)
|
|
assert "declares outcomes ('ok',)" in message
|
|
assert "unknown routes ['error']" in message
|
|
assert "remove --route entries for ['error']" in message
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_add_step_rejects_missing_outcomes_only_with_repair(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
artifact_store = FileWorkflowArtifactStore(tmp_path / "drafts_missing_outcome_only")
|
|
api, service, authoring = _draft_api(artifact_store, register_echo=True)
|
|
service.register_specs("demo.personal", _snapshot_tool)
|
|
await api.create_draft_workspace(
|
|
workspace_id="missing_single",
|
|
draft=_echo_draft(),
|
|
)
|
|
|
|
with pytest.raises(ValueError) as exc_info:
|
|
await authoring.add_step_from_capability(
|
|
workspace_id="missing_single",
|
|
revision=1,
|
|
step_id="snap",
|
|
capability_name="demo.personal.snapshot_tool",
|
|
routes={"ok": "__end__"},
|
|
)
|
|
|
|
message = str(exc_info.value)
|
|
assert "declares outcomes ('ok', 'skipped')" in message
|
|
assert "missing routes ['skipped']" in message
|
|
assert "add --route OUTCOME=TARGET for ['skipped']" in message
|
|
|
|
|
|
# -- Draft remove helpers --
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_remove_draft_route_persists_invalid_workspace(tmp_path: Path) -> None:
|
|
artifact_store = FileWorkflowArtifactStore(tmp_path / "remove_route")
|
|
api, _service, authoring = _draft_api(artifact_store)
|
|
await api.create_draft_workspace(
|
|
workspace_id="route_ws",
|
|
draft={
|
|
"name": "route_ws",
|
|
"start": "echo",
|
|
"steps": {
|
|
"echo": {"use": "demo.personal.echo_tool", "input": [], "output": []}
|
|
},
|
|
"routes": {"echo": {"ok": "__end__"}},
|
|
"input_schema": {"type": "object", "properties": {}},
|
|
"state_schema": {"type": "object", "properties": {}},
|
|
"output_schema": {"type": "object", "properties": {}},
|
|
},
|
|
)
|
|
|
|
result = await authoring.remove_draft_route(
|
|
workspace_id="route_ws",
|
|
revision=1,
|
|
step_id="echo",
|
|
outcome="ok",
|
|
)
|
|
|
|
assert result["revision"] == 2
|
|
assert result["status"] == "invalid"
|
|
fetched = await api.get_draft_workspace(
|
|
workspace_id="route_ws",
|
|
include_draft=True,
|
|
)
|
|
assert fetched["draft"]["routes"]["echo"] == {}
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_remove_draft_step_removes_outgoing_routes_not_inbound_routes(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
artifact_store = FileWorkflowArtifactStore(tmp_path / "remove_step")
|
|
api, _service, authoring = _draft_api(artifact_store)
|
|
await api.create_draft_workspace(
|
|
workspace_id="step_ws",
|
|
draft={
|
|
"name": "step_ws",
|
|
"start": "first",
|
|
"steps": {
|
|
"first": {"use": "demo.personal.echo_tool", "input": [], "output": []},
|
|
"second": {"use": "demo.personal.echo_tool", "input": [], "output": []},
|
|
},
|
|
"routes": {
|
|
"first": {"ok": "second"},
|
|
"second": {"ok": "__end__"},
|
|
},
|
|
"input_schema": {"type": "object", "properties": {}},
|
|
"state_schema": {"type": "object", "properties": {}},
|
|
"output_schema": {"type": "object", "properties": {}},
|
|
},
|
|
)
|
|
|
|
result = await authoring.remove_draft_step(
|
|
workspace_id="step_ws",
|
|
revision=1,
|
|
step_id="second",
|
|
)
|
|
|
|
assert result["revision"] == 2
|
|
assert result["status"] == "invalid"
|
|
assert any(
|
|
item["code"] == "unknown_edge_destination" for item in result["diagnostics"]
|
|
)
|
|
fetched = await api.get_draft_workspace(
|
|
workspace_id="step_ws",
|
|
include_draft=True,
|
|
)
|
|
assert "second" not in fetched["draft"]["steps"]
|
|
assert "second" not in fetched["draft"]["routes"]
|
|
assert fetched["draft"]["routes"]["first"]["ok"] == "second"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_remove_draft_binding_removes_input_and_output_bindings(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
artifact_store = FileWorkflowArtifactStore(tmp_path / "remove_binding")
|
|
api, _service, authoring = _draft_api(artifact_store)
|
|
await api.create_draft_workspace(
|
|
workspace_id="binding_ws",
|
|
draft={
|
|
"name": "binding_ws",
|
|
"start": "echo",
|
|
"steps": {
|
|
"echo": {
|
|
"use": "demo.personal.echo_tool",
|
|
"input": [
|
|
{"path": "input.message", "target": "message"},
|
|
{"path": "input.extra", "target": "extra"},
|
|
],
|
|
"output": [
|
|
{"source": "echoed", "target": "state.echoed"},
|
|
{"source": "debug", "target": "state.debug"},
|
|
],
|
|
}
|
|
},
|
|
"routes": {"echo": {"ok": "__end__"}},
|
|
"input_schema": {"type": "object", "properties": {}},
|
|
"state_schema": {"type": "object", "properties": {}},
|
|
"output_schema": {"type": "object", "properties": {}},
|
|
},
|
|
)
|
|
|
|
result = await authoring.remove_draft_binding(
|
|
workspace_id="binding_ws",
|
|
revision=1,
|
|
step_id="echo",
|
|
inputs=("message",),
|
|
outputs=("debug",),
|
|
)
|
|
|
|
assert result["revision"] == 2
|
|
fetched = await api.get_draft_workspace(
|
|
workspace_id="binding_ws",
|
|
include_draft=True,
|
|
)
|
|
assert fetched["draft"]["steps"]["echo"]["input"] == [
|
|
{"path": "input.extra", "target": "extra"}
|
|
]
|
|
assert fetched["draft"]["steps"]["echo"]["output"] == [
|
|
{"source": "echoed", "target": "state.echoed"}
|
|
]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_remove_draft_binding_envelope_error_precedes_revision_check(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
artifact_store = FileWorkflowArtifactStore(
|
|
tmp_path / "draft_remove_binding_envelope_precedence"
|
|
)
|
|
api, _service, authoring = _draft_api(artifact_store, register_echo=True)
|
|
await api.create_draft_workspace(workspace_id="draft_ws", draft=_echo_draft())
|
|
before = await api.get_draft_workspace(
|
|
workspace_id="draft_ws",
|
|
include_draft=True,
|
|
)
|
|
|
|
with pytest.raises(
|
|
ValueError,
|
|
match="pass at least one input or output binding to remove",
|
|
):
|
|
await authoring.remove_draft_binding(
|
|
workspace_id="draft_ws",
|
|
revision=2,
|
|
step_id="echo",
|
|
)
|
|
|
|
after = await api.get_draft_workspace(
|
|
workspace_id="draft_ws",
|
|
include_draft=True,
|
|
)
|
|
assert after == before
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_remove_missing_draft_element_is_noop(tmp_path: Path) -> None:
|
|
artifact_store = FileWorkflowArtifactStore(tmp_path / "remove_noop")
|
|
api, _service, authoring = _draft_api(artifact_store)
|
|
await api.create_draft_workspace(
|
|
workspace_id="noop_ws",
|
|
draft={
|
|
"name": "noop_ws",
|
|
"start": "echo",
|
|
"steps": {
|
|
"echo": {"use": "demo.personal.echo_tool", "input": [], "output": []}
|
|
},
|
|
"routes": {"echo": {"ok": "__end__"}},
|
|
"input_schema": {"type": "object", "properties": {}},
|
|
"state_schema": {"type": "object", "properties": {}},
|
|
"output_schema": {"type": "object", "properties": {}},
|
|
},
|
|
)
|
|
|
|
result = await authoring.remove_draft_route(
|
|
workspace_id="noop_ws",
|
|
revision=1,
|
|
step_id="echo",
|
|
outcome="missing",
|
|
)
|
|
|
|
assert result["revision"] == 1
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_compile_draft_workspace_returns_compiled_plan(tmp_path: Path) -> None:
|
|
artifact_store = FileWorkflowArtifactStore(tmp_path / "drafts_compile")
|
|
api, _service, _authoring = _draft_api(artifact_store, register_echo=True)
|
|
await api.create_draft_workspace(
|
|
workspace_id="compile_me",
|
|
draft=_echo_draft(),
|
|
)
|
|
before = await api.get_draft_workspace(
|
|
workspace_id="compile_me", include_draft=True
|
|
)
|
|
result = await api.compile_draft_workspace(workspace_id="compile_me")
|
|
after = await api.get_draft_workspace(workspace_id="compile_me", include_draft=True)
|
|
assert _compiled_workspace(result)["name"] == "echo"
|
|
assert "required_capabilities" in result
|
|
assert result["required_capabilities"]
|
|
assert after == before
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_compile_draft_workspace_invalid_returns_diagnostics(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
artifact_store = FileWorkflowArtifactStore(tmp_path / "drafts_compile_invalid")
|
|
api, _service, _authoring = _draft_api(artifact_store, register_echo=True)
|
|
draft = _echo_draft()
|
|
draft["routes"]["echo"] = {"typo": "__end__"}
|
|
await api.create_draft_workspace(
|
|
workspace_id="invalid_ws",
|
|
draft=draft,
|
|
)
|
|
result = await api.compile_draft_workspace(workspace_id="invalid_ws")
|
|
assert "status" in result
|
|
assert result["status"] == "invalid"
|
|
assert "compiled_plan" not in result
|
|
assert result["diagnostics"]
|
|
|
|
|
|
def _workflow_output_binding_draft() -> dict[str, Any]:
|
|
draft = _echo_draft()
|
|
draft["state_schema"] = {
|
|
"type": "object",
|
|
"properties": {
|
|
"report": {
|
|
"type": "object",
|
|
"properties": {"title": {"type": "string"}},
|
|
},
|
|
"items": {"type": "array", "items": {"type": "string"}},
|
|
},
|
|
}
|
|
draft["output_schema"] = {
|
|
"type": "object",
|
|
"properties": {
|
|
"format": {"type": "string"},
|
|
"metadata": {
|
|
"type": "object",
|
|
"properties": {"reviewed": {"type": "boolean"}},
|
|
"required": ["reviewed"],
|
|
},
|
|
"tags": {"type": "array", "items": {"type": "string"}},
|
|
"note": {"type": ["string", "null"]},
|
|
"context_value": {},
|
|
},
|
|
}
|
|
draft["output"] = []
|
|
return draft
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_set_workflow_output_bindings_projects_nested_paths_and_literals(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
draft_api, _service, authoring = _draft_api(
|
|
FileWorkflowArtifactStore(tmp_path / "workflow_outputs"),
|
|
register_echo=True,
|
|
)
|
|
draft = _workflow_output_binding_draft()
|
|
await draft_api.create_draft_workspace(workspace_id="report", draft=draft)
|
|
bindings: list[InputBinding] = [
|
|
InputPathBinding(
|
|
path=GraphSourcePath.state("report", "title"),
|
|
target=LocalPath.of("report", "title"),
|
|
),
|
|
InputPathBinding(
|
|
path=GraphSourcePath.state("report", "title"),
|
|
target=LocalPath.of("audit", "title"),
|
|
),
|
|
InputValueBinding(target=LocalPath.of("format"), value="markdown"),
|
|
]
|
|
|
|
first = await authoring.set_workflow_output_bindings(
|
|
workspace_id="report",
|
|
revision=1,
|
|
bindings=bindings,
|
|
)
|
|
second = await authoring.set_workflow_output_bindings(
|
|
workspace_id="report",
|
|
revision=first["revision"],
|
|
bindings=bindings,
|
|
)
|
|
inspected = await draft_api.get_draft_workspace(
|
|
workspace_id="report",
|
|
include_draft=True,
|
|
)
|
|
|
|
assert first["revision"] == 2
|
|
assert second["revision"] == 2
|
|
assert inspected["draft"]["output"] == [
|
|
{"path": "state.report.title", "target": "report.title"},
|
|
{"path": "state.report.title", "target": "audit.title"},
|
|
{"value": "markdown", "target": "format"},
|
|
]
|
|
assert inspected["draft"]["output_schema"]["properties"]["report"]["properties"][
|
|
"title"
|
|
] == {"type": "string"}
|
|
assert inspected["draft"]["output_schema"]["properties"]["audit"]["properties"][
|
|
"title"
|
|
] == {"type": "string"}
|
|
|
|
cleared = await authoring.set_workflow_output_bindings(
|
|
workspace_id="report",
|
|
revision=2,
|
|
bindings=[],
|
|
)
|
|
cleared_workspace = await draft_api.get_draft_workspace(
|
|
workspace_id="report",
|
|
include_draft=True,
|
|
)
|
|
assert cleared["revision"] == 3
|
|
assert cleared_workspace["draft"]["output"] == []
|
|
assert (
|
|
cleared_workspace["draft"]["output_schema"]
|
|
== inspected["draft"]["output_schema"]
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_set_workflow_output_bindings_projects_input_and_whole_state(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
draft_api, _service, authoring = _draft_api(
|
|
FileWorkflowArtifactStore(tmp_path / "workflow_output_sources"),
|
|
register_echo=True,
|
|
)
|
|
draft = _workflow_output_binding_draft()
|
|
draft["input_schema"]["properties"]["request"] = {
|
|
"type": "object",
|
|
"properties": {"title": {"type": "string"}},
|
|
}
|
|
await draft_api.create_draft_workspace(workspace_id="report", draft=draft)
|
|
|
|
await authoring.set_workflow_output_bindings(
|
|
workspace_id="report",
|
|
revision=1,
|
|
bindings=[
|
|
InputPathBinding(
|
|
path=GraphSourcePath.input("request", "title"),
|
|
target=LocalPath.of("input_title"),
|
|
),
|
|
InputPathBinding(
|
|
path=GraphSourcePath.state(),
|
|
target=LocalPath.of("snapshot"),
|
|
),
|
|
],
|
|
)
|
|
inspected = await draft_api.get_draft_workspace(
|
|
workspace_id="report",
|
|
include_draft=True,
|
|
)
|
|
|
|
output_schema = inspected["draft"]["output_schema"]["properties"]
|
|
assert output_schema["input_title"] == {"type": "string"}
|
|
assert output_schema["snapshot"]["properties"]["report"]["properties"]["title"] == {
|
|
"type": "string"
|
|
}
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_set_workflow_output_bindings_accepts_declared_context_and_literals(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
draft_api, _service, authoring = _draft_api(
|
|
FileWorkflowArtifactStore(tmp_path / "workflow_output_values"),
|
|
register_echo=True,
|
|
)
|
|
await draft_api.create_draft_workspace(
|
|
workspace_id="report",
|
|
draft=_workflow_output_binding_draft(),
|
|
)
|
|
bindings: list[InputBinding] = [
|
|
InputPathBinding(
|
|
path=GraphSourcePath.context("prior_outcome"),
|
|
target=LocalPath.of("context_value"),
|
|
),
|
|
InputValueBinding(
|
|
target=LocalPath.of("metadata"),
|
|
value={"reviewed": True},
|
|
),
|
|
InputValueBinding(target=LocalPath.of("tags"), value=["thesis"]),
|
|
InputValueBinding(target=LocalPath.of("note"), value=None),
|
|
]
|
|
|
|
result = await authoring.set_workflow_output_bindings(
|
|
workspace_id="report",
|
|
revision=1,
|
|
bindings=bindings,
|
|
)
|
|
inspected = await draft_api.get_draft_workspace(
|
|
workspace_id="report",
|
|
include_draft=True,
|
|
)
|
|
|
|
assert result["revision"] == 2
|
|
assert inspected["draft"]["output"] == [
|
|
{"path": "context.prior_outcome", "target": "context_value"},
|
|
{"value": {"reviewed": True}, "target": "metadata"},
|
|
{"value": ["thesis"], "target": "tags"},
|
|
{"value": None, "target": "note"},
|
|
]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_set_workflow_output_bindings_accepts_strict_root_bindings(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
draft_api, _service, authoring = _draft_api(
|
|
FileWorkflowArtifactStore(tmp_path / "workflow_output_root"),
|
|
register_echo=True,
|
|
)
|
|
schema = {
|
|
"type": "object",
|
|
"properties": {"text": {"type": "string"}},
|
|
"required": ["text"],
|
|
}
|
|
draft = _echo_draft()
|
|
draft["input_schema"] = schema
|
|
draft["output_schema"] = schema
|
|
draft["output"] = []
|
|
await draft_api.create_draft_workspace(workspace_id="report", draft=draft)
|
|
|
|
path_result = await authoring.set_workflow_output_bindings(
|
|
workspace_id="report",
|
|
revision=1,
|
|
bindings=[
|
|
InputPathBinding(
|
|
path=GraphSourcePath.input(),
|
|
target=LocalPath.root(),
|
|
)
|
|
],
|
|
)
|
|
literal_result = await authoring.set_workflow_output_bindings(
|
|
workspace_id="report",
|
|
revision=path_result["revision"],
|
|
bindings=[
|
|
InputValueBinding(
|
|
target=LocalPath.root(),
|
|
value={"text": "Thesis"},
|
|
)
|
|
],
|
|
)
|
|
|
|
assert path_result["revision"] == 2
|
|
assert literal_result["revision"] == 3
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_set_workflow_output_bindings_validates_root_literal_complete_schema(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
draft_api, _service, authoring = _draft_api(
|
|
FileWorkflowArtifactStore(tmp_path / "workflow_output_root_literal"),
|
|
register_echo=True,
|
|
)
|
|
draft = _echo_draft()
|
|
draft["output_schema"] = {
|
|
"type": "object",
|
|
"properties": {"text": {"type": "string"}},
|
|
"required": ["text"],
|
|
}
|
|
draft["output"] = []
|
|
await draft_api.create_draft_workspace(workspace_id="report", draft=draft)
|
|
before = await draft_api.get_draft_workspace(
|
|
workspace_id="report",
|
|
include_draft=True,
|
|
)
|
|
|
|
with pytest.raises(
|
|
ValueError,
|
|
match=r"bindings\[0\]\.value does not satisfy schema at '\.'",
|
|
):
|
|
await authoring.set_workflow_output_bindings(
|
|
workspace_id="report",
|
|
revision=1,
|
|
bindings=[
|
|
InputValueBinding(
|
|
target=LocalPath.root(),
|
|
value={"wrong": "field"},
|
|
)
|
|
],
|
|
)
|
|
|
|
after = await draft_api.get_draft_workspace(
|
|
workspace_id="report",
|
|
include_draft=True,
|
|
)
|
|
assert after == before
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize(
|
|
("bindings", "message"),
|
|
[
|
|
(
|
|
[
|
|
InputPathBinding(
|
|
path=GraphSourcePath.state("missing"),
|
|
target=LocalPath.of("missing"),
|
|
)
|
|
],
|
|
r"bindings\[0\]\.path 'state\.missing' is not declared",
|
|
),
|
|
(
|
|
[
|
|
InputPathBinding(
|
|
path=GraphSourcePath.context("prior_outcome"),
|
|
target=LocalPath.of("missing"),
|
|
)
|
|
],
|
|
r"bindings\[0\]\.path 'context\.prior_outcome' requires a declared "
|
|
"output target",
|
|
),
|
|
(
|
|
[InputValueBinding(target=LocalPath.of("missing"), value="x")],
|
|
r"bindings\[0\]\.target 'missing' is not declared",
|
|
),
|
|
(
|
|
[
|
|
InputPathBinding(
|
|
path=GraphSourcePath.state("report"),
|
|
target=LocalPath.of("report"),
|
|
),
|
|
InputValueBinding(
|
|
target=LocalPath.of("report", "format"),
|
|
value="markdown",
|
|
),
|
|
],
|
|
r"bindings\[0\]\.target 'report' overlaps bindings\[1\]",
|
|
),
|
|
(
|
|
[
|
|
InputValueBinding(
|
|
target=LocalPath.of("format"),
|
|
value="markdown",
|
|
),
|
|
InputValueBinding(
|
|
target=LocalPath.of("format"),
|
|
value="json",
|
|
),
|
|
],
|
|
r"bindings\[0\]\.target 'format' overlaps bindings\[1\]",
|
|
),
|
|
(
|
|
[
|
|
InputPathBinding(
|
|
path=GraphSourcePath.state(),
|
|
target=LocalPath.root(),
|
|
),
|
|
InputValueBinding(
|
|
target=LocalPath.of("format"),
|
|
value="markdown",
|
|
),
|
|
],
|
|
r"bindings\[0\]\.target '\.' overlaps bindings\[1\]",
|
|
),
|
|
(
|
|
[
|
|
InputPathBinding(
|
|
path=GraphSourcePath.state("missing"),
|
|
target=LocalPath.of("report"),
|
|
),
|
|
InputValueBinding(
|
|
target=LocalPath.of("report", "format"),
|
|
value="markdown",
|
|
),
|
|
],
|
|
r"bindings\[0\]\.path 'state\.missing' is not declared",
|
|
),
|
|
(
|
|
[
|
|
InputPathBinding(
|
|
path=GraphSourcePath.state("report"),
|
|
target=LocalPath.of("format"),
|
|
)
|
|
],
|
|
r"bindings\[0\]\.target 'format' cannot receive source "
|
|
r"'state\.report'",
|
|
),
|
|
(
|
|
[
|
|
InputPathBinding(
|
|
path=GraphSourcePath.state("report"),
|
|
target=LocalPath.root(),
|
|
)
|
|
],
|
|
r"bindings\[0\]\.target '\.' already has an incompatible schema",
|
|
),
|
|
(
|
|
[
|
|
InputPathBinding(
|
|
path=GraphSourcePath.context("prior_outcome"),
|
|
target=LocalPath.root(),
|
|
)
|
|
],
|
|
r"bindings\[0\]\.path 'context\.prior_outcome' cannot target '\.'",
|
|
),
|
|
(
|
|
[InputValueBinding(target=LocalPath.root(), value="not-an-object")],
|
|
r"bindings\[0\]\.value for root target must be an object",
|
|
),
|
|
],
|
|
)
|
|
async def test_set_workflow_output_bindings_rejects_without_mutation(
|
|
tmp_path: Path,
|
|
bindings: list[InputBinding],
|
|
message: str,
|
|
) -> None:
|
|
# Pytest gives every parameter case an isolated tmp_path, so a stable id is
|
|
# sufficient and keeps failure artifacts reproducible across processes.
|
|
workspace_id = "invalid_output"
|
|
draft_api, _service, authoring = _draft_api(
|
|
FileWorkflowArtifactStore(tmp_path / workspace_id),
|
|
register_echo=True,
|
|
)
|
|
await draft_api.create_draft_workspace(
|
|
workspace_id=workspace_id,
|
|
draft=_workflow_output_binding_draft(),
|
|
)
|
|
before = await draft_api.get_draft_workspace(
|
|
workspace_id=workspace_id,
|
|
include_draft=True,
|
|
)
|
|
|
|
with pytest.raises(ValueError, match=message):
|
|
await authoring.set_workflow_output_bindings(
|
|
workspace_id=workspace_id,
|
|
revision=1,
|
|
bindings=bindings,
|
|
)
|
|
|
|
after = await draft_api.get_draft_workspace(
|
|
workspace_id=workspace_id,
|
|
include_draft=True,
|
|
)
|
|
assert after == before
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_set_workflow_output_bindings_stale_revision_wins_without_mutation(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
draft_api, _service, authoring = _draft_api(
|
|
FileWorkflowArtifactStore(tmp_path / "stale_workflow_output"),
|
|
register_echo=True,
|
|
)
|
|
await draft_api.create_draft_workspace(
|
|
workspace_id="report",
|
|
draft=_workflow_output_binding_draft(),
|
|
)
|
|
before = await draft_api.get_draft_workspace(
|
|
workspace_id="report",
|
|
include_draft=True,
|
|
)
|
|
|
|
result = await authoring.set_workflow_output_bindings(
|
|
workspace_id="report",
|
|
revision=2,
|
|
bindings=[
|
|
InputValueBinding(target=LocalPath.of("missing"), value="x"),
|
|
InputValueBinding(target=LocalPath.of("missing", "child"), value="y"),
|
|
],
|
|
)
|
|
|
|
assert result["status"] == "conflict"
|
|
assert result["diagnostics"][0]["code"] == "revision_conflict"
|
|
after = await draft_api.get_draft_workspace(
|
|
workspace_id="report",
|
|
include_draft=True,
|
|
)
|
|
assert after == before
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_set_workflow_output_bindings_compile_and_execute(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
draft_api, service, authoring = _draft_api(
|
|
FileWorkflowArtifactStore(tmp_path / "execute_workflow_output"),
|
|
register_echo=True,
|
|
)
|
|
draft = _echo_draft()
|
|
draft["state_schema"] = {
|
|
"type": "object",
|
|
"properties": {
|
|
"report": {
|
|
"type": "object",
|
|
"properties": {"title": {"type": "string"}},
|
|
}
|
|
},
|
|
}
|
|
draft["steps"]["echo"]["output"] = [
|
|
{"source": "echoed", "target": "state.report.title"}
|
|
]
|
|
draft["output_schema"] = {
|
|
"type": "object",
|
|
"properties": {"format": {"type": "string"}},
|
|
}
|
|
draft["output"] = []
|
|
await draft_api.create_draft_workspace(workspace_id="report", draft=draft)
|
|
|
|
await authoring.set_workflow_output_bindings(
|
|
workspace_id="report",
|
|
revision=1,
|
|
bindings=[
|
|
InputPathBinding(
|
|
path=GraphSourcePath.state("report", "title"),
|
|
target=LocalPath.of("report", "title"),
|
|
),
|
|
InputPathBinding(
|
|
path=GraphSourcePath.state("report", "title"),
|
|
target=LocalPath.of("audit", "title"),
|
|
),
|
|
InputValueBinding(target=LocalPath.of("format"), value="markdown"),
|
|
],
|
|
)
|
|
compiled = await draft_api.compile_draft_workspace(workspace_id="report")
|
|
run = await service.run_workflow_from_plan(
|
|
RawWorkflowPlan.model_validate(_compiled_workspace(compiled)),
|
|
{"text": "Thesis"},
|
|
)
|
|
|
|
assert run.error is None
|
|
assert run.output == {
|
|
"report": {"title": "Thesis"},
|
|
"audit": {"title": "Thesis"},
|
|
"format": "markdown",
|
|
}
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_cleared_workflow_output_bindings_preserve_state_fallback(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
draft_api, service, authoring = _draft_api(
|
|
FileWorkflowArtifactStore(tmp_path / "execute_workflow_output_fallback"),
|
|
register_echo=True,
|
|
)
|
|
draft = _echo_draft()
|
|
draft["output"] = []
|
|
await draft_api.create_draft_workspace(workspace_id="report", draft=draft)
|
|
|
|
first = await authoring.set_workflow_output_bindings(
|
|
workspace_id="report",
|
|
revision=1,
|
|
bindings=[InputValueBinding(target=LocalPath.of("echoed"), value="explicit")],
|
|
)
|
|
await authoring.set_workflow_output_bindings(
|
|
workspace_id="report",
|
|
revision=first["revision"],
|
|
bindings=[],
|
|
)
|
|
compiled = await draft_api.compile_draft_workspace(workspace_id="report")
|
|
run = await service.run_workflow_from_plan(
|
|
RawWorkflowPlan.model_validate(_compiled_workspace(compiled)),
|
|
{"text": "Thesis"},
|
|
)
|
|
|
|
assert run.error is None
|
|
assert run.output == {"echoed": "Thesis"}
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_set_workflow_output_map_replaces_top_level_output(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
artifact_store = FileWorkflowArtifactStore(tmp_path / "drafts_out_replace")
|
|
api, _service, _authoring = _draft_api(artifact_store, register_echo=True)
|
|
await api.create_draft_workspace(workspace_id="report", draft=_echo_draft())
|
|
|
|
result = await api.set_workflow_output_map(
|
|
workspace_id="report",
|
|
revision=1,
|
|
output_map={"state.echoed": "echoed"},
|
|
)
|
|
|
|
assert result["revision"] == 2
|
|
assert result["status"] == "valid", result["diagnostics"]
|
|
fetched = await api.get_draft_workspace(workspace_id="report", include_draft=True)
|
|
assert fetched["draft"]["output"] == [{"path": "state.echoed", "target": "echoed"}]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_set_workflow_output_map_merges_top_level_output(tmp_path: Path) -> None:
|
|
artifact_store = FileWorkflowArtifactStore(tmp_path / "drafts_out_merge")
|
|
api, _service, _authoring = _draft_api(artifact_store, register_echo=True)
|
|
draft = {
|
|
**_echo_draft(),
|
|
"state_schema": {
|
|
"fields": {
|
|
"echoed": {"type": "string"},
|
|
"other": {"type": "string"},
|
|
}
|
|
},
|
|
"output_schema": {
|
|
"type": "object",
|
|
"properties": {
|
|
"echoed": {"type": "string"},
|
|
"kind": {"type": "string"},
|
|
"other": {"type": "string"},
|
|
},
|
|
},
|
|
"output": [
|
|
{"path": "state.echoed", "target": "echoed"},
|
|
{"value": "report", "target": "kind"},
|
|
],
|
|
}
|
|
await api.create_draft_workspace(workspace_id="report", draft=draft)
|
|
|
|
result = await api.set_workflow_output_map(
|
|
workspace_id="report",
|
|
revision=1,
|
|
output_map={"state.other": "other"},
|
|
merge=True,
|
|
)
|
|
|
|
assert result["status"] == "valid", result["diagnostics"]
|
|
fetched = await api.get_draft_workspace(workspace_id="report", include_draft=True)
|
|
assert fetched["draft"]["output"] == [
|
|
{"path": "state.echoed", "target": "echoed"},
|
|
{"value": "report", "target": "kind"},
|
|
{"path": "state.other", "target": "other"},
|
|
]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_workflow_output_map_merge_rejects_requested_fan_out_source(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
artifact_store = FileWorkflowArtifactStore(tmp_path / "workflow_merge_fan_out")
|
|
api, _service, _authoring = _draft_api(artifact_store, register_echo=True)
|
|
draft = {
|
|
**_echo_draft(),
|
|
"output_schema": {
|
|
"type": "object",
|
|
"properties": {
|
|
"first": {"type": "string"},
|
|
"second": {"type": "string"},
|
|
"kind": {"type": "string"},
|
|
},
|
|
},
|
|
"output": [
|
|
{"path": "state.echoed", "target": "first"},
|
|
{"path": "state.echoed", "target": "second"},
|
|
{"value": "markdown", "target": "kind"},
|
|
],
|
|
}
|
|
await api.create_draft_workspace(workspace_id="report", draft=draft)
|
|
before = await api.get_draft_workspace(
|
|
workspace_id="report",
|
|
include_draft=True,
|
|
)
|
|
|
|
with pytest.raises(ValueError, match="multiple bindings"):
|
|
await api.set_workflow_output_map(
|
|
workspace_id="report",
|
|
revision=1,
|
|
output_map={"state.echoed": "renamed"},
|
|
merge=True,
|
|
)
|
|
|
|
after = await api.get_draft_workspace(
|
|
workspace_id="report",
|
|
include_draft=True,
|
|
)
|
|
assert after == before
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_workflow_output_map_merge_checks_revision_before_ambiguity(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
api, _service, _authoring = _draft_api(
|
|
FileWorkflowArtifactStore(tmp_path / "workflow_merge_stale_fan_out"),
|
|
register_echo=True,
|
|
)
|
|
draft = {
|
|
**_echo_draft(),
|
|
"output": [
|
|
{"path": "state.echoed", "target": "first"},
|
|
{"path": "state.echoed", "target": "second"},
|
|
],
|
|
}
|
|
await api.create_draft_workspace(workspace_id="report", draft=draft)
|
|
await api.set_draft_name(
|
|
workspace_id="report",
|
|
revision=1,
|
|
name="report_v2",
|
|
)
|
|
before = await api.get_draft_workspace(
|
|
workspace_id="report",
|
|
include_draft=True,
|
|
)
|
|
|
|
result = await api.set_workflow_output_map(
|
|
workspace_id="report",
|
|
revision=1,
|
|
output_map={"state.echoed": "renamed"},
|
|
merge=True,
|
|
)
|
|
|
|
after = await api.get_draft_workspace(
|
|
workspace_id="report",
|
|
include_draft=True,
|
|
)
|
|
assert result["status"] == "conflict"
|
|
assert result["diagnostics"][0]["code"] == "revision_conflict"
|
|
assert _required_draft(result) == before["draft"]
|
|
assert after == before
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_workflow_output_map_merge_preserves_unrequested_fan_out_and_literals(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
artifact_store = FileWorkflowArtifactStore(
|
|
tmp_path / "workflow_merge_unrequested_fan_out"
|
|
)
|
|
api, _service, _authoring = _draft_api(artifact_store, register_echo=True)
|
|
draft = {
|
|
**_echo_draft(),
|
|
"state_schema": {
|
|
"fields": {
|
|
"echoed": {"type": "string"},
|
|
"other": {"type": "string"},
|
|
}
|
|
},
|
|
"output_schema": {
|
|
"type": "object",
|
|
"properties": {
|
|
"first": {"type": "string"},
|
|
"second": {"type": "string"},
|
|
"kind": {"type": "string"},
|
|
"other": {"type": "string"},
|
|
},
|
|
},
|
|
"output": [
|
|
{"path": "state.echoed", "target": "first"},
|
|
{"path": "state.echoed", "target": "second"},
|
|
{"value": "markdown", "target": "kind"},
|
|
],
|
|
}
|
|
await api.create_draft_workspace(workspace_id="report", draft=draft)
|
|
|
|
result = await api.set_workflow_output_map(
|
|
workspace_id="report",
|
|
revision=1,
|
|
output_map={"state.other": "other"},
|
|
merge=True,
|
|
)
|
|
|
|
fetched = await api.get_draft_workspace(
|
|
workspace_id="report",
|
|
include_draft=True,
|
|
)
|
|
assert result["revision"] == 2
|
|
assert fetched["draft"]["output"] == [
|
|
{"path": "state.echoed", "target": "first"},
|
|
{"path": "state.echoed", "target": "second"},
|
|
{"value": "markdown", "target": "kind"},
|
|
{"path": "state.other", "target": "other"},
|
|
]
|
|
|
|
|
|
def _composite_concat_draft() -> dict[str, Any]:
|
|
return {
|
|
"name": "composite_concat",
|
|
"input_schema": {"type": "object", "properties": {}},
|
|
"state_schema": {"type": "object", "properties": {}},
|
|
"output_schema": {"type": "object", "properties": {}},
|
|
"start": "concat",
|
|
"steps": {
|
|
"concat": {
|
|
"use": "wf.std.concat",
|
|
"input": [],
|
|
"output": [],
|
|
}
|
|
},
|
|
"routes": {"concat": {"ok": "__end__"}},
|
|
}
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_set_step_input_bindings_projects_composite_source_schema_and_order(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
draft_api, _service, authoring = _draft_api(
|
|
FileWorkflowArtifactStore(tmp_path / "composite_concat"),
|
|
)
|
|
await draft_api.create_draft_workspace(
|
|
workspace_id="concat",
|
|
draft=_composite_concat_draft(),
|
|
)
|
|
|
|
result = await authoring.set_step_input_bindings(
|
|
workspace_id="concat",
|
|
revision=1,
|
|
step_id="concat",
|
|
bindings=[
|
|
InputExpressionBinding.model_validate(
|
|
{
|
|
"target": "items",
|
|
"expression": {
|
|
"kind": "array",
|
|
"items": [
|
|
{"kind": "path", "path": "state.foo"},
|
|
{"kind": "literal", "value": "wowcool"},
|
|
],
|
|
},
|
|
}
|
|
),
|
|
InputValueBinding(target=LocalPath.of("separator"), value=" "),
|
|
],
|
|
)
|
|
|
|
assert result["revision"] == 2
|
|
assert _required_draft(result)["steps"]["concat"]["input"] == [
|
|
{
|
|
"target": "items",
|
|
"expression": {
|
|
"kind": "array",
|
|
"items": [
|
|
{"kind": "path", "path": "state.foo"},
|
|
{"kind": "literal", "value": "wowcool"},
|
|
],
|
|
},
|
|
},
|
|
{"target": "separator", "value": " "},
|
|
]
|
|
assert _required_draft(result)["state_schema"]["properties"]["foo"] == {
|
|
"type": "string"
|
|
}
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_set_step_input_bindings_rejects_incompatible_expression_atomically(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
draft_api, _service, authoring = _draft_api(
|
|
FileWorkflowArtifactStore(tmp_path / "composite_concat_incompatible"),
|
|
)
|
|
await draft_api.create_draft_workspace(
|
|
workspace_id="concat",
|
|
draft={
|
|
**_composite_concat_draft(),
|
|
"state_schema": {
|
|
"type": "object",
|
|
"properties": {"foo": {"type": "integer"}},
|
|
},
|
|
},
|
|
)
|
|
before = await draft_api.get_draft_workspace(
|
|
workspace_id="concat", include_draft=True
|
|
)
|
|
|
|
with pytest.raises(ValueError, match="state.foo"):
|
|
await authoring.set_step_input_bindings(
|
|
workspace_id="concat",
|
|
revision=1,
|
|
step_id="concat",
|
|
bindings=[
|
|
InputExpressionBinding.model_validate(
|
|
{
|
|
"target": "items",
|
|
"expression": {
|
|
"kind": "array",
|
|
"items": [{"kind": "path", "path": "state.foo"}],
|
|
},
|
|
}
|
|
)
|
|
],
|
|
)
|
|
|
|
after = await draft_api.get_draft_workspace(
|
|
workspace_id="concat", include_draft=True
|
|
)
|
|
assert after == before
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_set_step_input_bindings_rejects_overlapping_expression_targets_atomically(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
draft_api, _service, authoring = _draft_api(
|
|
FileWorkflowArtifactStore(tmp_path / "composite_concat_overlap"),
|
|
)
|
|
await draft_api.create_draft_workspace(
|
|
workspace_id="concat",
|
|
draft=_composite_concat_draft(),
|
|
)
|
|
before = await draft_api.get_draft_workspace(
|
|
workspace_id="concat", include_draft=True
|
|
)
|
|
|
|
with pytest.raises(ValueError, match="overlaps"):
|
|
await authoring.set_step_input_bindings(
|
|
workspace_id="concat",
|
|
revision=1,
|
|
step_id="concat",
|
|
bindings=[
|
|
InputExpressionBinding.model_validate(
|
|
{
|
|
"target": "items",
|
|
"expression": {
|
|
"kind": "array",
|
|
"items": [{"kind": "literal", "value": "hello"}],
|
|
},
|
|
}
|
|
),
|
|
InputValueBinding(
|
|
target=LocalPath.of("items", "0"),
|
|
value="shadowed",
|
|
),
|
|
],
|
|
)
|
|
|
|
after = await draft_api.get_draft_workspace(
|
|
workspace_id="concat", include_draft=True
|
|
)
|
|
assert after == before
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_set_step_input_bindings_uses_additional_property_source_schema(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
draft_api, service, authoring = _draft_api(
|
|
FileWorkflowArtifactStore(tmp_path / "composite_concat_additional_source"),
|
|
register_echo=True,
|
|
)
|
|
service.register_specs(
|
|
"demo.personal",
|
|
replace(
|
|
echo_tool,
|
|
input_schema_contract={
|
|
"type": "object",
|
|
"properties": {"items": {"type": "array", "items": {"type": "string"}}},
|
|
},
|
|
),
|
|
)
|
|
draft = _composite_concat_draft()
|
|
draft["steps"]["concat"]["use"] = "demo.personal.echo_tool"
|
|
draft["state_schema"] = {
|
|
"type": "object",
|
|
"additionalProperties": {"type": "integer"},
|
|
}
|
|
await draft_api.create_draft_workspace(workspace_id="concat", draft=draft)
|
|
|
|
with pytest.raises(ValueError, match="incompatible"):
|
|
await authoring.set_step_input_bindings(
|
|
workspace_id="concat",
|
|
revision=1,
|
|
step_id="concat",
|
|
bindings=[
|
|
InputExpressionBinding.model_validate(
|
|
{
|
|
"target": "items",
|
|
"expression": {
|
|
"kind": "array",
|
|
"items": [{"kind": "path", "path": "state.foo"}],
|
|
},
|
|
}
|
|
)
|
|
],
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize(
|
|
"additional_schema",
|
|
[
|
|
{"$ref": "https://example.com/schema.json"},
|
|
{"$ref": "#/$defs/Missing"},
|
|
{"$ref": "#/$defs/Node"},
|
|
],
|
|
)
|
|
async def test_set_step_input_bindings_rejects_unresolvable_additional_source_refs(
|
|
tmp_path: Path,
|
|
additional_schema: dict[str, object],
|
|
) -> None:
|
|
draft_api, service, authoring = _draft_api(
|
|
FileWorkflowArtifactStore(tmp_path / "composite_concat_bad_additional_source"),
|
|
register_echo=True,
|
|
)
|
|
service.register_specs(
|
|
"demo.personal",
|
|
replace(
|
|
echo_tool,
|
|
input_schema_contract={
|
|
"type": "object",
|
|
"properties": {"items": {"type": "array", "items": {"type": "string"}}},
|
|
},
|
|
),
|
|
)
|
|
draft = _composite_concat_draft()
|
|
draft["steps"]["concat"]["use"] = "demo.personal.echo_tool"
|
|
state_schema: dict[str, object] = {
|
|
"type": "object",
|
|
"additionalProperties": additional_schema,
|
|
}
|
|
if additional_schema == {"$ref": "#/$defs/Node"}:
|
|
state_schema["$defs"] = {
|
|
"Node": {
|
|
"type": "object",
|
|
"properties": {"next": {"$ref": "#/$defs/Node"}},
|
|
}
|
|
}
|
|
draft["state_schema"] = state_schema
|
|
await draft_api.create_draft_workspace(workspace_id="concat", draft=draft)
|
|
before = await draft_api.get_draft_workspace(
|
|
workspace_id="concat", include_draft=True
|
|
)
|
|
|
|
with pytest.raises(ValueError, match="reference|unsupported|cyclic|unresolved"):
|
|
await authoring.set_step_input_bindings(
|
|
workspace_id="concat",
|
|
revision=1,
|
|
step_id="concat",
|
|
bindings=[
|
|
InputExpressionBinding.model_validate(
|
|
{
|
|
"target": "items",
|
|
"expression": {
|
|
"kind": "array",
|
|
"items": [{"kind": "path", "path": "state.foo"}],
|
|
},
|
|
}
|
|
)
|
|
],
|
|
)
|
|
|
|
after = await draft_api.get_draft_workspace(
|
|
workspace_id="concat", include_draft=True
|
|
)
|
|
assert after == before
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize(
|
|
"additional_schema",
|
|
[
|
|
{"$ref": "https://example.com/schema.json"},
|
|
{"$ref": "#/$defs/Missing"},
|
|
{"$ref": "#/$defs/Node"},
|
|
],
|
|
)
|
|
async def test_set_step_input_bindings_rejects_unresolvable_additional_path_refs(
|
|
tmp_path: Path,
|
|
additional_schema: dict[str, object],
|
|
) -> None:
|
|
draft_api, service, authoring = _draft_api(
|
|
FileWorkflowArtifactStore(tmp_path / "composite_concat_bad_additional_path"),
|
|
register_echo=True,
|
|
)
|
|
service.register_specs(
|
|
"demo.personal",
|
|
replace(
|
|
echo_tool,
|
|
input_schema_contract={
|
|
"type": "object",
|
|
"properties": {"items": {"type": "array", "items": {"type": "string"}}},
|
|
},
|
|
),
|
|
)
|
|
draft = _composite_concat_draft()
|
|
draft["steps"]["concat"]["use"] = "demo.personal.echo_tool"
|
|
state_schema: dict[str, object] = {
|
|
"type": "object",
|
|
"additionalProperties": additional_schema,
|
|
}
|
|
if additional_schema == {"$ref": "#/$defs/Node"}:
|
|
state_schema["$defs"] = {
|
|
"Node": {
|
|
"type": "object",
|
|
"properties": {"next": {"$ref": "#/$defs/Node"}},
|
|
}
|
|
}
|
|
draft["state_schema"] = state_schema
|
|
await draft_api.create_draft_workspace(workspace_id="concat", draft=draft)
|
|
|
|
with pytest.raises(ValueError, match="reference|unsupported|cyclic|unresolved"):
|
|
await authoring.set_step_input_bindings(
|
|
workspace_id="concat",
|
|
revision=1,
|
|
step_id="concat",
|
|
bindings=[
|
|
InputPathBinding(
|
|
path=GraphSourcePath.state("foo"),
|
|
target=LocalPath.of("items"),
|
|
)
|
|
],
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_set_step_input_bindings_accepts_simple_source_with_min_length_schema(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
draft_api, service, authoring = _draft_api(
|
|
FileWorkflowArtifactStore(tmp_path / "composite_concat_min_length_source"),
|
|
register_echo=True,
|
|
)
|
|
service.register_specs(
|
|
"demo.personal",
|
|
replace(
|
|
echo_tool,
|
|
input_schema_contract={
|
|
"type": "object",
|
|
"properties": {"separator": {"type": "string"}},
|
|
},
|
|
),
|
|
)
|
|
draft = _composite_concat_draft()
|
|
draft["steps"]["concat"]["use"] = "demo.personal.echo_tool"
|
|
draft["state_schema"] = {
|
|
"type": "object",
|
|
"properties": {"foo": {"type": "string", "minLength": 1}},
|
|
}
|
|
await draft_api.create_draft_workspace(workspace_id="concat", draft=draft)
|
|
|
|
result = await authoring.set_step_input_bindings(
|
|
workspace_id="concat",
|
|
revision=1,
|
|
step_id="concat",
|
|
bindings=[
|
|
InputPathBinding(
|
|
path=GraphSourcePath.state("foo"),
|
|
target=LocalPath.of("separator"),
|
|
)
|
|
],
|
|
)
|
|
|
|
assert result["revision"] == 2
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_set_step_input_bindings_accepts_additional_property_target(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
draft_api, service, authoring = _draft_api(
|
|
FileWorkflowArtifactStore(tmp_path / "composite_concat_additional_target"),
|
|
register_echo=True,
|
|
)
|
|
service.register_specs(
|
|
"demo.personal",
|
|
replace(
|
|
echo_tool,
|
|
input_schema_contract={
|
|
"type": "object",
|
|
"additionalProperties": {"type": "string"},
|
|
},
|
|
),
|
|
)
|
|
draft = _composite_concat_draft()
|
|
draft["steps"]["concat"]["use"] = "demo.personal.echo_tool"
|
|
await draft_api.create_draft_workspace(workspace_id="concat", draft=draft)
|
|
|
|
result = await authoring.set_step_input_bindings(
|
|
workspace_id="concat",
|
|
revision=1,
|
|
step_id="concat",
|
|
bindings=[
|
|
InputExpressionBinding.model_validate(
|
|
{
|
|
"target": "dynamic",
|
|
"expression": {"kind": "literal", "value": "hello"},
|
|
}
|
|
)
|
|
],
|
|
)
|
|
|
|
assert result["revision"] == 2
|
|
assert _required_draft(result)["steps"]["concat"]["input"][0]["target"] == "dynamic"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_set_step_input_map_merge_rejects_existing_expression_without_mutation(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
draft_api, _service, _authoring = _draft_api(
|
|
FileWorkflowArtifactStore(tmp_path / "composite_concat_merge"),
|
|
)
|
|
draft = _composite_concat_draft()
|
|
await draft_api.create_draft_workspace(workspace_id="concat", draft=draft)
|
|
draft["steps"]["concat"]["input"] = [
|
|
{
|
|
"target": "items",
|
|
"expression": {
|
|
"kind": "array",
|
|
"items": [{"kind": "literal", "value": "hello"}],
|
|
},
|
|
}
|
|
]
|
|
await draft_api.replace_validated_draft_document(
|
|
workspace_id="concat",
|
|
revision=1,
|
|
draft=draft,
|
|
)
|
|
before = await draft_api.get_draft_workspace(
|
|
workspace_id="concat", include_draft=True
|
|
)
|
|
|
|
with pytest.raises(ValueError, match="cannot be safely merged"):
|
|
await draft_api.set_step_input_map(
|
|
workspace_id="concat",
|
|
revision=2,
|
|
step_id="concat",
|
|
input_map={"separator": "separator"},
|
|
merge=True,
|
|
)
|
|
|
|
after = await draft_api.get_draft_workspace(
|
|
workspace_id="concat", include_draft=True
|
|
)
|
|
assert after == before
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_set_workflow_output_map_projects_missing_output_schema(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
artifact_store = FileWorkflowArtifactStore(tmp_path / "drafts_out_project")
|
|
api, _service, _authoring = _draft_api(artifact_store, register_echo=True)
|
|
draft = {
|
|
**_echo_draft(),
|
|
"state_schema": {
|
|
"type": "object",
|
|
"properties": {
|
|
"echoed": {"type": "string"},
|
|
"after": {"type": "object"},
|
|
},
|
|
},
|
|
"output_schema": {"type": "object", "properties": {}},
|
|
"output": [],
|
|
}
|
|
await api.create_draft_workspace(workspace_id="report", draft=draft)
|
|
|
|
result = await api.set_workflow_output_map(
|
|
workspace_id="report",
|
|
revision=1,
|
|
output_map={"state.after": "after"},
|
|
merge=True,
|
|
)
|
|
|
|
assert result["status"] == "valid", result["diagnostics"]
|
|
fetched = await api.get_draft_workspace(workspace_id="report", include_draft=True)
|
|
assert fetched["draft"]["output"] == [{"path": "state.after", "target": "after"}]
|
|
assert fetched["draft"]["output_schema"]["properties"]["after"] == {
|
|
"type": "object"
|
|
}
|
|
|
|
|
|
# -- Browser-click test helpers for forward-route tests --
|
|
|
|
|
|
class _OpenClickPageInput(BaseModel):
|
|
open_browser: bool = False
|
|
|
|
|
|
class _OpenClickPageOutput(BaseModel):
|
|
before: dict = {}
|
|
session_id: str = ""
|
|
|
|
|
|
class _WaitForClickInput(BaseModel):
|
|
session_id: str
|
|
simulate: dict
|
|
timeout_seconds: int
|
|
|
|
|
|
class _WaitForClickOutput(BaseModel):
|
|
after: dict = {}
|
|
|
|
|
|
class _CollectSnapshotsInput(BaseModel):
|
|
session_id: str
|
|
before: dict
|
|
after: dict
|
|
|
|
|
|
class _CollectSnapshotsOutput(BaseModel):
|
|
before: dict = {}
|
|
after: dict = {}
|
|
|
|
|
|
@node(name="open_click_page", outcomes=("ok",))
|
|
def _open_click_page(payload: _OpenClickPageInput) -> _OpenClickPageOutput:
|
|
return _OpenClickPageOutput(before={}, session_id="")
|
|
|
|
|
|
@node(name="wait_for_click", outcomes=("ok",))
|
|
def _wait_for_click(payload: _WaitForClickInput) -> _WaitForClickOutput:
|
|
return _WaitForClickOutput(after={})
|
|
|
|
|
|
@node(name="collect_snapshots", outcomes=("ok",))
|
|
def _collect_snapshots(payload: _CollectSnapshotsInput) -> _CollectSnapshotsOutput:
|
|
return _CollectSnapshotsOutput(before={}, after={})
|
|
|
|
|
|
def _browser_click_api(
|
|
artifact_store: FileWorkflowArtifactStore,
|
|
) -> tuple[WorkflowApi, WfMcpService]:
|
|
mcp_root = artifact_store.root / "browser_mcp" / str(id(artifact_store))
|
|
service = WfMcpService(
|
|
store=FileStore(mcp_root),
|
|
artifact_store=artifact_store,
|
|
draft_workspace_store=FileDraftWorkspaceStore(mcp_root),
|
|
)
|
|
service.register_connection(
|
|
ConnectionConfig(
|
|
id="local.browser_click", server="local", account="browser_click"
|
|
)
|
|
)
|
|
service.register_specs(
|
|
"local.browser_click",
|
|
_open_click_page,
|
|
_wait_for_click,
|
|
_collect_snapshots,
|
|
)
|
|
context = context_from_service(service)
|
|
return WorkflowApi(context, drafts=True), service
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_create_draft_from_capability_does_not_bind_optional_inputs(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
api, _service = _browser_click_api(
|
|
FileWorkflowArtifactStore(tmp_path / "drafts_required_inputs")
|
|
)
|
|
|
|
created = await api.create_draft_workspace_from_capability(
|
|
workspace_id="browser",
|
|
capability_name="local.browser_click.open_click_page",
|
|
name="browser",
|
|
)
|
|
workspace = await api.get_draft_workspace(
|
|
workspace_id="browser",
|
|
include_draft=True,
|
|
)
|
|
|
|
assert created["wrapper_hints"]["input_map"] == {}
|
|
assert workspace["draft"]["steps"]["call"]["input"] == []
|
|
assert any("open_browser" in note for note in created["wrapper_hints"]["notes"])
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_add_step_projects_explicit_optional_workflow_inputs(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
api, _service = _browser_click_api(
|
|
FileWorkflowArtifactStore(tmp_path / "drafts_explicit_optional_input")
|
|
)
|
|
|
|
await api.create_draft_workspace_from_capability(
|
|
workspace_id="browser",
|
|
capability_name="local.browser_click.open_click_page",
|
|
name="browser",
|
|
)
|
|
|
|
result = await api.add_step_from_capability(
|
|
workspace_id="browser",
|
|
revision=1,
|
|
step_id="wait",
|
|
capability_name="local.browser_click.wait_for_click",
|
|
route_from_step="call",
|
|
routes={"ok": "__end__"},
|
|
input_map={
|
|
"state.session_id": "session_id",
|
|
"input.simulate": "simulate",
|
|
"input.timeout_seconds": "timeout_seconds",
|
|
},
|
|
bind_outputs={"after": "state.after"},
|
|
)
|
|
|
|
assert result["status"] == "valid", result["diagnostics"]
|
|
workspace = await api.get_draft_workspace(
|
|
workspace_id="browser", include_draft=True
|
|
)
|
|
properties = workspace["draft"]["input_schema"]["properties"]
|
|
assert properties["simulate"]["type"] == "object"
|
|
assert properties["timeout_seconds"]["type"] == "integer"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_add_step_persists_invalid_forward_route(tmp_path: Path) -> None:
|
|
api, _service = _browser_click_api(
|
|
FileWorkflowArtifactStore(tmp_path / "drafts_forward_route")
|
|
)
|
|
|
|
await api.create_draft_workspace_from_capability(
|
|
workspace_id="browser",
|
|
capability_name="local.browser_click.open_click_page",
|
|
name="browser",
|
|
)
|
|
|
|
# Use only state.session_id — simulate/timeout_seconds are not declared
|
|
# in the workflow input schema from open_click_page.
|
|
result = await api.add_step_from_capability(
|
|
workspace_id="browser",
|
|
revision=1,
|
|
step_id="wait",
|
|
capability_name="local.browser_click.wait_for_click",
|
|
route_from_step="call",
|
|
routes={"ok": "collect"},
|
|
input_map={
|
|
"state.session_id": "session_id",
|
|
},
|
|
bind_outputs={"after": "state.after"},
|
|
)
|
|
|
|
assert result["revision"] == 2
|
|
assert result["status"] == "invalid"
|
|
assert any(
|
|
item["code"] == "unknown_edge_destination" for item in result["diagnostics"]
|
|
)
|
|
|
|
stored = await api.get_draft_workspace(
|
|
workspace_id="browser",
|
|
include_draft=True,
|
|
)
|
|
assert (
|
|
stored["draft"]["steps"]["wait"]["use"] == "local.browser_click.wait_for_click"
|
|
)
|
|
assert stored["draft"]["routes"]["wait"]["ok"] == "collect"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_invalid_forward_route_cannot_compile_or_save(tmp_path: Path) -> None:
|
|
api, _service = _browser_click_api(
|
|
FileWorkflowArtifactStore(tmp_path / "drafts_compile_boundary")
|
|
)
|
|
|
|
await api.create_draft_workspace_from_capability(
|
|
workspace_id="browser",
|
|
capability_name="local.browser_click.open_click_page",
|
|
name="browser",
|
|
)
|
|
|
|
await api.add_step_from_capability(
|
|
workspace_id="browser",
|
|
revision=1,
|
|
step_id="wait",
|
|
capability_name="local.browser_click.wait_for_click",
|
|
route_from_step="call",
|
|
routes={"ok": "collect"},
|
|
input_map={"state.session_id": "session_id"},
|
|
bind_outputs={"after": "state.after"},
|
|
)
|
|
|
|
compiled = await api.compile_draft_workspace(workspace_id="browser")
|
|
|
|
assert "status" in compiled
|
|
assert compiled["status"] == "invalid"
|
|
assert any(
|
|
item["code"] == "unknown_edge_destination" for item in compiled["diagnostics"]
|
|
)
|
|
|
|
saved = await api.create_artifact_from_workspace(
|
|
workspace_id="browser",
|
|
artifact_id="browser_workflow",
|
|
version=1,
|
|
title="Browser Workflow",
|
|
outcomes=["ok"],
|
|
)
|
|
|
|
assert "status" in saved
|
|
assert saved["status"] == "invalid"
|
|
assert saved["saved"] is False
|
|
assert any(
|
|
item["code"] == "unknown_edge_destination" for item in saved["diagnostics"]
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_forward_route_becomes_valid_after_target_step_is_added(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
api, _service = _browser_click_api(
|
|
FileWorkflowArtifactStore(tmp_path / "drafts_repair_path")
|
|
)
|
|
|
|
await api.create_draft_workspace_from_capability(
|
|
workspace_id="browser",
|
|
capability_name="local.browser_click.open_click_page",
|
|
name="browser",
|
|
)
|
|
|
|
await api.add_step_from_capability(
|
|
workspace_id="browser",
|
|
revision=1,
|
|
step_id="wait",
|
|
capability_name="local.browser_click.wait_for_click",
|
|
route_from_step="call",
|
|
routes={"ok": "collect"},
|
|
input_map={
|
|
"state.session_id": "session_id",
|
|
},
|
|
bind_outputs={"after": "state.after"},
|
|
)
|
|
|
|
add_collect = await api.add_step_from_capability(
|
|
workspace_id="browser",
|
|
revision=2,
|
|
step_id="collect",
|
|
capability_name="local.browser_click.collect_snapshots",
|
|
route_from_step="wait",
|
|
input_map={
|
|
"state.session_id": "session_id",
|
|
"state.before": "before",
|
|
"state.after": "after",
|
|
},
|
|
bind_outputs={"before": "state.final_before", "after": "state.final_after"},
|
|
)
|
|
|
|
assert add_collect["revision"] == 3
|
|
validated = await api.validate_draft_workspace(workspace_id="browser")
|
|
assert validated["status"] == "valid"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_bind_draft_local_output_to_workflow_output_lowers_through_state(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
artifact_store = FileWorkflowArtifactStore(
|
|
tmp_path / "drafts_bind_output_workflow_output"
|
|
)
|
|
api, service, authoring = _draft_api(artifact_store, register_echo=True)
|
|
await api.create_draft_workspace(
|
|
workspace_id="report",
|
|
draft={
|
|
"name": "report",
|
|
"input_schema": {"type": "object", "properties": {}},
|
|
"state_schema": {"type": "object", "properties": {}},
|
|
"output_schema": {"type": "object", "properties": {}},
|
|
"start": "render",
|
|
"steps": {
|
|
"render": {
|
|
"use": "demo.personal.echo_tool",
|
|
"input": [],
|
|
"output": [],
|
|
}
|
|
},
|
|
"routes": {"render": {"ok": "__end__"}},
|
|
},
|
|
)
|
|
|
|
result = await authoring.bind_draft(
|
|
workspace_id="report",
|
|
revision=1,
|
|
step_id="render",
|
|
source_path="local.echoed",
|
|
target_path="output.echoed",
|
|
)
|
|
|
|
assert result["status"] == "valid"
|
|
workspace = await api.get_draft_workspace(workspace_id="report", include_draft=True)
|
|
assert workspace["draft"]["steps"]["render"]["output"] == [
|
|
{"source": "echoed", "target": "state.echoed"}
|
|
]
|
|
assert workspace["draft"]["output"] == [
|
|
{"path": "state.echoed", "target": "echoed"}
|
|
]
|
|
assert (
|
|
workspace["draft"]["state_schema"]["properties"]["echoed"]["type"] == "string"
|
|
)
|
|
assert (
|
|
workspace["draft"]["output_schema"]["properties"]["echoed"]["type"] == "string"
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_bind_draft_local_output_to_workflow_output_reuses_compatible_state(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
artifact_store = FileWorkflowArtifactStore(
|
|
tmp_path / "drafts_bind_existing_output_state"
|
|
)
|
|
api, _service, authoring = _draft_api(artifact_store, register_echo=True)
|
|
await api.create_draft_workspace(
|
|
workspace_id="report",
|
|
draft={
|
|
"name": "report",
|
|
"input_schema": {"type": "object", "properties": {}},
|
|
"state_schema": {
|
|
"type": "object",
|
|
"properties": {
|
|
"echoed": {
|
|
"description": "Echoed text",
|
|
"title": "Echoed",
|
|
"type": "string",
|
|
}
|
|
},
|
|
},
|
|
"output_schema": {"type": "object", "properties": {}},
|
|
"start": "render",
|
|
"steps": {
|
|
"render": {
|
|
"use": "demo.personal.echo_tool",
|
|
"input": [],
|
|
"output": [{"source": "echoed", "target": "state.echoed"}],
|
|
}
|
|
},
|
|
"routes": {"render": {"ok": "__end__"}},
|
|
},
|
|
)
|
|
|
|
result = await authoring.bind_draft(
|
|
workspace_id="report",
|
|
revision=1,
|
|
step_id="render",
|
|
source_path="local.echoed",
|
|
target_path="output.echoed",
|
|
)
|
|
|
|
assert result["status"] == "valid"
|
|
workspace = await api.get_draft_workspace(workspace_id="report", include_draft=True)
|
|
assert workspace["draft"]["steps"]["render"]["output"] == [
|
|
{"source": "echoed", "target": "state.echoed"}
|
|
]
|
|
assert workspace["draft"]["output"] == [
|
|
{"path": "state.echoed", "target": "echoed"}
|
|
]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_bind_draft_local_output_to_state_reuses_compatible_state(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
artifact_store = FileWorkflowArtifactStore(tmp_path / "drafts_bind_existing_state")
|
|
api, _service, authoring = _draft_api(artifact_store, register_echo=True)
|
|
draft = _echo_draft()
|
|
draft["state_schema"] = {
|
|
"type": "object",
|
|
"properties": {
|
|
"echoed": {
|
|
"description": "Echoed text",
|
|
"title": "Echoed",
|
|
"type": "string",
|
|
}
|
|
},
|
|
}
|
|
await api.create_draft_workspace(workspace_id="report", draft=draft)
|
|
|
|
result = await authoring.bind_draft(
|
|
workspace_id="report",
|
|
revision=1,
|
|
step_id="echo",
|
|
source_path="local.echoed",
|
|
target_path="state.echoed",
|
|
)
|
|
|
|
assert result["status"] == "valid", result["diagnostics"]
|
|
workspace = await api.get_draft_workspace(workspace_id="report", include_draft=True)
|
|
assert workspace["draft"]["steps"]["echo"]["output"] == [
|
|
{"source": "echoed", "target": "state.echoed"}
|
|
]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_bind_draft_local_output_to_quoted_workflow_output_field(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
artifact_store = FileWorkflowArtifactStore(tmp_path / "drafts_bind_quoted_output")
|
|
api, _service, authoring = _draft_api(artifact_store, register_echo=True)
|
|
draft = _echo_draft()
|
|
draft["state_schema"] = {"type": "object", "properties": {}}
|
|
draft["output_schema"] = {"type": "object", "properties": {}}
|
|
draft["steps"]["echo"]["output"] = []
|
|
await api.create_draft_workspace(workspace_id="quoted", draft=draft)
|
|
|
|
result = await authoring.bind_draft(
|
|
workspace_id="quoted",
|
|
revision=1,
|
|
step_id="echo",
|
|
source_path="local.echoed",
|
|
target_path='output."public.echoed"',
|
|
)
|
|
|
|
assert result["status"] == "valid"
|
|
workspace = await api.get_draft_workspace(workspace_id="quoted", include_draft=True)
|
|
assert workspace["draft"]["steps"]["echo"]["output"] == [
|
|
{"source": "echoed", "target": 'state."public.echoed"'}
|
|
]
|
|
assert workspace["draft"]["output"] == [
|
|
{"path": 'state."public.echoed"', "target": '"public.echoed"'}
|
|
]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_bind_draft_replaces_previous_public_output_for_local_field(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
artifact_store = FileWorkflowArtifactStore(tmp_path / "drafts_rebind_output")
|
|
api, _service, authoring = _draft_api(artifact_store, register_echo=True)
|
|
draft = _echo_draft()
|
|
draft["state_schema"] = {
|
|
"type": "object",
|
|
"properties": {
|
|
"old": {"type": "string"},
|
|
},
|
|
}
|
|
draft["output_schema"] = {
|
|
"type": "object",
|
|
"properties": {
|
|
"old": {"type": "string"},
|
|
},
|
|
}
|
|
draft["steps"]["echo"]["output"] = [{"source": "echoed", "target": "state.old"}]
|
|
draft["output"] = [{"path": "state.old", "target": "old"}]
|
|
await api.create_draft_workspace(workspace_id="report", draft=draft)
|
|
|
|
result = await authoring.bind_draft(
|
|
workspace_id="report",
|
|
revision=1,
|
|
step_id="echo",
|
|
source_path="local.echoed",
|
|
target_path="output.echoed",
|
|
)
|
|
|
|
assert result["status"] == "valid"
|
|
workspace = await api.get_draft_workspace(workspace_id="report", include_draft=True)
|
|
assert workspace["draft"]["output"] == [
|
|
{"path": "state.echoed", "target": "echoed"}
|
|
]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_bind_draft_rejects_ambiguous_previous_public_output_fan_out(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
api, _service, authoring = _draft_api(
|
|
FileWorkflowArtifactStore(tmp_path / "bind_ambiguous_public_output"),
|
|
register_echo=True,
|
|
)
|
|
draft = _echo_draft()
|
|
draft["state_schema"] = {
|
|
"type": "object",
|
|
"properties": {"old": {"type": "string"}},
|
|
}
|
|
draft["output_schema"] = {
|
|
"type": "object",
|
|
"properties": {
|
|
"first": {"type": "string"},
|
|
"second": {"type": "string"},
|
|
},
|
|
}
|
|
draft["steps"]["echo"]["output"] = [{"source": "echoed", "target": "state.old"}]
|
|
draft["output"] = [
|
|
{"path": "state.old", "target": "first"},
|
|
{"path": "state.old", "target": "second"},
|
|
]
|
|
await api.create_draft_workspace(workspace_id="report", draft=draft)
|
|
before = await api.get_draft_workspace(workspace_id="report", include_draft=True)
|
|
|
|
with pytest.raises(ValueError, match="multiple public output bindings"):
|
|
await authoring.bind_draft(
|
|
workspace_id="report",
|
|
revision=1,
|
|
step_id="echo",
|
|
source_path="local.echoed",
|
|
target_path="output.echoed",
|
|
)
|
|
|
|
after = await api.get_draft_workspace(workspace_id="report", include_draft=True)
|
|
assert after == before
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_remove_draft_binding_rejects_non_object_entries(tmp_path: Path) -> None:
|
|
artifact_store = FileWorkflowArtifactStore(tmp_path / "drafts_bad_bindings")
|
|
api, _service, authoring = _draft_api(artifact_store, register_echo=True)
|
|
draft = _echo_draft()
|
|
draft["steps"]["echo"]["input"] = ["not-an-object"]
|
|
await api.create_draft_workspace(workspace_id="bad", draft=draft)
|
|
|
|
with pytest.raises(ValueError, match="input binding entries.*objects"):
|
|
await authoring.remove_draft_binding(
|
|
workspace_id="bad",
|
|
revision=1,
|
|
step_id="echo",
|
|
inputs=["text"],
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_validate_draft_workspace_omits_unusable_nested_input_repair_hint(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
artifact_store = FileWorkflowArtifactStore(
|
|
tmp_path / "drafts_nested_input_schema_hint"
|
|
)
|
|
api, _service, _authoring = _draft_api(artifact_store, register_echo=True)
|
|
await api.create_draft_workspace(
|
|
workspace_id="nested_ws",
|
|
draft={
|
|
"name": "nested",
|
|
"input_schema": {"type": "object", "properties": {}},
|
|
"state_schema": {"type": "object", "properties": {}},
|
|
"output_schema": {"type": "object", "properties": {}},
|
|
"start": "call",
|
|
"steps": {
|
|
"call": {
|
|
"use": "demo.personal.echo_tool",
|
|
"input": [{"target": "payload.text", "path": "input.undeclared"}],
|
|
"output": [],
|
|
}
|
|
},
|
|
"routes": {"call": {"ok": "__end__"}},
|
|
},
|
|
)
|
|
|
|
payload = await api.validate_draft_workspace(workspace_id="nested_ws")
|
|
|
|
diagnostic = next(
|
|
item for item in payload["diagnostics"] if item["code"] == "invalid_source_path"
|
|
)
|
|
assert "repair_hint" not in diagnostic
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_validate_draft_workspace_details_invalid_input_source_path(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
"""invalid_source_path on a step input must carry step_id, source_path, target_field."""
|
|
artifact_store = FileWorkflowArtifactStore(
|
|
tmp_path / "drafts_invalid_source_details"
|
|
)
|
|
api, service, authoring = _draft_api(artifact_store, register_echo=True)
|
|
await api.create_draft_workspace(
|
|
workspace_id="wait_ws",
|
|
draft={
|
|
"name": "wait",
|
|
"input_schema": {"type": "object", "properties": {}},
|
|
"state_schema": {"type": "object", "properties": {}},
|
|
"output_schema": {"type": "object", "properties": {}},
|
|
"start": "wait",
|
|
"steps": {
|
|
"wait": {
|
|
"use": "demo.personal.echo_tool",
|
|
"input": [
|
|
{
|
|
"target": "text",
|
|
"path": "input.undeclared",
|
|
}
|
|
],
|
|
"output": [],
|
|
}
|
|
},
|
|
"routes": {"wait": {"ok": "__end__"}},
|
|
},
|
|
)
|
|
|
|
payload = await api.validate_draft_workspace(workspace_id="wait_ws")
|
|
|
|
diagnostic = payload["diagnostics"][0]
|
|
assert diagnostic["code"] == "invalid_source_path"
|
|
assert diagnostic.get("step_id") == "wait"
|
|
details = diagnostic.get("details")
|
|
assert isinstance(details, dict)
|
|
assert details["source_path"] == "input.undeclared"
|
|
assert details["target_field"] == "text"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_validate_draft_workspace_hints_input_schema_projection(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
"""invalid_source_path with input.* source must produce a repair_hint."""
|
|
artifact_store = FileWorkflowArtifactStore(tmp_path / "drafts_input_schema_hint")
|
|
api, service, authoring = _draft_api(artifact_store, register_echo=True)
|
|
await api.create_draft_workspace(
|
|
workspace_id="wait_ws",
|
|
draft={
|
|
"name": "wait",
|
|
"input_schema": {"type": "object", "properties": {}},
|
|
"state_schema": {"type": "object", "properties": {}},
|
|
"output_schema": {"type": "object", "properties": {}},
|
|
"start": "wait",
|
|
"steps": {
|
|
"wait": {
|
|
"use": "demo.personal.echo_tool",
|
|
"input": [
|
|
{
|
|
"target": "text",
|
|
"path": "input.undeclared",
|
|
}
|
|
],
|
|
"output": [],
|
|
}
|
|
},
|
|
"routes": {"wait": {"ok": "__end__"}},
|
|
},
|
|
)
|
|
|
|
payload = await api.validate_draft_workspace(workspace_id="wait_ws")
|
|
|
|
diagnostic = payload["diagnostics"][0]
|
|
assert diagnostic["code"] == "invalid_source_path"
|
|
assert diagnostic.get("step_id") == "wait"
|
|
assert diagnostic.get("repair_hint") == (
|
|
"wf draft bind wait_ws --revision 1 "
|
|
"--step wait --from input.undeclared --to local.text"
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_validate_draft_workspace_hints_state_schema_projection(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
artifact_store = FileWorkflowArtifactStore(tmp_path / "drafts_state_schema_hint")
|
|
api, _service, _authoring = _draft_api(artifact_store, register_echo=True)
|
|
await api.create_draft_workspace(
|
|
workspace_id="wait_ws",
|
|
draft={
|
|
"name": "wait",
|
|
"input_schema": {"type": "object", "properties": {}},
|
|
"state_schema": {"type": "object", "properties": {}},
|
|
"output_schema": {"type": "object", "properties": {}},
|
|
"start": "wait",
|
|
"steps": {
|
|
"wait": {
|
|
"use": "demo.personal.echo_tool",
|
|
"input": [{"target": "text", "path": "state.undeclared"}],
|
|
"output": [],
|
|
}
|
|
},
|
|
"routes": {"wait": {"ok": "__end__"}},
|
|
},
|
|
)
|
|
|
|
payload = await api.validate_draft_workspace(workspace_id="wait_ws")
|
|
|
|
diagnostic = next(
|
|
item for item in payload["diagnostics"] if item["code"] == "invalid_source_path"
|
|
)
|
|
assert diagnostic.get("repair_hint") == (
|
|
"wf draft bind wait_ws --revision 1 "
|
|
"--step wait --from state.undeclared --to local.text"
|
|
)
|