feat: expose authoring contract inventory

This commit is contained in:
lda
2026-08-14 15:44:04 +07:00 Verified
parent 0f1ed56876
commit 22510e0681
11 changed files with 713 additions and 2 deletions
+148
View File
@@ -114,6 +114,154 @@ async def test_update_capability_step_changes_metadata_and_inputs_atomically(
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)
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["input_targets"][0]["schema"]["type"] == "string"
assert selected["output_sources"][0]["schema"]["type"] == "string"
assert selected["outcomes"] == ["ok"]
@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)
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)
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)
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 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,
+202
View File
@@ -15,6 +15,7 @@ from wf_transport_rpc_http.app import create_rpc_app
from wf_transport_rpc_http.models import (
AddDraftStepParams,
AddStepFromCapabilityParams,
InspectDraftAuthoringContractParams,
SetDraftContractParams,
UpdateCapabilityStepParams,
)
@@ -165,6 +166,28 @@ def test_set_draft_contract_params_reject_whitespace_duplicate_outcomes() -> Non
)
def test_inspect_draft_authoring_contract_params_allow_nullable_selection() -> None:
params = InspectDraftAuthoringContractParams.model_validate(
{"workspace_id": "report", "revision": 4}
)
assert params.selected_step_id is None
@pytest.mark.parametrize(
"params",
[
{"workspace_id": "report", "revision": 4, "selected_step_id": ""},
{"workspace_id": "report", "revision": 0},
],
)
def test_inspect_draft_authoring_contract_params_reject_invalid_envelope(
params: dict[str, Any],
) -> None:
with pytest.raises(ValidationError):
InspectDraftAuthoringContractParams.model_validate(params)
async def test_rpc_health_and_capability_methods(tmp_path) -> None:
server = build_local_static_workflow_server(tmp_path / "store")
app = create_rpc_app(server)
@@ -733,6 +756,185 @@ async def test_rpc_draft_workspace_lifecycle_methods(tmp_path) -> None:
assert inspected["result"]["draft"]["outcomes"] == ["error"]
async def test_rpc_inspects_draft_authoring_contract_without_mutation(tmp_path) -> None:
server = build_local_static_workflow_server(tmp_path / "store")
app = create_rpc_app(server)
transport = httpx.ASGITransport(app=app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
created = await _rpc(
client,
"workflow.draft_workspaces.create_from_capability",
{
"workspace_id": "authoring",
"capability_name": "wf.std.constant",
"name": "authoring",
},
)
before = await _rpc(
client,
"workflow.draft_workspaces.get",
{"workspace_id": "authoring", "include_draft": True},
)
inspected = await _rpc(
client,
"workflow.draft_workspaces.inspect_authoring_contract",
{
"workspace_id": "authoring",
"revision": created["result"]["revision"],
"selected_step_id": "call",
},
)
after = await _rpc(
client,
"workflow.draft_workspaces.get",
{"workspace_id": "authoring", "include_draft": True},
)
result = inspected["result"]
assert result["workspace_id"] == "authoring"
assert result["revision"] == created["result"]["revision"]
assert result["selected_step_id"] == "call"
assert result["entry_steps"][0]["step_id"] == "call"
assert result["entry_steps"][0]["outcomes"] == ["ok"]
assert result["step_input_targets"]
assert result["step_input_targets"][0]["path"] == "step_input.value"
assert isinstance(result["step_input_targets"][0]["schema"], dict)
assert result["step_output_sources"]
assert result["step_output_sources"][0]["path"] == "step_output.value"
assert isinstance(result["step_output_sources"][0]["schema"], dict)
assert any(
option["path"] == "context.prior_outcome"
for option in result["readable_sources"]
)
assert after["result"] == before["result"]
async def test_rpc_inspect_authoring_contract_maps_domain_errors_and_conflicts(
tmp_path,
) -> None:
server = build_local_static_workflow_server(tmp_path / "store")
app = create_rpc_app(server)
transport = httpx.ASGITransport(app=app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
created = await _rpc(
client,
"workflow.draft_workspaces.create_empty",
{"workspace_id": "authoring", "name": "authoring"},
)
unknown = await _rpc(
client,
"workflow.draft_workspaces.inspect_authoring_contract",
{
"workspace_id": "authoring",
"revision": created["result"]["revision"],
"selected_step_id": "missing",
},
)
missing = await _rpc(
client,
"workflow.draft_workspaces.inspect_authoring_contract",
{"workspace_id": "missing", "revision": 1},
)
changed = await _rpc(
client,
"workflow.draft_workspaces.set_name",
{
"workspace_id": "authoring",
"revision": created["result"]["revision"],
"name": "changed",
},
)
stale = await _rpc(
client,
"workflow.draft_workspaces.inspect_authoring_contract",
{
"workspace_id": "authoring",
"revision": created["result"]["revision"],
},
)
assert unknown["error"]["code"] == 5000
assert unknown["error"]["data"]["code"] == "KeyError"
assert missing["error"]["code"] == 5000
assert changed["result"]["revision"] == 2
assert stale["result"]["status"] == "conflict"
assert stale["result"]["diagnostics"][0]["code"] == "revision_conflict"
async def test_rpc_inspect_authoring_contract_handles_invalid_persisted_draft(
tmp_path,
) -> None:
server = build_local_static_workflow_server(tmp_path / "store")
app = create_rpc_app(server)
transport = httpx.ASGITransport(app=app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
created = await _rpc(
client,
"workflow.draft_workspaces.create_empty",
{
"workspace_id": "invalid_authoring",
"name": "invalid_authoring",
"input_schema": {
"type": "object",
"properties": {"request": {"type": "string"}},
},
},
)
before = await _rpc(
client,
"workflow.draft_workspaces.get",
{"workspace_id": "invalid_authoring", "include_draft": True},
)
malformed = await _rpc(
client,
"workflow.draft_workspaces.inspect_authoring_contract",
{
"workspace_id": "invalid_authoring",
"revision": created["result"]["revision"],
"selected_step_id": "",
},
)
patched = await _rpc(
client,
"workflow.draft_workspaces.patch",
{
"workspace_id": "invalid_authoring",
"revision": created["result"]["revision"],
"patch": [
{
"op": "replace",
"path": "/steps",
"value": {"broken": {"unknown_kind": {}}},
},
{"op": "replace", "path": "/start", "value": "broken"},
],
},
)
inspected = await _rpc(
client,
"workflow.draft_workspaces.inspect_authoring_contract",
{
"workspace_id": "invalid_authoring",
"revision": patched["result"]["revision"],
"selected_step_id": "broken",
},
)
after = await _rpc(
client,
"workflow.draft_workspaces.get",
{"workspace_id": "invalid_authoring", "include_draft": True},
)
assert malformed["error"]["code"] == -32602
assert before["result"]["revision"] == 1
assert patched["result"]["status"] == "invalid"
assert inspected["result"]["selected_step_id"] == "broken"
assert inspected["result"]["entry_steps"] == []
assert inspected["result"]["readable_sources"][0]["path"] == "input.request"
assert any("broken" in warning for warning in inspected["result"]["warnings"])
assert after["result"]["revision"] == patched["result"]["revision"]
@pytest.mark.parametrize(
("method", "params"),
[
@@ -400,6 +400,33 @@ async def test_rpc_client_sends_exact_draft_lifecycle_payloads() -> None:
]
async def test_rpc_client_sends_exact_authoring_contract_payload() -> None:
calls: list[dict[str, Any]] = []
class Client(RpcDraftClientMixin):
async def _call(self, method: str, params: dict[str, object]):
calls.append({"method": method, "params": params})
return {"workspace_id": "ws", "revision": 4, "selected_step_id": None}
client = Client()
result = await client.inspect_draft_authoring_contract(
workspace_id="ws",
revision=4,
)
assert result["revision"] == 4
assert calls == [
{
"method": "workflow.draft_workspaces.inspect_authoring_contract",
"params": {
"workspace_id": "ws",
"revision": 4,
"selected_step_id": None,
},
}
]
async def test_rpc_client_sends_exact_stateless_draft_payloads() -> None:
calls: list[dict[str, Any]] = []
@@ -492,6 +492,41 @@ def test_openrpc_exposes_typed_draft_workspace_results(
)
def test_openrpc_exposes_typed_authoring_contract_inventory(
openrpc_document: dict[str, Any],
) -> None:
method = _method_by_name(
openrpc_document,
"workflow.draft_workspaces.inspect_authoring_contract",
)
schemas = openrpc_document["components"]["schemas"]
assert method["result"]["schema"]["anyOf"] == [
{"$ref": "#/components/schemas/AuthoringContractInventoryPayload"},
{"$ref": "#/components/schemas/DraftWorkspaceResult"},
]
params = method["params"]
assert [param["name"] for param in params] == [
"workspace_id",
"revision",
"selected_step_id",
]
assert params[0]["required"] is True
assert params[1]["required"] is True
assert params[2]["required"] is False
assert params[2]["schema"]["anyOf"][0] == {
"type": "string",
"minLength": 1,
}
assert params[2]["schema"]["anyOf"][1] == {"type": "null"}
assert schemas["AuthoringContractInventoryPayload"]["properties"][
"selected_step_id"
]["anyOf"] == [{"type": "string"}, {"type": "null"}]
option = schemas["AuthoringPathOptionPayload"]
assert option["properties"]["reason"]["type"] == "string"
assert "reason" not in option["required"]
def test_openrpc_separates_step_input_and_workflow_output_binding_unions(
openrpc_document: dict[str, Any],
) -> None: