fix: close Python workflow client review

This commit is contained in:
lda
2026-08-31 23:38:03 +07:00 Verified
parent 4f946a35ef
commit 5e3d0b6524
15 changed files with 200 additions and 224 deletions
@@ -1,47 +0,0 @@
# Final fix recovery report
## Scope
Audited the uncommitted patch on `b377311a` against the final review findings
and the Python workflow-client design/plan. The generated `.wf_mcp_store/` and
`test-artifacts/` directories were left untouched and unstaged.
## Fixes completed
- Opted the remaining draft-focused RPC client test into `drafts=True`; default
server/storage composition remains draft-free.
- Added the public HTTP port adapter used by `App.from_http_jsonrpc()`. HTTP,
connection, malformed JSON, and malformed JSON-RPC response failures become
`WorkflowClientError` subclasses; known workflow error codes map to stable
subclasses and unknown codes remain inspectable `ProtocolError` values with
code/message/data preserved.
- Added strict identity validation for artifact inspection/save, capability
calls, deployment lifecycle, run lifecycle, and bounded trace pages.
- Made `WorkflowArtifact`, `Deployment`, and `Run` retain deep private copies
and expose defensive copies for nested mutable values.
- Narrowed workflow-plan reconstruction handling to Pydantic `ValidationError`.
- Typed deployment drift policy with the existing `wf_artifacts.DriftPolicy`
enum and removed unused internal client exports/protocol operations.
- Hardened the underlying RPC client against valid JSON values that are not
JSON-RPC objects, and removed `frozen=True` from `RpcProtocolError` so Python
can attach exception traceback state.
## Verification
Commands were run from the feature worktree.
| Command | Result |
| --- | --- |
| `uv run pytest -q tests/wf_client tests/wf_transport_rpc_http` | **292 passed**, 257 warnings |
| `uv run pytest -q tests/wf_client tests/authoring/test_builder.py tests/authoring/test_subgraph.py tests/wf_api/test_artifact_api.py tests/wf_transport_rpc_http/test_client.py tests/wf_transport_rpc_http/test_app.py tests/wf_transport_rpc_http/test_openrpc_contract.py tests/wf_contract_manifest/test_generate.py tests/wf_contract_manifest/test_committed_manifest.py tests/wf_api/test_stores.py tests/wf_server/test_local_static_server.py tests/wf_mcp/test_mcp_workflow_server.py tests/wf_mcp/server/test_tools.py tests/wf_mcp/workflow_surface tests/wf_cli/test_context.py tests/wf_server/test_cli.py` | **429 passed**, 201 warnings |
| `uv run ruff check` | **All checks passed** |
| `uv run ruff format --check` | **677 files already formatted** |
| `uv run basedpyright --level error` | **0 errors, 0 warnings, 0 notes** |
| `uv run python -m wf_contract_manifest check` | **checked** `contracts/workflow-api.manifest.json` |
| `pnpm --dir web --filter @lda/workflow-rpc contract:check` | **passed** |
| `pnpm --dir web --filter @lda/workflow-rpc test` | **151 passed**, 3 skipped |
| `git diff --check` | **passed** |
| `uv run pytest -q` | **2644 passed**, 1 skipped, 1 xfailed; 1 known baseline failure: `tests/docs/test_big_doc_links.py::test_thesis_bundle_has_reproducible_agent_evaluation_assets` (missing generated thesis figure PDFs) |
The full-suite failure is the documented pre-existing missing-asset failure;
no thesis assets were generated or added.
@@ -1,48 +0,0 @@
# Task 6 report: deployment and durable run objects
## Status
Implemented and committed as `112e9656` (`feat: add Python deployment and run objects`).
## Files
- Added `src/wf_client/deployments.py` with immutable `Deployment` and
`DeploymentValidation` snapshots plus strict artifact deployment selection.
- Added `src/wf_client/runs.py` with immutable `Run` and bounded `TracePage`
snapshots, resume/refresh lifecycle methods, and core interrupt/trace
reconstruction.
- Added `tests/wf_client/test_deployments.py` and
`tests/wf_client/test_runs.py`.
- Updated `WorkflowArtifact`, `App`, public exports, codecs, and structured
deployment exceptions.
## Verification
- `uv run pytest tests/wf_client/test_deployments.py tests/wf_client/test_runs.py tests/wf_api/test_deployment_api.py tests/wf_api/test_run_api.py -q`**30 passed**.
- `uv run pytest tests/wf_client -q`**28 passed**.
- `uv run ruff check src/wf_client tests/wf_client/test_deployments.py tests/wf_client/test_runs.py`**passed**.
- `uv run basedpyright --level error src/wf_client tests/wf_client/test_deployments.py tests/wf_client/test_runs.py`**0 errors**.
## Caveats
- `.wf_mcp_store/` and `test-artifacts/` remain untracked generated directories
and were intentionally not staged.
- Trace limits follow the existing server bound of 1100 and are validated
before issuing a trace request.
## Review fix round 1
Identity checks now reject mismatched deployment/artifact/run ids at every
inspect, validate, start, refresh, and resume boundary. Nested interrupt route
references are converted to `InvalidResponse` with operation context, and run
decoding accepts truthful inspect/start/resume operation names.
Fixes committed in the follow-up review commit for this report.
## Review fix round 2
The sole discovered deployment path now rechecks the inspected artifact
identity, deployment creation acknowledgements and inspected ids are checked
before validation, and run-start responses enforce deployment plus artifact
identity. Missing-run errors preserve server text, including operation-aware
start interrupt decoding.
@@ -1,73 +0,0 @@
# Task 7 Report
## Delivered
- Added a real `httpx.ASGITransport` proof for capability discovery, local
authoring, remote validation, immutable artifact save, deployment selection,
and durable run execution.
- Added bounded, inert `repr()` and `_repr_html_()` implementations for the
Python client's loaded capability, artifact, deployment, validation, run,
diagnostic, and trace objects. The shared private renderer HTML-escapes,
bounds nested previews, and redacts credential-shaped keys without touching
the client port.
- Added the `wf_client` package map, source/API boundary notes, and a concrete
Python walkthrough that explains the artifact -> deployment -> run model.
- Added an opt-out seam for server draft composition. `drafts=False` skips
draft service construction, does not require a draft store, and omits draft
JSON-RPC methods; the existing implementation remains available to explicit
draft-enabled callers.
## Verification
- Initial Task 7 client and cross-layer selection — 288 passed.
- Fix-round focused client/composition selection — 55 passed.
- Ruff check and basedpyright for changed client/API/transport surfaces — passed.
- `uv run python -m wf_contract_manifest check` — passed.
- `pnpm --dir web --filter @lda/workflow-rpc contract:check` — passed.
- `pnpm --dir web --filter @lda/workflow-rpc test` — 151 passed, 3 skipped.
The full repository suite was not used as the fix-round gate: legacy direct
draft-service tests still construct draft APIs without the now-required
explicit `drafts=True` opt-in, and one thesis asset test expects untracked PDF
figures absent from the base worktree. No generated `.wf_mcp_store/` or
`test-artifacts/` files are part of this change.
## Fix round 2
- `file_workflow_stores()` now skips `FileDraftWorkspaceStore` by default;
`build_local_static_workflow_server()` forwards `drafts` so default local
composition creates no draft directory, while `drafts=True` remains a
working explicit opt-in.
- Draft-bearing MCP broker/workflow-surface and CLI compositions now pass
`drafts=True` explicitly. The standalone RPC server CLI does the same for
its documented draft RPC surface. The complete OpenRPC inventory fixture and
draft-focused RPC tests now opt into both server storage and RPC method
registration.
- The API architecture walkthrough now imports `App` from `wf_client`.
Fix-round 2 verification (fresh after formatting):
- `uv run pytest tests/wf_api/test_stores.py tests/wf_server/test_local_static_server.py tests/wf_mcp/test_mcp_workflow_server.py tests/wf_mcp/server/test_tools.py tests/wf_mcp/workflow_surface tests/wf_cli/test_context.py tests/wf_server/test_cli.py tests/wf_transport_rpc_http/test_openrpc_contract.py -q` — 183 passed.
- `uv run pytest tests/wf_client tests/authoring/test_builder.py tests/authoring/test_subgraph.py tests/wf_api/test_artifact_api.py tests/wf_transport_rpc_http/test_client.py tests/wf_transport_rpc_http/test_app.py tests/wf_transport_rpc_http/test_openrpc_contract.py tests/wf_contract_manifest/test_generate.py tests/wf_contract_manifest/test_committed_manifest.py -q` — 291 passed, 201 warnings.
- Ruff check and `ruff format --check` on changed Python surfaces — passed.
- `uv run basedpyright --level error src/wf_api src/wf_cli src/wf_mcp/broker src/wf_mcp/workflow_surface src/wf_server` — 0 errors, 0 warnings, 0 notes.
- `uv run python -m wf_contract_manifest check` — passed.
- `pnpm --dir web --filter @lda/workflow-rpc contract:check` — passed.
- `pnpm --dir web --filter @lda/workflow-rpc test` — 151 passed, 3 skipped.
- `git diff --check` — passed.
## Fix round 1
- Normal `WorkflowApi`, nested capability/artifact services, durable context,
local server construction, and JSON-RPC app composition now default to
`drafts=False`. Draft APIs are stored as `None` when disabled and require
explicit `drafts=True` at composition time; RPC registration rejects an
opt-in against a disabled API.
- The two live walkthroughs now include real schemas, explicit constant input
and output bindings, an `end` step, and the terminal route.
- The repr projector now follows the console evidence policy's exact-key
matching, normalizes camelCase spellings (`apiKey`, `accessToken`, etc.),
avoids false positives (`tokenCount`, `secretary`), and consumes at most a
bounded prefix of mappings/sequences/iterables.
- Fix-round verification: focused client/composition tests `55 passed`; Ruff
and basedpyright passed with zero errors.
+11 -8
View File
@@ -22,14 +22,17 @@ The durable product path is now `wf-rpc-server` plus neutral `wf_config` /
`wf_server` composition. The old `wf-mcp` script remains a legacy/special-purpose
MCP entrypoint and compatibility surface.
Completed: the async `wf_client` Python slice is verified against the real
JSON-RPC ASGI application. It covers capability discovery, local graph
authoring, remote validation, immutable artifact save, deployment selection,
and durable run execution. Rich client objects have bounded, secret-safe
`repr()` and `_repr_html_()` views that never perform remote I/O. Draft
workspaces remain a separate server/admin surface and are not part of the
client; server registration is explicit so the artifact -> deployment -> run
path does not require draft storage.
Delivered, with the repository-wide verification gate still open: the async
`wf_client` Python slice is verified against the real JSON-RPC ASGI application.
It covers capability discovery, local graph authoring, remote validation,
immutable artifact save, deployment selection, and durable run execution. Rich
client objects have bounded, secret-safe `repr()` and `_repr_html_()` views that
never perform remote I/O. Draft workspaces remain a separate server/admin
surface and are not part of the client; server registration is explicit so the
artifact -> deployment -> run path does not require draft storage. All
slice-owned checks pass, while the full repository suite still has its
pre-existing missing-thesis-PDF failure; mark this slice complete only when that
repository asset gate also passes.
## Active Initiative: Workflow Console And Defense Demo
@@ -1301,7 +1301,13 @@ artifact = await graph.save(version=1)
run = await artifact.run({})
```
State explicitly that drafts are not part of `wf_client` but remain registered in the server until the separate draft opt-out plan is executed. Update `docs/current_roadmap.md` to mark the Python-client slice complete only after every verification step below passes.
Drafts are not part of `wf_client`. The shipped server composition uses
`drafts=False` by default, which excludes draft storage, domain modules, and RPC
registration; real legacy draft consumers opt in explicitly with `drafts=True`.
The earlier plan assumption that draft methods would remain registered pending a
separate opt-out slice is historical. Update `docs/current_roadmap.md` to mark
the Python-client slice complete only after every verification step below
passes.
- [ ] **Step 6: Run focused and cross-layer verification**
@@ -633,12 +633,11 @@ capability, expose secrets, or fetch an unbounded trace.
authoring compiles a complete workflow and saves it through the existing
artifact-from-plan operation.
Making draft support uninitialized by default is a separate server-composition
slice. Today `WorkflowApi` constructs draft modules unconditionally, durable
context validation requires a draft store, and the JSON-RPC app always
registers draft methods. That follow-up must make draft storage, domain modules,
and RPC registration opt-in without weakening artifact, deployment, or run
durability.
Draft support is uninitialized by default across storage, domain modules, and
JSON-RPC registration. Normal composition uses `drafts=False`; callers that
still operate the legacy draft workspace surface must opt in explicitly with
`drafts=True`. This keeps artifact, deployment, and run durability independent
from draft storage.
## Testing Strategy
+33 -6
View File
@@ -61,7 +61,7 @@ def _prepare_artifact_from_plan(
description: str | None,
plan: RawWorkflowPlan | dict[str, Any],
outcomes: Sequence[str],
required_capabilities: dict[str, dict[str, Any]] | None,
required_capabilities: Mapping[str, RequiredCapability | dict[str, Any]] | None,
source_bindings: dict[str, str] | None,
created_from_catalog_version: str | None,
) -> WorkflowArtifact:
@@ -80,7 +80,11 @@ def _prepare_artifact_from_plan(
plan=typed_plan.model_dump(mode="json", by_alias=True),
outcomes=tuple(outcomes),
required_capabilities={
name: RequiredCapability.model_validate(capability)
name: (
capability
if isinstance(capability, RequiredCapability)
else RequiredCapability.model_validate(capability)
)
for name, capability in (required_capabilities or {}).items()
},
source_bindings=source_bindings,
@@ -103,14 +107,16 @@ def _invalid_artifact_plan_payload(
def _diagnostic_from_validation_error(
exc: ValidationError,
*,
root: str = "plan",
) -> ArtifactPlanDiagnosticPayload:
"""Project the first typed model error with a stable plan-rooted path."""
"""Project the first typed model error beneath its request-field root."""
error = exc.errors()[0]
location = ".".join(str(part) for part in error["loc"])
return {
"severity": "error",
"code": "artifact_plan_invalid",
"path": f"plan.{location}" if location else "plan",
"path": f"{root}.{location}" if location else root,
"message": str(error["msg"]),
"repair_hint": None,
}
@@ -254,6 +260,27 @@ class WorkflowArtifactApi:
source_bindings: dict[str, str] | None = None,
) -> ValidateArtifactPlanResult:
"""Validate and inventory a plan without writing the artifact store."""
try:
typed_plan = RawWorkflowPlan.model_validate(plan)
except ValidationError as exc:
return _PROJECT_VALIDATE_ARTIFACT(
_invalid_artifact_plan_payload(_diagnostic_from_validation_error(exc))
)
typed_requirements: dict[str, RequiredCapability] = {}
for name, capability in (required_capabilities or {}).items():
try:
typed_requirements[name] = RequiredCapability.model_validate(capability)
except ValidationError as exc:
return _PROJECT_VALIDATE_ARTIFACT(
_invalid_artifact_plan_payload(
_diagnostic_from_validation_error(
exc,
root=f"required_capabilities.{name}",
)
)
)
try:
# These identity fields satisfy the shared artifact factory only;
# validation never calls the store or emits a saved-artifact event.
@@ -264,9 +291,9 @@ class WorkflowArtifactApi:
title="Validation",
kind="workflow",
description=None,
plan=plan,
plan=typed_plan,
outcomes=outcomes,
required_capabilities=required_capabilities,
required_capabilities=typed_requirements,
source_bindings=source_bindings,
created_from_catalog_version=None,
)
+7
View File
@@ -107,6 +107,13 @@ def _workflow_dependencies_from_plan(plan: JsonObject) -> dict[str, int]:
continue
workflow_ref = WorkflowRef.model_validate(node.get("workflow"))
if workflow_ref.artifact_id is not None and workflow_ref.version is not None:
pinned = dependencies.get(workflow_ref.artifact_id)
if pinned is not None and pinned != workflow_ref.version:
raise WorkflowPlanValidationError(
"invalid workflow plan: conflicting versions "
f"{pinned} and {workflow_ref.version} pinned for child "
f"artifact {workflow_ref.artifact_id!r}"
)
dependencies[workflow_ref.artifact_id] = workflow_ref.version
return dependencies
+3 -21
View File
@@ -29,12 +29,8 @@ from wf_transport_rpc_http.client.base import RpcProtocolError
from .errors import (
ArtifactNotFound,
ArtifactVersionConflict,
CapabilityNotFound,
DeploymentNotRunnable,
DeploymentRequired,
ProtocolError,
RevisionConflict,
TransportError,
WorkflowClientError,
)
@@ -64,22 +60,8 @@ def _known_protocol_error(
operation: str,
error: RpcProtocolError,
) -> WorkflowClientError | None:
"""Translate only stable codes or exact legacy missing-resource signals."""
"""Translate only operation-specific errors emitted by the current server."""
code, detail = _server_detail(error)
normalized = code.casefold() if code is not None else ""
if normalized in {"capability_not_found", "capabilitynotfound"}:
return CapabilityNotFound(detail)
if normalized in {"artifact_not_found", "artifactnotfound"}:
return ArtifactNotFound(detail)
if normalized in {"artifact_version_conflict", "artifactversionconflict"}:
return ArtifactVersionConflict(detail)
if normalized in {"revision_conflict", "revisionconflict"}:
return RevisionConflict(detail)
if normalized in {"deployment_required", "deploymentrequired"}:
return DeploymentRequired()
if normalized in {"deployment_not_runnable", "deploymentnotrunnable"}:
return DeploymentNotRunnable(error=detail)
# The current RPC server reports expected application exception class names
# in ``data.code``. A generic KeyError is safe to specialize only when both
# the operation and its exact resource phrase agree.
@@ -87,11 +69,11 @@ def _known_protocol_error(
if operation.startswith("workflow.capabilities.") and (
"unknown workflow capability" in detail
):
return CapabilityNotFound(detail)
return CapabilityNotFound(detail, code=error.code, data=error.data)
if operation == "workflow.artifacts.inspect" and (
"unknown workflow artifact" in detail
):
return ArtifactNotFound(detail)
return ArtifactNotFound(detail, code=error.code, data=error.data)
return None
+2 -2
View File
@@ -116,7 +116,7 @@ class Deployment:
deployment_id=self.deployment_id,
artifact=f"{self.artifact_id}.v{self.artifact_version}",
runnable=self.runnable,
diagnostics=f"{len(self.diagnostics)} diagnostics",
diagnostics=f"{len(self._diagnostics)} diagnostics",
)
def _repr_html_(self) -> str:
@@ -126,7 +126,7 @@ class Deployment:
artifact=f"{self.artifact_id}.v{self.artifact_version}",
bindings=f"{len(self.bindings)} bindings",
runnable=self.runnable,
diagnostics=f"{len(self.diagnostics)} diagnostics",
diagnostics=f"{len(self._diagnostics)} diagnostics",
)
@property
+16 -3
View File
@@ -11,6 +11,20 @@ from wf_artifacts import DependencyDiagnostic
class WorkflowClientError(Exception):
"""Base class for errors that can be handled by workflow callers."""
code: int | str | None
data: object
def __init__(
self,
message: str = "",
*,
code: int | str | None = None,
data: object = None,
) -> None:
self.code = code
self.data = deepcopy(data)
super().__init__(message)
class TransportError(WorkflowClientError):
"""The client could not communicate with the workflow service."""
@@ -29,10 +43,9 @@ class ProtocolError(WorkflowClientError):
message: str,
data: object = None,
) -> None:
self.code = code
self.message = message
self.data = deepcopy(data)
super().__init__(str(self))
super().__init__(message, code=code, data=data)
self.args = (str(self),)
def __str__(self) -> str:
if isinstance(self.data, dict) and isinstance(self.data.get("message"), str):
+6 -1
View File
@@ -51,9 +51,10 @@ class RpcClientTransport:
http_client: httpx.AsyncClient | None = None
async def _call(self, method: str, params: dict[str, Any]) -> dict[str, Any]:
request_id = uuid4().hex
request = {
"jsonrpc": "2.0",
"id": uuid4().hex,
"id": request_id,
"method": method,
"params": params,
}
@@ -66,6 +67,10 @@ class RpcClientTransport:
payload = response.json()
if not isinstance(payload, dict):
raise RuntimeError("JSON-RPC response must be an object")
if payload.get("jsonrpc") != "2.0":
raise RuntimeError("JSON-RPC response must declare version '2.0'")
if payload.get("id") != request_id:
raise RuntimeError("JSON-RPC response id does not match the request")
if "error" in payload:
error = payload["error"]
if not isinstance(error, dict):
+58
View File
@@ -236,6 +236,29 @@ async def test_validate_artifact_plan_projects_invalid_plan_diagnostic(
}
@pytest.mark.asyncio
async def test_validate_artifact_plan_roots_capability_diagnostic_at_request_field(
tmp_path: Path,
) -> None:
artifact_store = FileWorkflowArtifactStore(tmp_path / "artifacts_requirement")
api, _service = _artifact_api(artifact_store)
result = await api.validate_artifact_plan(
plan=_echo_artifact().plan,
outcomes=("completed",),
required_capabilities={
"broken": {
"ref": {"source": "demo", "capability_key": "echo"},
"kind": "not-a-capability-kind",
}
},
source_bindings={},
)
assert result["status"] == "invalid"
assert result["diagnostics"][0]["path"] == "required_capabilities.broken.kind"
@pytest.mark.asyncio
async def test_validate_artifact_plan_propagates_unexpected_value_error(
tmp_path: Path,
@@ -290,6 +313,41 @@ async def test_validate_artifact_plan_derives_saved_workflow_dependencies(
assert result["workflow_dependencies"] == {"child_workflow": 7}
@pytest.mark.asyncio
async def test_validate_artifact_plan_rejects_conflicting_child_version_pins(
tmp_path: Path,
) -> None:
artifact_store = FileWorkflowArtifactStore(tmp_path / "artifacts_pin_conflict")
api, _service = _artifact_api(artifact_store)
plan = _echo_artifact().plan
plan["start"] = "child_v1"
plan["nodes"] = [
{
"id": node_id,
"type": "subgraph",
"workflow": {"artifact_id": "child_workflow", "version": version},
"input_schema": {"type": "object"},
"output_schema": {"type": "object"},
"outcomes": ["completed"],
}
for node_id, version in (("child_v1", 1), ("child_v2", 2))
]
plan["edges"] = [
{"from": "child_v1", "outcome": "completed", "to": "child_v2"},
{"from": "child_v2", "outcome": "completed", "to": "__end__"},
]
result = await api.validate_artifact_plan(
plan=plan,
outcomes=("completed",),
source_bindings={},
)
assert result["status"] == "invalid"
assert result["diagnostics"][0]["path"] == "plan"
assert "conflicting versions 1 and 2" in result["diagnostics"][0]["message"]
@pytest.mark.asyncio
async def test_create_artifact_from_workspace_suggests_exact_available_source_binding(
tmp_path: Path,
+24 -7
View File
@@ -150,19 +150,25 @@ async def test_http_app_translates_http_and_json_failures(
async def test_http_app_translates_known_workflow_protocol_error(
monkeypatch: pytest.MonkeyPatch,
) -> None:
async def error_post(*args: object, **kwargs: object) -> httpx.Response:
async def error_post(
_client: object,
_url: object,
*,
json: dict[str, object],
**_kwargs: object,
) -> httpx.Response:
return httpx.Response(
200,
request=httpx.Request("POST", "http://test/rpc"),
json={
"jsonrpc": "2.0",
"id": "request",
"id": json["id"],
"error": {
"code": 5000,
"message": "Workflow operation failed",
"data": {
"code": "capability_not_found",
"message": "unknown capability app.default.search",
"code": "KeyError",
"message": "unknown workflow capability 'app.default.search'",
},
},
},
@@ -175,7 +181,12 @@ async def test_http_app_translates_known_workflow_protocol_error(
await app.capability("app.default.search")
assert isinstance(raised.value, CapabilityNotFound)
assert "unknown capability" in str(raised.value)
assert "unknown workflow capability" in str(raised.value)
assert raised.value.code == 5000
assert raised.value.data == {
"code": "KeyError",
"message": "unknown workflow capability 'app.default.search'",
}
@pytest.mark.asyncio
@@ -184,13 +195,19 @@ async def test_http_app_preserves_unknown_protocol_error_details(
) -> None:
data = {"code": "future_workflow_error", "message": "future detail", "retry": 3}
async def error_post(*args: object, **kwargs: object) -> httpx.Response:
async def error_post(
_client: object,
_url: object,
*,
json: dict[str, object],
**_kwargs: object,
) -> httpx.Response:
return httpx.Response(
200,
request=httpx.Request("POST", "http://test/rpc"),
json={
"jsonrpc": "2.0",
"id": "request",
"id": json["id"],
"error": {
"code": 5999,
"message": "Future workflow error",
+28 -1
View File
@@ -1,5 +1,6 @@
from __future__ import annotations
import json
from typing import Any
import httpx
@@ -33,11 +34,12 @@ from wf_transport_rpc_http.client.sources import RpcSourceAdminClientMixin
async def test_rpc_client_preserves_structured_jsonrpc_error() -> None:
def handler(request: httpx.Request) -> httpx.Response:
request_id = json.loads(request.content)["id"]
return httpx.Response(
200,
json={
"jsonrpc": "2.0",
"id": "request",
"id": request_id,
"error": {
"code": "missing_source",
"message": "workflow operation failed",
@@ -61,6 +63,31 @@ async def test_rpc_client_preserves_structured_jsonrpc_error() -> None:
assert str(raised.value) == ("workflow operation failed: source is not configured")
@pytest.mark.asyncio
@pytest.mark.parametrize(
("jsonrpc", "response_id"),
[(None, "echo"), ("1.0", "echo"), ("2.0", "wrong")],
)
async def test_rpc_client_rejects_malformed_response_envelope(
jsonrpc: str | None,
response_id: str,
) -> None:
def handler(request: httpx.Request) -> httpx.Response:
request_id = json.loads(request.content)["id"]
payload: dict[str, object] = {
"id": request_id if response_id == "echo" else response_id,
"result": {},
}
if jsonrpc is not None:
payload["jsonrpc"] = jsonrpc
return httpx.Response(200, json=payload)
async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as http_client:
client = RpcWorkflowApiClient(url="http://test/rpc", http_client=http_client)
with pytest.raises(RuntimeError, match="JSON-RPC response"):
await client.list_capabilities()
def _constant_plan() -> RawWorkflowPlan:
return RawWorkflowPlan.model_validate(
{