fix: harden Python workflow client boundary
This commit is contained in:
@@ -0,0 +1,47 @@
|
|||||||
|
# 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.
|
||||||
@@ -62,9 +62,7 @@ class WorkflowDeploymentApi:
|
|||||||
.model_dump(mode="json"),
|
.model_dump(mode="json"),
|
||||||
)
|
)
|
||||||
|
|
||||||
async def save_deployment(
|
async def save_deployment(self, deployment: dict[str, Any]) -> SaveDeploymentResult:
|
||||||
self, deployment: dict[str, Any]
|
|
||||||
) -> SaveDeploymentResult:
|
|
||||||
workflow_deployment = WorkflowDeployment.model_validate(deployment)
|
workflow_deployment = WorkflowDeployment.model_validate(deployment)
|
||||||
self._artifact_store().save_deployment(workflow_deployment)
|
self._artifact_store().save_deployment(workflow_deployment)
|
||||||
self.context.events.record_workflow_event(
|
self.context.events.record_workflow_event(
|
||||||
@@ -83,9 +81,7 @@ class WorkflowDeploymentApi:
|
|||||||
"saved": True,
|
"saved": True,
|
||||||
}
|
}
|
||||||
|
|
||||||
async def delete_deployment(
|
async def delete_deployment(self, *, deployment_id: str) -> DeleteDeploymentResult:
|
||||||
self, *, deployment_id: str
|
|
||||||
) -> DeleteDeploymentResult:
|
|
||||||
"""Delete one mutable deployment environment binding."""
|
"""Delete one mutable deployment environment binding."""
|
||||||
self._artifact_store().delete_deployment(deployment_id)
|
self._artifact_store().delete_deployment(deployment_id)
|
||||||
self.context.events.record_workflow_event(
|
self.context.events.record_workflow_event(
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from collections.abc import Mapping
|
from collections.abc import Mapping
|
||||||
|
|
||||||
|
from pydantic import ValidationError
|
||||||
|
|
||||||
from wf_core import ReducerRef, Workflow
|
from wf_core import ReducerRef, Workflow
|
||||||
from wf_core.models.workflow_refs import WorkflowRef
|
from wf_core.models.workflow_refs import WorkflowRef
|
||||||
from wf_platform import CapabilityRef, NodeSpecInventory, hash_json_schema
|
from wf_platform import CapabilityRef, NodeSpecInventory, hash_json_schema
|
||||||
@@ -69,7 +71,7 @@ def _required_object_field(plan: JsonObject, field_name: str) -> JsonObject:
|
|||||||
def _validate_workflow_plan(plan: JsonObject) -> None:
|
def _validate_workflow_plan(plan: JsonObject) -> None:
|
||||||
try:
|
try:
|
||||||
workflow = Workflow.model_validate(plan)
|
workflow = Workflow.model_validate(plan)
|
||||||
except Exception as exc:
|
except ValidationError as exc:
|
||||||
raise WorkflowPlanValidationError(f"invalid workflow plan: {exc}") from exc
|
raise WorkflowPlanValidationError(f"invalid workflow plan: {exc}") from exc
|
||||||
|
|
||||||
node_ids = {node.id for node in workflow.nodes}
|
node_ids = {node.id for node in workflow.nodes}
|
||||||
|
|||||||
@@ -5,22 +5,6 @@ from wf_platform import CapabilityRef, Page
|
|||||||
from .app import App
|
from .app import App
|
||||||
from .authoring import EditableWorkflow
|
from .authoring import EditableWorkflow
|
||||||
from .capabilities import CapabilityResult, CapabilitySummary, RemoteCapability
|
from .capabilities import CapabilityResult, CapabilitySummary, RemoteCapability
|
||||||
from .codec import (
|
|
||||||
DecodedRunResult,
|
|
||||||
DecodedTracePage,
|
|
||||||
decode_capabilities_page,
|
|
||||||
decode_capability_call,
|
|
||||||
decode_capability_diagnostics,
|
|
||||||
decode_capability_inspect,
|
|
||||||
decode_dependency_diagnostics,
|
|
||||||
decode_deployment,
|
|
||||||
decode_deployment_validation,
|
|
||||||
decode_deployments,
|
|
||||||
decode_run_result,
|
|
||||||
decode_trace_result,
|
|
||||||
decode_validate_artifact_plan,
|
|
||||||
decode_workflow_artifact,
|
|
||||||
)
|
|
||||||
from .deployments import Deployment, DeploymentValidation
|
from .deployments import Deployment, DeploymentValidation
|
||||||
from .errors import (
|
from .errors import (
|
||||||
ArtifactNotFound,
|
ArtifactNotFound,
|
||||||
@@ -35,7 +19,6 @@ from .errors import (
|
|||||||
ValidationFailed,
|
ValidationFailed,
|
||||||
WorkflowClientError,
|
WorkflowClientError,
|
||||||
)
|
)
|
||||||
from .protocols import WorkflowClientPort
|
|
||||||
from .runs import Run, TracePage
|
from .runs import Run, TracePage
|
||||||
from .workflows import (
|
from .workflows import (
|
||||||
ArtifactRef,
|
ArtifactRef,
|
||||||
@@ -54,8 +37,6 @@ __all__ = [
|
|||||||
"CapabilityRef",
|
"CapabilityRef",
|
||||||
"CapabilityResult",
|
"CapabilityResult",
|
||||||
"CapabilitySummary",
|
"CapabilitySummary",
|
||||||
"DecodedRunResult",
|
|
||||||
"DecodedTracePage",
|
|
||||||
"Diagnostic",
|
"Diagnostic",
|
||||||
"DeploymentNotRunnable",
|
"DeploymentNotRunnable",
|
||||||
"DeploymentRequired",
|
"DeploymentRequired",
|
||||||
@@ -70,22 +51,9 @@ __all__ = [
|
|||||||
"TransportError",
|
"TransportError",
|
||||||
"ValidationFailed",
|
"ValidationFailed",
|
||||||
"WorkflowClientError",
|
"WorkflowClientError",
|
||||||
"WorkflowClientPort",
|
|
||||||
"EditableWorkflow",
|
"EditableWorkflow",
|
||||||
"WorkflowArtifact",
|
"WorkflowArtifact",
|
||||||
"WorkflowDiagnostic",
|
"WorkflowDiagnostic",
|
||||||
"WorkflowValidation",
|
"WorkflowValidation",
|
||||||
"TracePage",
|
"TracePage",
|
||||||
"decode_capabilities_page",
|
|
||||||
"decode_capability_call",
|
|
||||||
"decode_capability_diagnostics",
|
|
||||||
"decode_capability_inspect",
|
|
||||||
"decode_dependency_diagnostics",
|
|
||||||
"decode_deployment_validation",
|
|
||||||
"decode_deployments",
|
|
||||||
"decode_deployment",
|
|
||||||
"decode_run_result",
|
|
||||||
"decode_trace_result",
|
|
||||||
"decode_validate_artifact_plan",
|
|
||||||
"decode_workflow_artifact",
|
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -0,0 +1,302 @@
|
|||||||
|
"""HTTP client-port adapter that exposes only public ``wf_client`` errors."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Awaitable, Callable, Sequence
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from json import JSONDecodeError
|
||||||
|
from typing import Any, Literal, TypeVar
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from wf_api.models import (
|
||||||
|
CapabilityCallResult,
|
||||||
|
InspectCapabilityResult,
|
||||||
|
ListCapabilitiesResult,
|
||||||
|
ListDeploymentsResult,
|
||||||
|
RunResult,
|
||||||
|
RunTraceResult,
|
||||||
|
SaveArtifactResult,
|
||||||
|
SaveDeploymentResult,
|
||||||
|
ValidateArtifactPlanResult,
|
||||||
|
ValidateDeploymentResult,
|
||||||
|
WorkflowArtifactPayload,
|
||||||
|
WorkflowDeploymentPayload,
|
||||||
|
)
|
||||||
|
from wf_api.runs import TraceRangeLike
|
||||||
|
from wf_transport_rpc_http import RpcWorkflowApiClient
|
||||||
|
from wf_transport_rpc_http.client.base import RpcProtocolError
|
||||||
|
|
||||||
|
from .errors import (
|
||||||
|
ArtifactNotFound,
|
||||||
|
ArtifactVersionConflict,
|
||||||
|
CapabilityNotFound,
|
||||||
|
DeploymentNotRunnable,
|
||||||
|
DeploymentRequired,
|
||||||
|
ProtocolError,
|
||||||
|
RevisionConflict,
|
||||||
|
TransportError,
|
||||||
|
WorkflowClientError,
|
||||||
|
)
|
||||||
|
|
||||||
|
_ResultT = TypeVar("_ResultT")
|
||||||
|
|
||||||
|
|
||||||
|
def _server_detail(error: RpcProtocolError) -> tuple[str | None, str]:
|
||||||
|
data = error.data
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
return error.code if isinstance(error.code, str) else None, error.message
|
||||||
|
code = data.get("code")
|
||||||
|
detail = data.get("message")
|
||||||
|
return (
|
||||||
|
(
|
||||||
|
code
|
||||||
|
if isinstance(code, str)
|
||||||
|
else error.code
|
||||||
|
if isinstance(error.code, str)
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
detail if isinstance(detail, str) else error.message,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _known_protocol_error(
|
||||||
|
operation: str,
|
||||||
|
error: RpcProtocolError,
|
||||||
|
) -> WorkflowClientError | None:
|
||||||
|
"""Translate only stable codes or exact legacy missing-resource signals."""
|
||||||
|
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.
|
||||||
|
if code == "KeyError":
|
||||||
|
if operation.startswith("workflow.capabilities.") and (
|
||||||
|
"unknown workflow capability" in detail
|
||||||
|
):
|
||||||
|
return CapabilityNotFound(detail)
|
||||||
|
if operation == "workflow.artifacts.inspect" and (
|
||||||
|
"unknown workflow artifact" in detail
|
||||||
|
):
|
||||||
|
return ArtifactNotFound(detail)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class PublicErrorWorkflowClientPort:
|
||||||
|
"""Delegate RPC operations while preventing transport exception leakage."""
|
||||||
|
|
||||||
|
_rpc: RpcWorkflowApiClient
|
||||||
|
|
||||||
|
async def _invoke(
|
||||||
|
self,
|
||||||
|
operation: str,
|
||||||
|
call: Callable[..., Awaitable[_ResultT]],
|
||||||
|
/,
|
||||||
|
**params: Any,
|
||||||
|
) -> _ResultT:
|
||||||
|
try:
|
||||||
|
return await call(**params)
|
||||||
|
except RpcProtocolError as exc:
|
||||||
|
known = _known_protocol_error(operation, exc)
|
||||||
|
if known is not None:
|
||||||
|
raise known from exc
|
||||||
|
raise ProtocolError(exc.code, exc.message, exc.data) from exc
|
||||||
|
except (httpx.TransportError, httpx.HTTPStatusError, JSONDecodeError) as exc:
|
||||||
|
raise TransportError(f"{operation} transport failed: {exc}") from exc
|
||||||
|
except RuntimeError as exc:
|
||||||
|
# The RPC transport uses RuntimeError only when a decoded JSON-RPC
|
||||||
|
# result is not an object. That is a protocol failure, not a public
|
||||||
|
# transport implementation detail.
|
||||||
|
raise ProtocolError(None, f"{operation}: {exc}") from exc
|
||||||
|
|
||||||
|
async def list_capabilities(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
query: str | None = None,
|
||||||
|
source_id: str | None = None,
|
||||||
|
cursor: str | None = None,
|
||||||
|
limit: int = 50,
|
||||||
|
) -> ListCapabilitiesResult:
|
||||||
|
return await self._invoke(
|
||||||
|
"workflow.capabilities.list",
|
||||||
|
self._rpc.list_capabilities,
|
||||||
|
query=query,
|
||||||
|
source_id=source_id,
|
||||||
|
cursor=cursor,
|
||||||
|
limit=limit,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def inspect_capability(
|
||||||
|
self, *, qualified_name: str
|
||||||
|
) -> InspectCapabilityResult:
|
||||||
|
return await self._invoke(
|
||||||
|
"workflow.capabilities.inspect",
|
||||||
|
self._rpc.inspect_capability,
|
||||||
|
qualified_name=qualified_name,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def call_capability(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
qualified_name: str,
|
||||||
|
payload: dict[str, Any],
|
||||||
|
deployment_id: str | None = None,
|
||||||
|
) -> CapabilityCallResult:
|
||||||
|
return await self._invoke(
|
||||||
|
"workflow.capabilities.call",
|
||||||
|
self._rpc.call_capability,
|
||||||
|
qualified_name=qualified_name,
|
||||||
|
payload=payload,
|
||||||
|
deployment_id=deployment_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def inspect_artifact(
|
||||||
|
self, *, artifact_id: str, version: int
|
||||||
|
) -> WorkflowArtifactPayload:
|
||||||
|
return await self._invoke(
|
||||||
|
"workflow.artifacts.inspect",
|
||||||
|
self._rpc.inspect_artifact,
|
||||||
|
artifact_id=artifact_id,
|
||||||
|
version=version,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def create_artifact_from_plan(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
artifact_id: str,
|
||||||
|
version: int,
|
||||||
|
title: str,
|
||||||
|
plan: dict[str, Any],
|
||||||
|
outcomes: Sequence[str],
|
||||||
|
kind: Literal["workflow", "wrapper"] = "workflow",
|
||||||
|
description: str | None = None,
|
||||||
|
required_capabilities: dict[str, dict[str, Any]] | None = None,
|
||||||
|
source_bindings: dict[str, str] | None = None,
|
||||||
|
created_from_catalog_version: str | None = None,
|
||||||
|
) -> SaveArtifactResult:
|
||||||
|
return await self._invoke(
|
||||||
|
"workflow.artifacts.create_from_plan",
|
||||||
|
self._rpc.create_artifact_from_plan,
|
||||||
|
artifact_id=artifact_id,
|
||||||
|
version=version,
|
||||||
|
title=title,
|
||||||
|
plan=plan,
|
||||||
|
outcomes=outcomes,
|
||||||
|
kind=kind,
|
||||||
|
description=description,
|
||||||
|
required_capabilities=required_capabilities,
|
||||||
|
source_bindings=source_bindings,
|
||||||
|
created_from_catalog_version=created_from_catalog_version,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def validate_artifact_plan(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
plan: dict[str, Any],
|
||||||
|
outcomes: Sequence[str],
|
||||||
|
required_capabilities: dict[str, dict[str, Any]] | None = None,
|
||||||
|
source_bindings: dict[str, str] | None = None,
|
||||||
|
) -> ValidateArtifactPlanResult:
|
||||||
|
return await self._invoke(
|
||||||
|
"workflow.artifacts.validate_plan",
|
||||||
|
self._rpc.validate_artifact_plan,
|
||||||
|
plan=plan,
|
||||||
|
outcomes=outcomes,
|
||||||
|
required_capabilities=required_capabilities,
|
||||||
|
source_bindings=source_bindings,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def list_deployments(self) -> ListDeploymentsResult:
|
||||||
|
return await self._invoke(
|
||||||
|
"workflow.deployments.list",
|
||||||
|
self._rpc.list_deployments,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def inspect_deployment(
|
||||||
|
self, *, deployment_id: str
|
||||||
|
) -> WorkflowDeploymentPayload:
|
||||||
|
return await self._invoke(
|
||||||
|
"workflow.deployments.inspect",
|
||||||
|
self._rpc.inspect_deployment,
|
||||||
|
deployment_id=deployment_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def save_deployment(self, deployment: dict[str, Any]) -> SaveDeploymentResult:
|
||||||
|
return await self._invoke(
|
||||||
|
"workflow.deployments.save",
|
||||||
|
self._rpc.save_deployment,
|
||||||
|
deployment=deployment,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def validate_deployment(
|
||||||
|
self, *, deployment_id: str, live_check: bool = False
|
||||||
|
) -> ValidateDeploymentResult:
|
||||||
|
return await self._invoke(
|
||||||
|
"workflow.deployments.validate",
|
||||||
|
self._rpc.validate_deployment,
|
||||||
|
deployment_id=deployment_id,
|
||||||
|
live_check=live_check,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def run_deployment(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
deployment_id: str,
|
||||||
|
workflow_input: dict[str, Any],
|
||||||
|
trace_range: TraceRangeLike | None = None,
|
||||||
|
) -> RunResult:
|
||||||
|
return await self._invoke(
|
||||||
|
"workflow.runs.start",
|
||||||
|
self._rpc.run_deployment,
|
||||||
|
deployment_id=deployment_id,
|
||||||
|
workflow_input=workflow_input,
|
||||||
|
trace_range=trace_range,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def inspect_run(self, *, run_id: str) -> RunResult:
|
||||||
|
return await self._invoke(
|
||||||
|
"workflow.runs.inspect",
|
||||||
|
self._rpc.inspect_run,
|
||||||
|
run_id=run_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def resume_run(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
run_id: str,
|
||||||
|
resume_payload: dict[str, Any],
|
||||||
|
resume_outcome: str = "submitted",
|
||||||
|
trace_range: TraceRangeLike | None = None,
|
||||||
|
) -> RunResult:
|
||||||
|
return await self._invoke(
|
||||||
|
"workflow.runs.resume",
|
||||||
|
self._rpc.resume_run,
|
||||||
|
run_id=run_id,
|
||||||
|
resume_payload=resume_payload,
|
||||||
|
resume_outcome=resume_outcome,
|
||||||
|
trace_range=trace_range,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def read_run_trace(
|
||||||
|
self, *, run_id: str, trace_range: TraceRangeLike
|
||||||
|
) -> RunTraceResult:
|
||||||
|
return await self._invoke(
|
||||||
|
"workflow.runs.trace",
|
||||||
|
self._rpc.read_run_trace,
|
||||||
|
run_id=run_id,
|
||||||
|
trace_range=trace_range,
|
||||||
|
)
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
"""Strict identity checks for reconstructing public client snapshots."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Mapping
|
||||||
|
|
||||||
|
from .errors import InvalidResponse
|
||||||
|
|
||||||
|
|
||||||
|
def require_response_identity(
|
||||||
|
*,
|
||||||
|
operation: str,
|
||||||
|
actual: Mapping[str, object],
|
||||||
|
expected: Mapping[str, object],
|
||||||
|
) -> None:
|
||||||
|
"""Reject a validly shaped response that belongs to another resource.
|
||||||
|
|
||||||
|
Shape validation alone cannot prevent a server, proxy, or cache from
|
||||||
|
returning the wrong resource. Keeping this check centralized makes every
|
||||||
|
public reconstruction boundary report the operation and mismatched field.
|
||||||
|
"""
|
||||||
|
for field, expected_value in expected.items():
|
||||||
|
actual_value = actual.get(field)
|
||||||
|
if actual_value != expected_value:
|
||||||
|
raise InvalidResponse(
|
||||||
|
operation=operation,
|
||||||
|
details=(
|
||||||
|
f"response {field} {actual_value!r} does not match "
|
||||||
|
f"requested {expected_value!r}"
|
||||||
|
),
|
||||||
|
)
|
||||||
+11
-1
@@ -9,6 +9,8 @@ from typing import TYPE_CHECKING, Any
|
|||||||
from wf_platform import CapabilityRef, Page, SourceRef
|
from wf_platform import CapabilityRef, Page, SourceRef
|
||||||
from wf_transport_rpc_http import RpcWorkflowApiClient
|
from wf_transport_rpc_http import RpcWorkflowApiClient
|
||||||
|
|
||||||
|
from ._http_port import PublicErrorWorkflowClientPort
|
||||||
|
from ._identity import require_response_identity
|
||||||
from .authoring import EditableWorkflow
|
from .authoring import EditableWorkflow
|
||||||
from .capabilities import CapabilitySummary, RemoteCapability
|
from .capabilities import CapabilitySummary, RemoteCapability
|
||||||
from .codec import (
|
from .codec import (
|
||||||
@@ -82,9 +84,11 @@ class App:
|
|||||||
) -> App:
|
) -> App:
|
||||||
"""Configure a lazy HTTP JSON-RPC connection without performing I/O."""
|
"""Configure a lazy HTTP JSON-RPC connection without performing I/O."""
|
||||||
return cls(
|
return cls(
|
||||||
_port=RpcWorkflowApiClient(
|
_port=PublicErrorWorkflowClientPort(
|
||||||
|
RpcWorkflowApiClient(
|
||||||
url=url,
|
url=url,
|
||||||
timeout_seconds=timeout_seconds,
|
timeout_seconds=timeout_seconds,
|
||||||
|
)
|
||||||
),
|
),
|
||||||
endpoint=url,
|
endpoint=url,
|
||||||
)
|
)
|
||||||
@@ -118,6 +122,7 @@ class App:
|
|||||||
output_schema=dict(wire["output_schema"]),
|
output_schema=dict(wire["output_schema"]),
|
||||||
outcomes=tuple(wire["outcomes"]),
|
outcomes=tuple(wire["outcomes"]),
|
||||||
is_async=wire["is_async"],
|
is_async=wire["is_async"],
|
||||||
|
_kind=wire["kind"],
|
||||||
)
|
)
|
||||||
|
|
||||||
async def capabilities(
|
async def capabilities(
|
||||||
@@ -170,6 +175,11 @@ class App:
|
|||||||
version=version,
|
version=version,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
require_response_identity(
|
||||||
|
operation="workflow.artifacts.inspect",
|
||||||
|
actual={"artifact_id": artifact.id, "version": artifact.version},
|
||||||
|
expected={"artifact_id": artifact_id, "version": version},
|
||||||
|
)
|
||||||
return WorkflowArtifact(self._port, artifact, workflow)
|
return WorkflowArtifact(self._port, artifact, workflow)
|
||||||
|
|
||||||
async def edit_workflow(
|
async def edit_workflow(
|
||||||
|
|||||||
@@ -19,8 +19,13 @@ from wf_core import (
|
|||||||
Workflow,
|
Workflow,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
from ._identity import require_response_identity
|
||||||
from .capabilities import RemoteCapability
|
from .capabilities import RemoteCapability
|
||||||
from .codec import decode_validate_artifact_plan, decode_workflow_artifact
|
from .codec import (
|
||||||
|
decode_save_artifact,
|
||||||
|
decode_validate_artifact_plan,
|
||||||
|
decode_workflow_artifact,
|
||||||
|
)
|
||||||
from .protocols import WorkflowClientPort
|
from .protocols import WorkflowClientPort
|
||||||
from .workflows import (
|
from .workflows import (
|
||||||
ArtifactRef,
|
ArtifactRef,
|
||||||
@@ -39,9 +44,7 @@ class EditableWorkflow(WorkflowBuilder):
|
|||||||
artifact_title: str | None = field(default=None, kw_only=True)
|
artifact_title: str | None = field(default=None, kw_only=True)
|
||||||
artifact_description: str | None = field(default=None, kw_only=True)
|
artifact_description: str | None = field(default=None, kw_only=True)
|
||||||
_source_plan: dict[str, Any] | None = field(default=None, repr=False, kw_only=True)
|
_source_plan: dict[str, Any] | None = field(default=None, repr=False, kw_only=True)
|
||||||
_source_workflow: Workflow | None = field(
|
_source_workflow: Workflow | None = field(default=None, repr=False, kw_only=True)
|
||||||
default=None, repr=False, kw_only=True
|
|
||||||
)
|
|
||||||
_permissive_node_defs: set[str] = field(
|
_permissive_node_defs: set[str] = field(
|
||||||
default_factory=set, repr=False, kw_only=True
|
default_factory=set, repr=False, kw_only=True
|
||||||
)
|
)
|
||||||
@@ -191,11 +194,14 @@ class EditableWorkflow(WorkflowBuilder):
|
|||||||
validation = await self.validate()
|
validation = await self.validate()
|
||||||
validation.raise_for_errors()
|
validation.raise_for_errors()
|
||||||
_workflow, plan = self._plan()
|
_workflow, plan = self._plan()
|
||||||
saved_id = artifact_id or (self.based_on.artifact_id if self.based_on else self.name)
|
saved_id = artifact_id or (
|
||||||
|
self.based_on.artifact_id if self.based_on else self.name
|
||||||
|
)
|
||||||
saved_title = title if title is not None else self.artifact_title or self.name
|
saved_title = title if title is not None else self.artifact_title or self.name
|
||||||
saved_description = (
|
saved_description = (
|
||||||
description if description is not None else self.artifact_description
|
description if description is not None else self.artifact_description
|
||||||
)
|
)
|
||||||
|
acknowledgement = decode_save_artifact(
|
||||||
await self._port.create_artifact_from_plan(
|
await self._port.create_artifact_from_plan(
|
||||||
artifact_id=saved_id,
|
artifact_id=saved_id,
|
||||||
version=version,
|
version=version,
|
||||||
@@ -204,6 +210,16 @@ class EditableWorkflow(WorkflowBuilder):
|
|||||||
outcomes=tuple(self.outcomes),
|
outcomes=tuple(self.outcomes),
|
||||||
description=saved_description,
|
description=saved_description,
|
||||||
)
|
)
|
||||||
|
)
|
||||||
|
require_response_identity(
|
||||||
|
operation="workflow.artifacts.create_from_plan",
|
||||||
|
actual={
|
||||||
|
"artifact_id": acknowledgement["artifact_id"],
|
||||||
|
"version": acknowledgement["version"],
|
||||||
|
"saved": acknowledgement["saved"],
|
||||||
|
},
|
||||||
|
expected={"artifact_id": saved_id, "version": version, "saved": True},
|
||||||
|
)
|
||||||
# The acknowledgement is only an identity signal. Inspecting the exact
|
# The acknowledgement is only an identity signal. Inspecting the exact
|
||||||
# requested version ensures server normalization is retained losslessly.
|
# requested version ensures server normalization is retained losslessly.
|
||||||
inspected = await self._port.inspect_artifact(
|
inspected = await self._port.inspect_artifact(
|
||||||
@@ -211,6 +227,11 @@ class EditableWorkflow(WorkflowBuilder):
|
|||||||
version=version,
|
version=version,
|
||||||
)
|
)
|
||||||
artifact, workflow = decode_workflow_artifact(inspected)
|
artifact, workflow = decode_workflow_artifact(inspected)
|
||||||
|
require_response_identity(
|
||||||
|
operation="workflow.artifacts.inspect",
|
||||||
|
actual={"artifact_id": artifact.id, "version": artifact.version},
|
||||||
|
expected={"artifact_id": saved_id, "version": version},
|
||||||
|
)
|
||||||
return WorkflowArtifact(self._port, artifact, workflow)
|
return WorkflowArtifact(self._port, artifact, workflow)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ from wf_artifacts.models import DependencyDiagnostic
|
|||||||
from wf_core.models.schemas import NodeDef, SchemaRef
|
from wf_core.models.schemas import NodeDef, SchemaRef
|
||||||
from wf_platform import CapabilityRef
|
from wf_platform import CapabilityRef
|
||||||
|
|
||||||
|
from ._identity import require_response_identity
|
||||||
from ._repr import html_repr, short_repr
|
from ._repr import html_repr, short_repr
|
||||||
from .codec import decode_capability_call, decode_capability_diagnostics
|
from .codec import decode_capability_call, decode_capability_diagnostics
|
||||||
from .errors import InvalidResponse
|
from .errors import InvalidResponse
|
||||||
@@ -113,6 +114,7 @@ class RemoteCapability:
|
|||||||
output_schema: dict[str, Any]
|
output_schema: dict[str, Any]
|
||||||
outcomes: tuple[str, ...]
|
outcomes: tuple[str, ...]
|
||||||
is_async: bool
|
is_async: bool
|
||||||
|
_kind: str = field(default="node_spec", repr=False, compare=False)
|
||||||
|
|
||||||
def __post_init__(self) -> None:
|
def __post_init__(self) -> None:
|
||||||
# Freeze the public container shape at construction. The nested JSON
|
# Freeze the public container shape at construction. The nested JSON
|
||||||
@@ -200,6 +202,23 @@ class RemoteCapability:
|
|||||||
f"match requested {self.qualified_name!r}"
|
f"match requested {self.qualified_name!r}"
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
require_response_identity(
|
||||||
|
operation="workflow.capabilities.call",
|
||||||
|
actual={
|
||||||
|
"source_id": wire["source_id"],
|
||||||
|
"kind": wire["kind"],
|
||||||
|
"deployment_id": wire["deployment_id"],
|
||||||
|
},
|
||||||
|
expected={
|
||||||
|
"source_id": str(self.ref.source),
|
||||||
|
"kind": self._kind,
|
||||||
|
# Direct node calls intentionally ignore deployment ids; saved
|
||||||
|
# wrapper capabilities echo the selected deployment exactly.
|
||||||
|
"deployment_id": (
|
||||||
|
deployment_id if self._kind == "wrapper_artifact" else None
|
||||||
|
),
|
||||||
|
},
|
||||||
|
)
|
||||||
if wire["outcome"] not in self.outcomes:
|
if wire["outcome"] not in self.outcomes:
|
||||||
raise InvalidResponse(
|
raise InvalidResponse(
|
||||||
operation="workflow.capabilities.call",
|
operation="workflow.capabilities.call",
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ from wf_api.models import (
|
|||||||
RawWorkflowPlan,
|
RawWorkflowPlan,
|
||||||
RunResult,
|
RunResult,
|
||||||
RunTraceResult,
|
RunTraceResult,
|
||||||
|
SaveArtifactResult,
|
||||||
|
SaveDeploymentResult,
|
||||||
ValidateArtifactPlanResult,
|
ValidateArtifactPlanResult,
|
||||||
ValidateDeploymentResult,
|
ValidateDeploymentResult,
|
||||||
WorkflowArtifactPayload,
|
WorkflowArtifactPayload,
|
||||||
@@ -121,6 +123,24 @@ def decode_validate_artifact_plan(payload: object) -> ValidateArtifactPlanResult
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def decode_save_artifact(payload: object) -> SaveArtifactResult:
|
||||||
|
"""Validate an artifact creation acknowledgement."""
|
||||||
|
return _validate(
|
||||||
|
payload,
|
||||||
|
SaveArtifactResult,
|
||||||
|
"workflow.artifacts.create_from_plan",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def decode_save_deployment(payload: object) -> SaveDeploymentResult:
|
||||||
|
"""Validate a deployment save acknowledgement."""
|
||||||
|
return _validate(
|
||||||
|
payload,
|
||||||
|
SaveDeploymentResult,
|
||||||
|
"workflow.deployments.save",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
_ModelT = TypeVar("_ModelT", bound=BaseModel)
|
_ModelT = TypeVar("_ModelT", bound=BaseModel)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -51,22 +51,64 @@ class DeploymentValidation:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
@dataclass(frozen=True, slots=True, init=False)
|
||||||
class Deployment:
|
class Deployment:
|
||||||
"""Immutable snapshot of one configured artifact deployment."""
|
"""Immutable snapshot of one configured artifact deployment."""
|
||||||
|
|
||||||
_port: WorkflowClientPort = field(repr=False, compare=False)
|
_port: WorkflowClientPort = field(repr=False, compare=False)
|
||||||
model: WorkflowDeployment
|
_model: WorkflowDeployment = field(repr=False)
|
||||||
diagnostics: tuple[DependencyDiagnostic, ...] = ()
|
_diagnostics: tuple[DependencyDiagnostic, ...] = field(repr=False)
|
||||||
runnable: bool | None = None
|
runnable: bool | None = None
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
_port: WorkflowClientPort,
|
||||||
|
model: WorkflowDeployment,
|
||||||
|
diagnostics: tuple[DependencyDiagnostic, ...] = (),
|
||||||
|
runnable: bool | None = None,
|
||||||
|
) -> None:
|
||||||
|
# Pydantic models remain mutable even inside a frozen dataclass. Keep
|
||||||
|
# private copies so public inspection cannot retarget later calls.
|
||||||
|
object.__setattr__(self, "_port", _port)
|
||||||
|
object.__setattr__(self, "_model", model.model_copy(deep=True))
|
||||||
|
object.__setattr__(
|
||||||
|
self,
|
||||||
|
"_diagnostics",
|
||||||
|
tuple(item.model_copy(deep=True) for item in diagnostics),
|
||||||
|
)
|
||||||
|
object.__setattr__(self, "runnable", runnable)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_payload(cls, port: WorkflowClientPort, payload: object) -> Deployment:
|
def from_payload(cls, port: WorkflowClientPort, payload: object) -> Deployment:
|
||||||
return cls(_port=port, model=decode_deployment(payload))
|
return cls(_port=port, model=decode_deployment(payload))
|
||||||
|
|
||||||
|
def with_validation(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
diagnostics: tuple[DependencyDiagnostic, ...],
|
||||||
|
runnable: bool,
|
||||||
|
) -> Deployment:
|
||||||
|
"""Return a new snapshot enriched with one validation result."""
|
||||||
|
return type(self)(
|
||||||
|
_port=self._port,
|
||||||
|
model=self._model,
|
||||||
|
diagnostics=diagnostics,
|
||||||
|
runnable=runnable,
|
||||||
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def model(self) -> WorkflowDeployment:
|
||||||
|
"""Return a defensive copy of the deployment domain model."""
|
||||||
|
return self._model.model_copy(deep=True)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def diagnostics(self) -> tuple[DependencyDiagnostic, ...]:
|
||||||
|
"""Return defensive copies of loaded dependency diagnostics."""
|
||||||
|
return tuple(item.model_copy(deep=True) for item in self._diagnostics)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def deployment_id(self) -> str:
|
def deployment_id(self) -> str:
|
||||||
return self.model.id
|
return self._model.id
|
||||||
|
|
||||||
def __repr__(self) -> str:
|
def __repr__(self) -> str:
|
||||||
return short_repr(
|
return short_repr(
|
||||||
@@ -89,19 +131,19 @@ class Deployment:
|
|||||||
|
|
||||||
@property
|
@property
|
||||||
def artifact_id(self) -> str:
|
def artifact_id(self) -> str:
|
||||||
return self.model.artifact_id
|
return self._model.artifact_id
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def artifact_version(self) -> int:
|
def artifact_version(self) -> int:
|
||||||
return self.model.artifact_version
|
return self._model.artifact_version
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def bindings(self) -> dict[str, str]:
|
def bindings(self) -> dict[str, str]:
|
||||||
return self.model.binding_map()
|
return self._model.binding_map()
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def drift_policy(self) -> DriftPolicy:
|
def drift_policy(self) -> DriftPolicy:
|
||||||
return self.model.drift_policy
|
return self._model.drift_policy
|
||||||
|
|
||||||
async def validate(self) -> DeploymentValidation:
|
async def validate(self) -> DeploymentValidation:
|
||||||
payload = await self._port.validate_deployment(
|
payload = await self._port.validate_deployment(
|
||||||
@@ -171,7 +213,10 @@ class Deployment:
|
|||||||
if decoded.run_id is None or decoded.status in {"unrunnable", "rejected"}:
|
if decoded.run_id is None or decoded.status in {"unrunnable", "rejected"}:
|
||||||
raise DeploymentNotRunnable(
|
raise DeploymentNotRunnable(
|
||||||
deployment_id=self.deployment_id,
|
deployment_id=self.deployment_id,
|
||||||
diagnostics=decoded.diagnostics,
|
diagnostics=tuple(
|
||||||
|
diagnostic.model_copy(deep=True)
|
||||||
|
for diagnostic in decoded.diagnostics
|
||||||
|
),
|
||||||
outcome=decoded.outcome,
|
outcome=decoded.outcome,
|
||||||
error=decoded.error,
|
error=decoded.error,
|
||||||
)
|
)
|
||||||
@@ -194,7 +239,7 @@ async def run_artifact(
|
|||||||
*,
|
*,
|
||||||
deployment_id: str | None,
|
deployment_id: str | None,
|
||||||
bindings: Mapping[str, str] | None,
|
bindings: Mapping[str, str] | None,
|
||||||
drift_policy: DriftPolicy | str,
|
drift_policy: DriftPolicy,
|
||||||
) -> Run:
|
) -> Run:
|
||||||
"""Apply the artifact's strict deployment-selection policy."""
|
"""Apply the artifact's strict deployment-selection policy."""
|
||||||
if deployment_id is not None:
|
if deployment_id is not None:
|
||||||
@@ -211,14 +256,14 @@ async def run_artifact(
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
if (
|
if (
|
||||||
deployment.artifact_id != artifact.artifact.id
|
deployment.artifact_id != artifact.ref.artifact_id
|
||||||
or deployment.artifact_version != artifact.artifact.version
|
or deployment.artifact_version != artifact.ref.version
|
||||||
):
|
):
|
||||||
raise InvalidResponse(
|
raise InvalidResponse(
|
||||||
operation="workflow.deployments.inspect",
|
operation="workflow.deployments.inspect",
|
||||||
details=(
|
details=(
|
||||||
f"deployment {deployment_id!r} does not target artifact "
|
f"deployment {deployment_id!r} does not target artifact "
|
||||||
f"{artifact.artifact.id!r} version {artifact.artifact.version}"
|
f"{artifact.ref.artifact_id!r} version {artifact.ref.version}"
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
return await deployment.run(workflow_input)
|
return await deployment.run(workflow_input)
|
||||||
@@ -228,8 +273,8 @@ async def run_artifact(
|
|||||||
(
|
(
|
||||||
summary
|
summary
|
||||||
for summary in summaries
|
for summary in summaries
|
||||||
if summary["artifact_id"] == artifact.artifact.id
|
if summary["artifact_id"] == artifact.ref.artifact_id
|
||||||
and summary["artifact_version"] == artifact.artifact.version
|
and summary["artifact_version"] == artifact.ref.version
|
||||||
),
|
),
|
||||||
key=lambda summary: summary["id"],
|
key=lambda summary: summary["id"],
|
||||||
)
|
)
|
||||||
@@ -237,7 +282,7 @@ async def run_artifact(
|
|||||||
raise DeploymentRequired(
|
raise DeploymentRequired(
|
||||||
candidate_deployment_ids=tuple(summary["id"] for summary in matches)
|
candidate_deployment_ids=tuple(summary["id"] for summary in matches)
|
||||||
)
|
)
|
||||||
default_id = f"{artifact.artifact.id}.v{artifact.artifact.version}.default"
|
default_id = f"{artifact.ref.artifact_id}.v{artifact.ref.version}.default"
|
||||||
if not matches:
|
if not matches:
|
||||||
conflicting = next(
|
conflicting = next(
|
||||||
(
|
(
|
||||||
@@ -245,8 +290,8 @@ async def run_artifact(
|
|||||||
for summary in summaries
|
for summary in summaries
|
||||||
if summary["id"] == default_id
|
if summary["id"] == default_id
|
||||||
and (
|
and (
|
||||||
summary["artifact_id"] != artifact.artifact.id
|
summary["artifact_id"] != artifact.ref.artifact_id
|
||||||
or summary["artifact_version"] != artifact.artifact.version
|
or summary["artifact_version"] != artifact.ref.version
|
||||||
)
|
)
|
||||||
),
|
),
|
||||||
None,
|
None,
|
||||||
@@ -275,14 +320,14 @@ async def run_artifact(
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
if (
|
if (
|
||||||
deployment.artifact_id != artifact.artifact.id
|
deployment.artifact_id != artifact.ref.artifact_id
|
||||||
or deployment.artifact_version != artifact.artifact.version
|
or deployment.artifact_version != artifact.ref.version
|
||||||
):
|
):
|
||||||
raise InvalidResponse(
|
raise InvalidResponse(
|
||||||
operation="workflow.deployments.inspect",
|
operation="workflow.deployments.inspect",
|
||||||
details=(
|
details=(
|
||||||
f"deployment {matches[0]['id']!r} does not target artifact "
|
f"deployment {matches[0]['id']!r} does not target artifact "
|
||||||
f"{artifact.artifact.id!r} version {artifact.artifact.version}"
|
f"{artifact.ref.artifact_id!r} version {artifact.ref.version}"
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
return await deployment.run(workflow_input)
|
return await deployment.run(workflow_input)
|
||||||
|
|||||||
+22
-1
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from copy import deepcopy
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
|
||||||
from wf_artifacts import DependencyDiagnostic
|
from wf_artifacts import DependencyDiagnostic
|
||||||
@@ -16,7 +17,27 @@ class TransportError(WorkflowClientError):
|
|||||||
|
|
||||||
|
|
||||||
class ProtocolError(WorkflowClientError):
|
class ProtocolError(WorkflowClientError):
|
||||||
"""The service returned a response that violates its protocol contract."""
|
"""An inspectable JSON-RPC error not covered by a stable public subclass."""
|
||||||
|
|
||||||
|
code: int | str | None
|
||||||
|
message: str
|
||||||
|
data: object
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
code: int | str | None,
|
||||||
|
message: str,
|
||||||
|
data: object = None,
|
||||||
|
) -> None:
|
||||||
|
self.code = code
|
||||||
|
self.message = message
|
||||||
|
self.data = deepcopy(data)
|
||||||
|
super().__init__(str(self))
|
||||||
|
|
||||||
|
def __str__(self) -> str:
|
||||||
|
if isinstance(self.data, dict) and isinstance(self.data.get("message"), str):
|
||||||
|
return f"{self.message}: {self.data['message']}"
|
||||||
|
return self.message
|
||||||
|
|
||||||
|
|
||||||
@dataclass(slots=True)
|
@dataclass(slots=True)
|
||||||
|
|||||||
@@ -8,10 +8,8 @@ from typing import Any, Literal, Protocol
|
|||||||
from wf_api.models import (
|
from wf_api.models import (
|
||||||
CapabilityCallResult,
|
CapabilityCallResult,
|
||||||
InspectCapabilityResult,
|
InspectCapabilityResult,
|
||||||
ListArtifactsResult,
|
|
||||||
ListCapabilitiesResult,
|
ListCapabilitiesResult,
|
||||||
ListDeploymentsResult,
|
ListDeploymentsResult,
|
||||||
ListRunsResult,
|
|
||||||
RunResult,
|
RunResult,
|
||||||
RunTraceResult,
|
RunTraceResult,
|
||||||
SaveArtifactResult,
|
SaveArtifactResult,
|
||||||
@@ -55,15 +53,6 @@ class WorkflowClientPort(Protocol):
|
|||||||
deployment_id: str | None = None,
|
deployment_id: str | None = None,
|
||||||
) -> CapabilityCallResult: ...
|
) -> CapabilityCallResult: ...
|
||||||
|
|
||||||
async def list_artifacts(
|
|
||||||
self,
|
|
||||||
*,
|
|
||||||
query: str | None = None,
|
|
||||||
kind: Literal["workflow", "wrapper"] | None = None,
|
|
||||||
cursor: str | None = None,
|
|
||||||
limit: int = 50,
|
|
||||||
) -> ListArtifactsResult: ...
|
|
||||||
|
|
||||||
async def inspect_artifact(
|
async def inspect_artifact(
|
||||||
self,
|
self,
|
||||||
*,
|
*,
|
||||||
@@ -71,8 +60,6 @@ class WorkflowClientPort(Protocol):
|
|||||||
version: int,
|
version: int,
|
||||||
) -> WorkflowArtifactPayload: ...
|
) -> WorkflowArtifactPayload: ...
|
||||||
|
|
||||||
async def save_artifact(self, artifact: dict[str, Any]) -> SaveArtifactResult: ...
|
|
||||||
|
|
||||||
async def create_artifact_from_plan(
|
async def create_artifact_from_plan(
|
||||||
self,
|
self,
|
||||||
*,
|
*,
|
||||||
@@ -117,14 +104,6 @@ class WorkflowClientPort(Protocol):
|
|||||||
live_check: bool = False,
|
live_check: bool = False,
|
||||||
) -> ValidateDeploymentResult: ...
|
) -> ValidateDeploymentResult: ...
|
||||||
|
|
||||||
async def list_runs(
|
|
||||||
self,
|
|
||||||
*,
|
|
||||||
status: str | None = None,
|
|
||||||
cursor: str | None = None,
|
|
||||||
limit: int = 50,
|
|
||||||
) -> ListRunsResult: ...
|
|
||||||
|
|
||||||
async def run_deployment(
|
async def run_deployment(
|
||||||
self,
|
self,
|
||||||
*,
|
*,
|
||||||
|
|||||||
+85
-17
@@ -3,6 +3,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from collections.abc import Mapping
|
from collections.abc import Mapping
|
||||||
|
from copy import deepcopy
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
@@ -12,6 +13,7 @@ from wf_api import TraceRange
|
|||||||
from wf_artifacts import DependencyDiagnostic
|
from wf_artifacts import DependencyDiagnostic
|
||||||
from wf_core import InterruptRequest, InterruptRoute, TraceEntry, WorkflowRef
|
from wf_core import InterruptRequest, InterruptRoute, TraceEntry, WorkflowRef
|
||||||
|
|
||||||
|
from ._identity import require_response_identity
|
||||||
from ._repr import html_repr, short_repr
|
from ._repr import html_repr, short_repr
|
||||||
from .codec import DecodedRunResult, decode_run_result, decode_trace_result
|
from .codec import DecodedRunResult, decode_run_result, decode_trace_result
|
||||||
from .errors import DeploymentNotRunnable, InvalidResponse
|
from .errors import DeploymentNotRunnable, InvalidResponse
|
||||||
@@ -85,15 +87,20 @@ def _run_from_decoded(
|
|||||||
decoded: DecodedRunResult,
|
decoded: DecodedRunResult,
|
||||||
*,
|
*,
|
||||||
expected_run_id: str | None = None,
|
expected_run_id: str | None = None,
|
||||||
|
expected_deployment_id: str | None = None,
|
||||||
operation: str = "workflow.runs.inspect",
|
operation: str = "workflow.runs.inspect",
|
||||||
) -> Run:
|
) -> Run:
|
||||||
if expected_run_id is not None and decoded.run_id != expected_run_id:
|
if expected_run_id is not None and decoded.run_id != expected_run_id:
|
||||||
raise InvalidResponse(
|
require_response_identity(
|
||||||
operation=operation,
|
operation=operation,
|
||||||
details=(
|
actual={"run_id": decoded.run_id},
|
||||||
f"returned run {decoded.run_id!r} does not match requested "
|
expected={"run_id": expected_run_id},
|
||||||
f"{expected_run_id!r}"
|
)
|
||||||
),
|
if expected_deployment_id is not None:
|
||||||
|
require_response_identity(
|
||||||
|
operation=operation,
|
||||||
|
actual={"deployment_id": decoded.deployment_id},
|
||||||
|
expected={"deployment_id": expected_deployment_id},
|
||||||
)
|
)
|
||||||
if decoded.run_id is None:
|
if decoded.run_id is None:
|
||||||
raise DeploymentNotRunnable(
|
raise DeploymentNotRunnable(
|
||||||
@@ -115,7 +122,7 @@ def _run_from_decoded(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
@dataclass(frozen=True, slots=True, init=False)
|
||||||
class Run:
|
class Run:
|
||||||
"""Immutable client snapshot of one durable deployment run."""
|
"""Immutable client snapshot of one durable deployment run."""
|
||||||
|
|
||||||
@@ -124,11 +131,53 @@ class Run:
|
|||||||
deployment_id: str
|
deployment_id: str
|
||||||
status: str
|
status: str
|
||||||
outcome: str | None
|
outcome: str | None
|
||||||
output: dict[str, Any] | None
|
_output: dict[str, Any] | None = field(repr=False)
|
||||||
interrupt: InterruptRequest | None
|
_interrupt: InterruptRequest | None = field(repr=False)
|
||||||
diagnostics: tuple[DependencyDiagnostic, ...]
|
_diagnostics: tuple[DependencyDiagnostic, ...] = field(repr=False)
|
||||||
trace_count: int
|
trace_count: int
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
_port: WorkflowClientPort,
|
||||||
|
run_id: str,
|
||||||
|
deployment_id: str,
|
||||||
|
status: str,
|
||||||
|
outcome: str | None,
|
||||||
|
output: dict[str, Any] | None,
|
||||||
|
interrupt: InterruptRequest | None,
|
||||||
|
diagnostics: tuple[DependencyDiagnostic, ...],
|
||||||
|
trace_count: int,
|
||||||
|
) -> None:
|
||||||
|
object.__setattr__(self, "_port", _port)
|
||||||
|
object.__setattr__(self, "run_id", run_id)
|
||||||
|
object.__setattr__(self, "deployment_id", deployment_id)
|
||||||
|
object.__setattr__(self, "status", status)
|
||||||
|
object.__setattr__(self, "outcome", outcome)
|
||||||
|
object.__setattr__(self, "_output", deepcopy(output))
|
||||||
|
object.__setattr__(self, "_interrupt", deepcopy(interrupt))
|
||||||
|
object.__setattr__(
|
||||||
|
self,
|
||||||
|
"_diagnostics",
|
||||||
|
tuple(item.model_copy(deep=True) for item in diagnostics),
|
||||||
|
)
|
||||||
|
object.__setattr__(self, "trace_count", trace_count)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def output(self) -> dict[str, Any] | None:
|
||||||
|
"""Return a defensive copy of the already-loaded workflow output."""
|
||||||
|
return deepcopy(self._output)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def interrupt(self) -> InterruptRequest | None:
|
||||||
|
"""Return a defensive copy of the already-loaded interrupt contract."""
|
||||||
|
return deepcopy(self._interrupt)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def diagnostics(self) -> tuple[DependencyDiagnostic, ...]:
|
||||||
|
"""Return defensive copies of loaded dependency diagnostics."""
|
||||||
|
return tuple(item.model_copy(deep=True) for item in self._diagnostics)
|
||||||
|
|
||||||
def __repr__(self) -> str:
|
def __repr__(self) -> str:
|
||||||
return short_repr(
|
return short_repr(
|
||||||
type(self).__name__,
|
type(self).__name__,
|
||||||
@@ -136,8 +185,8 @@ class Run:
|
|||||||
deployment_id=self.deployment_id,
|
deployment_id=self.deployment_id,
|
||||||
status=self.status,
|
status=self.status,
|
||||||
outcome=self.outcome,
|
outcome=self.outcome,
|
||||||
output=self.output,
|
output=self._output,
|
||||||
diagnostics=f"{len(self.diagnostics)} diagnostics",
|
diagnostics=f"{len(self._diagnostics)} diagnostics",
|
||||||
trace=f"{self.trace_count} frames",
|
trace=f"{self.trace_count} frames",
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -148,8 +197,8 @@ class Run:
|
|||||||
deployment_id=self.deployment_id,
|
deployment_id=self.deployment_id,
|
||||||
status=self.status,
|
status=self.status,
|
||||||
outcome=self.outcome,
|
outcome=self.outcome,
|
||||||
output=self.output,
|
output=self._output,
|
||||||
diagnostics=f"{len(self.diagnostics)} diagnostics",
|
diagnostics=f"{len(self._diagnostics)} diagnostics",
|
||||||
trace=f"{self.trace_count} frames (use trace() for a bounded page)",
|
trace=f"{self.trace_count} frames (use trace() for a bounded page)",
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -160,6 +209,7 @@ class Run:
|
|||||||
payload: object,
|
payload: object,
|
||||||
*,
|
*,
|
||||||
expected_run_id: str | None = None,
|
expected_run_id: str | None = None,
|
||||||
|
expected_deployment_id: str | None = None,
|
||||||
operation: str = "workflow.runs.inspect",
|
operation: str = "workflow.runs.inspect",
|
||||||
) -> Run:
|
) -> Run:
|
||||||
"""Validate one run response and reconstruct its immutable snapshot."""
|
"""Validate one run response and reconstruct its immutable snapshot."""
|
||||||
@@ -167,6 +217,7 @@ class Run:
|
|||||||
port,
|
port,
|
||||||
decode_run_result(payload, operation=operation),
|
decode_run_result(payload, operation=operation),
|
||||||
expected_run_id=expected_run_id,
|
expected_run_id=expected_run_id,
|
||||||
|
expected_deployment_id=expected_deployment_id,
|
||||||
operation=operation,
|
operation=operation,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -176,6 +227,7 @@ class Run:
|
|||||||
self._port,
|
self._port,
|
||||||
await self._port.inspect_run(run_id=self.run_id),
|
await self._port.inspect_run(run_id=self.run_id),
|
||||||
expected_run_id=self.run_id,
|
expected_run_id=self.run_id,
|
||||||
|
expected_deployment_id=self.deployment_id,
|
||||||
operation="workflow.runs.inspect",
|
operation="workflow.runs.inspect",
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -186,9 +238,9 @@ class Run:
|
|||||||
outcome: str = "submitted",
|
outcome: str = "submitted",
|
||||||
) -> Run:
|
) -> Run:
|
||||||
"""Resume an interrupted run and return the server's new snapshot."""
|
"""Resume an interrupted run and return the server's new snapshot."""
|
||||||
if self.status != "interrupted" or self.interrupt is None:
|
if self.status != "interrupted" or self._interrupt is None:
|
||||||
raise ValueError("only interrupted runs can be resumed")
|
raise ValueError("only interrupted runs can be resumed")
|
||||||
if not self.interrupt.resumable:
|
if not self._interrupt.resumable:
|
||||||
raise ValueError("run interrupt is not resumable")
|
raise ValueError("run interrupt is not resumable")
|
||||||
return self.from_payload(
|
return self.from_payload(
|
||||||
self._port,
|
self._port,
|
||||||
@@ -198,6 +250,7 @@ class Run:
|
|||||||
resume_outcome=outcome,
|
resume_outcome=outcome,
|
||||||
),
|
),
|
||||||
expected_run_id=self.run_id,
|
expected_run_id=self.run_id,
|
||||||
|
expected_deployment_id=self.deployment_id,
|
||||||
operation="workflow.runs.resume",
|
operation="workflow.runs.resume",
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -213,10 +266,25 @@ class Run:
|
|||||||
trace_range=TraceRange(start=start, limit=limit),
|
trace_range=TraceRange(start=start, limit=limit),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
require_response_identity(
|
||||||
|
operation="workflow.runs.trace",
|
||||||
|
actual={
|
||||||
|
"run_id": decoded.run_id,
|
||||||
|
"deployment_id": decoded.deployment_id,
|
||||||
|
"trace_start": decoded.trace_start,
|
||||||
|
"trace_limit": decoded.trace_limit,
|
||||||
|
},
|
||||||
|
expected={
|
||||||
|
"run_id": self.run_id,
|
||||||
|
"deployment_id": self.deployment_id,
|
||||||
|
"trace_start": start,
|
||||||
|
"trace_limit": limit,
|
||||||
|
},
|
||||||
|
)
|
||||||
frames = tuple(TraceEntry(**dict(frame)) for frame in (decoded.trace or ()))
|
frames = tuple(TraceEntry(**dict(frame)) for frame in (decoded.trace or ()))
|
||||||
return TracePage(
|
return TracePage(
|
||||||
start=decoded.trace_start if decoded.trace_start is not None else start,
|
start=start,
|
||||||
limit=decoded.trace_limit if decoded.trace_limit is not None else limit,
|
limit=limit,
|
||||||
frames=frames,
|
frames=frames,
|
||||||
truncated=bool(decoded.trace_truncated),
|
truncated=bool(decoded.trace_truncated),
|
||||||
trace_count=decoded.trace_count,
|
trace_count=decoded.trace_count,
|
||||||
|
|||||||
+59
-32
@@ -3,10 +3,11 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from collections.abc import Mapping
|
from collections.abc import Mapping
|
||||||
from dataclasses import dataclass, field, replace
|
from dataclasses import dataclass, field
|
||||||
from typing import TYPE_CHECKING, Any, Literal
|
from typing import TYPE_CHECKING, Any, Literal
|
||||||
|
|
||||||
from wf_artifacts.models import (
|
from wf_artifacts.models import (
|
||||||
|
DriftPolicy,
|
||||||
RequiredCapability,
|
RequiredCapability,
|
||||||
)
|
)
|
||||||
from wf_artifacts.models import (
|
from wf_artifacts.models import (
|
||||||
@@ -14,7 +15,9 @@ from wf_artifacts.models import (
|
|||||||
)
|
)
|
||||||
from wf_core import ValidationReport, Workflow
|
from wf_core import ValidationReport, Workflow
|
||||||
|
|
||||||
|
from ._identity import require_response_identity
|
||||||
from ._repr import html_repr, short_repr
|
from ._repr import html_repr, short_repr
|
||||||
|
from .codec import decode_save_deployment
|
||||||
from .errors import InvalidResponse, ValidationFailed
|
from .errors import InvalidResponse, ValidationFailed
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
@@ -119,17 +122,39 @@ class WorkflowValidation:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
@dataclass(frozen=True, slots=True, init=False)
|
||||||
class WorkflowArtifact:
|
class WorkflowArtifact:
|
||||||
"""Immutable client snapshot retaining the validated artifact and workflow."""
|
"""Immutable client snapshot retaining the validated artifact and workflow."""
|
||||||
|
|
||||||
_port: WorkflowClientPort = field(repr=False, compare=False)
|
_port: WorkflowClientPort = field(repr=False, compare=False)
|
||||||
artifact: ArtifactDomainModel
|
_artifact: ArtifactDomainModel = field(repr=False)
|
||||||
workflow: Workflow
|
_workflow: Workflow = field(repr=False)
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
port: WorkflowClientPort,
|
||||||
|
artifact: ArtifactDomainModel,
|
||||||
|
workflow: Workflow,
|
||||||
|
) -> None:
|
||||||
|
# Frozen dataclasses do not recursively freeze Pydantic models. Retain
|
||||||
|
# private deep copies and expose only defensive projections below.
|
||||||
|
object.__setattr__(self, "_port", port)
|
||||||
|
object.__setattr__(self, "_artifact", artifact.model_copy(deep=True))
|
||||||
|
object.__setattr__(self, "_workflow", workflow.model_copy(deep=True))
|
||||||
|
|
||||||
|
@property
|
||||||
|
def artifact(self) -> ArtifactDomainModel:
|
||||||
|
"""Return a defensive copy of the validated artifact envelope."""
|
||||||
|
return self._artifact.model_copy(deep=True)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def workflow(self) -> Workflow:
|
||||||
|
"""Return a defensive copy of the executable workflow."""
|
||||||
|
return self._workflow.model_copy(deep=True)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def ref(self) -> ArtifactRef:
|
def ref(self) -> ArtifactRef:
|
||||||
return ArtifactRef(self.artifact.id, self.artifact.version)
|
return ArtifactRef(self._artifact.id, self._artifact.version)
|
||||||
|
|
||||||
def __repr__(self) -> str:
|
def __repr__(self) -> str:
|
||||||
return short_repr(
|
return short_repr(
|
||||||
@@ -150,28 +175,28 @@ class WorkflowArtifact:
|
|||||||
|
|
||||||
@property
|
@property
|
||||||
def title(self) -> str:
|
def title(self) -> str:
|
||||||
return self.artifact.title
|
return self._artifact.title
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def description(self) -> str | None:
|
def description(self) -> str | None:
|
||||||
return self.artifact.description
|
return self._artifact.description
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def required_capabilities(self) -> tuple[RequiredCapability, ...]:
|
def required_capabilities(self) -> tuple[RequiredCapability, ...]:
|
||||||
return tuple(
|
return tuple(
|
||||||
capability
|
capability.model_copy(deep=True)
|
||||||
if isinstance(capability, RequiredCapability)
|
if isinstance(capability, RequiredCapability)
|
||||||
else RequiredCapability.model_validate(capability)
|
else RequiredCapability.model_validate(capability)
|
||||||
for capability in self.artifact.required_capabilities
|
for capability in self._artifact.required_capabilities
|
||||||
)
|
)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def workflow_dependencies(self) -> dict[str, int]:
|
def workflow_dependencies(self) -> dict[str, int]:
|
||||||
return dict(self.artifact.workflow_dependencies)
|
return dict(self._artifact.workflow_dependencies)
|
||||||
|
|
||||||
def inspect(self) -> Workflow:
|
def inspect(self) -> Workflow:
|
||||||
"""Return a deep copy so inspecting an artifact cannot mutate its snapshot."""
|
"""Return a deep copy so inspecting an artifact cannot mutate its snapshot."""
|
||||||
return self.workflow.model_copy(deep=True)
|
return self._workflow.model_copy(deep=True)
|
||||||
|
|
||||||
def edit(self) -> EditableWorkflow:
|
def edit(self) -> EditableWorkflow:
|
||||||
"""Seed an editable builder from this exact immutable artifact version."""
|
"""Seed an editable builder from this exact immutable artifact version."""
|
||||||
@@ -185,33 +210,36 @@ class WorkflowArtifact:
|
|||||||
deployment_id: str,
|
deployment_id: str,
|
||||||
*,
|
*,
|
||||||
bindings: Mapping[str, str] | None = None,
|
bindings: Mapping[str, str] | None = None,
|
||||||
drift_policy: str = "block",
|
drift_policy: DriftPolicy = DriftPolicy.BLOCK,
|
||||||
) -> Deployment:
|
) -> Deployment:
|
||||||
"""Save, inspect, and validate a deployment for this artifact version."""
|
"""Save, inspect, and validate a deployment for this artifact version."""
|
||||||
from .deployments import Deployment
|
from .deployments import Deployment
|
||||||
|
|
||||||
saved = await self._port.save_deployment(
|
saved = decode_save_deployment(
|
||||||
|
await self._port.save_deployment(
|
||||||
{
|
{
|
||||||
"id": deployment_id,
|
"id": deployment_id,
|
||||||
"artifact_id": self.artifact.id,
|
"artifact_id": self._artifact.id,
|
||||||
"artifact_version": self.artifact.version,
|
"artifact_version": self._artifact.version,
|
||||||
"bindings": dict(bindings or {}),
|
"bindings": dict(bindings or {}),
|
||||||
"drift_policy": drift_policy,
|
"drift_policy": drift_policy,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
if (
|
|
||||||
not isinstance(saved, Mapping)
|
|
||||||
or saved.get("deployment_id") != deployment_id
|
|
||||||
):
|
|
||||||
saved_id = (
|
|
||||||
saved.get("deployment_id") if isinstance(saved, Mapping) else None
|
|
||||||
)
|
)
|
||||||
raise InvalidResponse(
|
require_response_identity(
|
||||||
operation="workflow.deployments.save",
|
operation="workflow.deployments.save",
|
||||||
details=(
|
actual={
|
||||||
f"saved deployment id {saved_id!r} does not match requested "
|
"deployment_id": saved["deployment_id"],
|
||||||
f"{deployment_id!r}"
|
"artifact_id": saved["artifact_id"],
|
||||||
),
|
"artifact_version": saved["artifact_version"],
|
||||||
|
"saved": saved["saved"],
|
||||||
|
},
|
||||||
|
expected={
|
||||||
|
"deployment_id": deployment_id,
|
||||||
|
"artifact_id": self._artifact.id,
|
||||||
|
"artifact_version": self._artifact.version,
|
||||||
|
"saved": True,
|
||||||
|
},
|
||||||
)
|
)
|
||||||
deployment = Deployment.from_payload(
|
deployment = Deployment.from_payload(
|
||||||
self._port,
|
self._port,
|
||||||
@@ -226,19 +254,18 @@ class WorkflowArtifact:
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
if (
|
if (
|
||||||
deployment.artifact_id != self.artifact.id
|
deployment.artifact_id != self._artifact.id
|
||||||
or deployment.artifact_version != self.artifact.version
|
or deployment.artifact_version != self._artifact.version
|
||||||
):
|
):
|
||||||
raise InvalidResponse(
|
raise InvalidResponse(
|
||||||
operation="workflow.deployments.inspect",
|
operation="workflow.deployments.inspect",
|
||||||
details=(
|
details=(
|
||||||
f"deployment {deployment_id!r} does not target artifact "
|
f"deployment {deployment_id!r} does not target artifact "
|
||||||
f"{self.artifact.id!r} version {self.artifact.version}"
|
f"{self._artifact.id!r} version {self._artifact.version}"
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
validation = await deployment.validate()
|
validation = await deployment.validate()
|
||||||
return replace(
|
return deployment.with_validation(
|
||||||
deployment,
|
|
||||||
diagnostics=validation.diagnostics,
|
diagnostics=validation.diagnostics,
|
||||||
runnable=validation.runnable,
|
runnable=validation.runnable,
|
||||||
)
|
)
|
||||||
@@ -249,7 +276,7 @@ class WorkflowArtifact:
|
|||||||
*,
|
*,
|
||||||
deployment_id: str | None = None,
|
deployment_id: str | None = None,
|
||||||
bindings: Mapping[str, str] | None = None,
|
bindings: Mapping[str, str] | None = None,
|
||||||
drift_policy: str = "block",
|
drift_policy: DriftPolicy = DriftPolicy.BLOCK,
|
||||||
) -> Run:
|
) -> Run:
|
||||||
"""Run the artifact under the strict deployment selection policy."""
|
"""Run the artifact under the strict deployment selection policy."""
|
||||||
from .deployments import run_artifact
|
from .deployments import run_artifact
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ def __getattr__(name: str) -> object:
|
|||||||
return generate_manifest
|
return generate_manifest
|
||||||
raise AttributeError(name)
|
raise AttributeError(name)
|
||||||
|
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"ContractManifest",
|
"ContractManifest",
|
||||||
"JsonSchema",
|
"JsonSchema",
|
||||||
|
|||||||
@@ -15,7 +15,9 @@ from .model import ManifestError
|
|||||||
|
|
||||||
|
|
||||||
def _parser() -> argparse.ArgumentParser:
|
def _parser() -> argparse.ArgumentParser:
|
||||||
parser = argparse.ArgumentParser(description="Manage the checked workflow API contract manifest.")
|
parser = argparse.ArgumentParser(
|
||||||
|
description="Manage the checked workflow API contract manifest."
|
||||||
|
)
|
||||||
parser.add_argument("command", choices=("write", "check"))
|
parser.add_argument("command", choices=("write", "check"))
|
||||||
return parser
|
return parser
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ from uuid import uuid4
|
|||||||
import httpx
|
import httpx
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
@dataclass(slots=True)
|
||||||
class RpcProtocolError(RuntimeError):
|
class RpcProtocolError(RuntimeError):
|
||||||
"""Structured JSON-RPC application error returned by a remote endpoint.
|
"""Structured JSON-RPC application error returned by a remote endpoint.
|
||||||
|
|
||||||
@@ -64,6 +64,8 @@ class RpcClientTransport:
|
|||||||
response = await self.http_client.post(self.url, json=request)
|
response = await self.http_client.post(self.url, json=request)
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
payload = response.json()
|
payload = response.json()
|
||||||
|
if not isinstance(payload, dict):
|
||||||
|
raise RuntimeError("JSON-RPC response must be an object")
|
||||||
if "error" in payload:
|
if "error" in payload:
|
||||||
error = payload["error"]
|
error = payload["error"]
|
||||||
if not isinstance(error, dict):
|
if not isinstance(error, dict):
|
||||||
|
|||||||
@@ -256,9 +256,7 @@ async def test_lda_report_workflow_artifact_interrupt_resume_path(
|
|||||||
"approved",
|
"approved",
|
||||||
"selected_issue_ids",
|
"selected_issue_ids",
|
||||||
}
|
}
|
||||||
proposed_ids = [
|
proposed_ids = [issue["id"] for issue in interrupt["payload"]["proposed_issues"]]
|
||||||
issue["id"] for issue in interrupt["payload"]["proposed_issues"]
|
|
||||||
]
|
|
||||||
assert proposed_ids
|
assert proposed_ids
|
||||||
started_run_id = started["run_id"]
|
started_run_id = started["run_id"]
|
||||||
assert isinstance(started_run_id, str)
|
assert isinstance(started_run_id, str)
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ def _capability_api(
|
|||||||
)
|
)
|
||||||
service.register_specs("demo.personal", failing_tool)
|
service.register_specs("demo.personal", failing_tool)
|
||||||
context = context_from_service(service)
|
context = context_from_service(service)
|
||||||
return WorkflowCapabilityApi(context), service
|
return WorkflowCapabilityApi(context, drafts=True), service
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@@ -226,6 +226,7 @@ async def test_create_draft_workspace_from_capability(tmp_path: Path) -> None:
|
|||||||
assert "next_actions" in result
|
assert "next_actions" in result
|
||||||
assert result["wrapper_hints"]["capability_name"] == "demo.personal.echo_tool"
|
assert result["wrapper_hints"]["capability_name"] == "demo.personal.echo_tool"
|
||||||
|
|
||||||
|
assert api.drafts is not None
|
||||||
fetched = await api.drafts.get_draft_workspace(
|
fetched = await api.drafts.get_draft_workspace(
|
||||||
workspace_id="echo_ws", include_draft=True
|
workspace_id="echo_ws", include_draft=True
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -42,12 +42,13 @@ def _composite_concat_draft() -> dict[str, object]:
|
|||||||
async def test_composite_concat_runs_through_the_platform_registry(
|
async def test_composite_concat_runs_through_the_platform_registry(
|
||||||
tmp_path: Path,
|
tmp_path: Path,
|
||||||
) -> None:
|
) -> None:
|
||||||
server = build_local_static_workflow_server(tmp_path / "store")
|
server = build_local_static_workflow_server(tmp_path / "store", drafts=True)
|
||||||
await server.api.create_draft_workspace(
|
await server.api.create_draft_workspace(
|
||||||
workspace_id="composite_concat",
|
workspace_id="composite_concat",
|
||||||
draft=_composite_concat_draft(),
|
draft=_composite_concat_draft(),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
assert server.api.draft_authoring is not None
|
||||||
authored = await server.api.draft_authoring.set_step_input_bindings(
|
authored = await server.api.draft_authoring.set_step_input_bindings(
|
||||||
workspace_id="composite_concat",
|
workspace_id="composite_concat",
|
||||||
revision=1,
|
revision=1,
|
||||||
|
|||||||
@@ -171,7 +171,7 @@ async def test_inspect_draft_authoring_contract_projects_selected_capability(
|
|||||||
"properties": {"echoed": {"type": "string"}},
|
"properties": {"echoed": {"type": "string"}},
|
||||||
}
|
}
|
||||||
await draft_api.create_draft_workspace(workspace_id="authoring", draft=draft)
|
await draft_api.create_draft_workspace(workspace_id="authoring", draft=draft)
|
||||||
api = WorkflowApi(authoring.context)
|
api = WorkflowApi(authoring.context, drafts=True)
|
||||||
|
|
||||||
inventory = _authoring_inventory(
|
inventory = _authoring_inventory(
|
||||||
await api.inspect_draft_authoring_contract(
|
await api.inspect_draft_authoring_contract(
|
||||||
@@ -219,7 +219,7 @@ async def test_inspect_draft_authoring_contract_tolerates_invalid_workflow_schem
|
|||||||
"properties": {"echoed": {"type": "string"}},
|
"properties": {"echoed": {"type": "string"}},
|
||||||
}
|
}
|
||||||
await draft_api.create_draft_workspace(workspace_id="authoring", draft=draft)
|
await draft_api.create_draft_workspace(workspace_id="authoring", draft=draft)
|
||||||
api = WorkflowApi(authoring.context)
|
api = WorkflowApi(authoring.context, drafts=True)
|
||||||
|
|
||||||
inventory = _authoring_inventory(
|
inventory = _authoring_inventory(
|
||||||
await api.inspect_draft_authoring_contract(
|
await api.inspect_draft_authoring_contract(
|
||||||
@@ -256,7 +256,7 @@ async def test_inspect_draft_authoring_contract_resolves_saved_wrapper_capabilit
|
|||||||
"properties": {"echoed": {"type": "string"}},
|
"properties": {"echoed": {"type": "string"}},
|
||||||
}
|
}
|
||||||
await draft_api.create_draft_workspace(workspace_id="authoring", draft=draft)
|
await draft_api.create_draft_workspace(workspace_id="authoring", draft=draft)
|
||||||
api = WorkflowApi(authoring.context)
|
api = WorkflowApi(authoring.context, drafts=True)
|
||||||
|
|
||||||
inventory = _authoring_inventory(
|
inventory = _authoring_inventory(
|
||||||
await api.inspect_draft_authoring_contract(
|
await api.inspect_draft_authoring_contract(
|
||||||
@@ -296,7 +296,7 @@ async def test_inspect_draft_authoring_contract_preserves_empty_capability_schem
|
|||||||
"properties": {"echoed": {"type": "string"}},
|
"properties": {"echoed": {"type": "string"}},
|
||||||
}
|
}
|
||||||
await draft_api.create_draft_workspace(workspace_id="authoring", draft=draft)
|
await draft_api.create_draft_workspace(workspace_id="authoring", draft=draft)
|
||||||
api = WorkflowApi(authoring.context)
|
api = WorkflowApi(authoring.context, drafts=True)
|
||||||
|
|
||||||
inventory = _authoring_inventory(
|
inventory = _authoring_inventory(
|
||||||
await api.inspect_draft_authoring_contract(
|
await api.inspect_draft_authoring_contract(
|
||||||
@@ -344,7 +344,7 @@ async def test_inspect_draft_authoring_contract_warns_for_invalid_capability_sch
|
|||||||
workspace_id="authoring",
|
workspace_id="authoring",
|
||||||
draft=_echo_draft(),
|
draft=_echo_draft(),
|
||||||
)
|
)
|
||||||
api = WorkflowApi(authoring.context)
|
api = WorkflowApi(authoring.context, drafts=True)
|
||||||
|
|
||||||
inventory = _authoring_inventory(
|
inventory = _authoring_inventory(
|
||||||
await api.inspect_draft_authoring_contract(
|
await api.inspect_draft_authoring_contract(
|
||||||
@@ -375,7 +375,7 @@ async def test_inspect_draft_authoring_contract_rejects_unknown_selected_step(
|
|||||||
await draft_api.create_draft_workspace(
|
await draft_api.create_draft_workspace(
|
||||||
workspace_id="authoring", draft=_echo_draft()
|
workspace_id="authoring", draft=_echo_draft()
|
||||||
)
|
)
|
||||||
api = WorkflowApi(authoring.context)
|
api = WorkflowApi(authoring.context, drafts=True)
|
||||||
|
|
||||||
with pytest.raises(KeyError, match="unknown draft step"):
|
with pytest.raises(KeyError, match="unknown draft step"):
|
||||||
await api.inspect_draft_authoring_contract(
|
await api.inspect_draft_authoring_contract(
|
||||||
@@ -397,7 +397,7 @@ async def test_inspect_draft_authoring_contract_tolerates_invalid_selected_step(
|
|||||||
draft["steps"] = {"broken": {"unknown_kind": {}}}
|
draft["steps"] = {"broken": {"unknown_kind": {}}}
|
||||||
draft["start"] = "broken"
|
draft["start"] = "broken"
|
||||||
await draft_api.create_draft_workspace(workspace_id="authoring", draft=draft)
|
await draft_api.create_draft_workspace(workspace_id="authoring", draft=draft)
|
||||||
api = WorkflowApi(authoring.context)
|
api = WorkflowApi(authoring.context, drafts=True)
|
||||||
|
|
||||||
inventory = _authoring_inventory(
|
inventory = _authoring_inventory(
|
||||||
await api.inspect_draft_authoring_contract(
|
await api.inspect_draft_authoring_contract(
|
||||||
@@ -426,7 +426,7 @@ async def test_inspect_draft_authoring_contract_stale_revision_is_read_only(
|
|||||||
await draft_api.create_draft_workspace(
|
await draft_api.create_draft_workspace(
|
||||||
workspace_id="authoring", draft=_echo_draft()
|
workspace_id="authoring", draft=_echo_draft()
|
||||||
)
|
)
|
||||||
api = WorkflowApi(authoring.context)
|
api = WorkflowApi(authoring.context, drafts=True)
|
||||||
changed = await api.set_draft_name(
|
changed = await api.set_draft_name(
|
||||||
workspace_id="authoring",
|
workspace_id="authoring",
|
||||||
revision=1,
|
revision=1,
|
||||||
@@ -1023,7 +1023,7 @@ async def _create_structured_binding_api(
|
|||||||
workspace_id=workspace_id,
|
workspace_id=workspace_id,
|
||||||
draft=_structured_report_draft(),
|
draft=_structured_report_draft(),
|
||||||
)
|
)
|
||||||
return draft_api, service, WorkflowApi(authoring.context)
|
return draft_api, service, WorkflowApi(authoring.context, drafts=True)
|
||||||
|
|
||||||
|
|
||||||
async def _create_nested_output_binding_api(
|
async def _create_nested_output_binding_api(
|
||||||
@@ -1058,7 +1058,7 @@ async def _create_nested_output_binding_api(
|
|||||||
workspace_id=workspace_id,
|
workspace_id=workspace_id,
|
||||||
draft=_nested_report_draft(),
|
draft=_nested_report_draft(),
|
||||||
)
|
)
|
||||||
return draft_api, service, WorkflowApi(authoring.context)
|
return draft_api, service, WorkflowApi(authoring.context, drafts=True)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@@ -1163,7 +1163,7 @@ async def test_create_empty_draft_workspace_persists_invalid_skeleton(
|
|||||||
) -> None:
|
) -> None:
|
||||||
artifact_store = FileWorkflowArtifactStore(tmp_path / "drafts_create_empty")
|
artifact_store = FileWorkflowArtifactStore(tmp_path / "drafts_create_empty")
|
||||||
_drafts, _service, authoring = _draft_api(artifact_store)
|
_drafts, _service, authoring = _draft_api(artifact_store)
|
||||||
facade = WorkflowApi(authoring.context)
|
facade = WorkflowApi(authoring.context, drafts=True)
|
||||||
|
|
||||||
created = await facade.create_empty_draft_workspace(
|
created = await facade.create_empty_draft_workspace(
|
||||||
workspace_id="control_first",
|
workspace_id="control_first",
|
||||||
@@ -1198,7 +1198,7 @@ async def test_create_empty_draft_workspace_preserves_custom_contract(
|
|||||||
) -> None:
|
) -> None:
|
||||||
artifact_store = FileWorkflowArtifactStore(tmp_path / "drafts_create_contract")
|
artifact_store = FileWorkflowArtifactStore(tmp_path / "drafts_create_contract")
|
||||||
_drafts, _service, authoring = _draft_api(artifact_store)
|
_drafts, _service, authoring = _draft_api(artifact_store)
|
||||||
facade = WorkflowApi(authoring.context)
|
facade = WorkflowApi(authoring.context, drafts=True)
|
||||||
input_schema = {
|
input_schema = {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {"topic": {"type": "string"}},
|
"properties": {"topic": {"type": "string"}},
|
||||||
@@ -1249,7 +1249,7 @@ async def test_create_empty_draft_workspace_isolates_default_schemas(
|
|||||||
) -> None:
|
) -> None:
|
||||||
artifact_store = FileWorkflowArtifactStore(tmp_path / "drafts_schema_isolation")
|
artifact_store = FileWorkflowArtifactStore(tmp_path / "drafts_schema_isolation")
|
||||||
_drafts, _service, authoring = _draft_api(artifact_store)
|
_drafts, _service, authoring = _draft_api(artifact_store)
|
||||||
facade = WorkflowApi(authoring.context)
|
facade = WorkflowApi(authoring.context, drafts=True)
|
||||||
input_schema = {
|
input_schema = {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {"topic": {"type": "string"}},
|
"properties": {"topic": {"type": "string"}},
|
||||||
@@ -1283,7 +1283,7 @@ async def test_create_empty_draft_workspace_reports_duplicate_conflict(
|
|||||||
) -> None:
|
) -> None:
|
||||||
artifact_store = FileWorkflowArtifactStore(tmp_path / "drafts_create_conflict")
|
artifact_store = FileWorkflowArtifactStore(tmp_path / "drafts_create_conflict")
|
||||||
_drafts, _service, authoring = _draft_api(artifact_store)
|
_drafts, _service, authoring = _draft_api(artifact_store)
|
||||||
facade = WorkflowApi(authoring.context)
|
facade = WorkflowApi(authoring.context, drafts=True)
|
||||||
await facade.create_empty_draft_workspace(
|
await facade.create_empty_draft_workspace(
|
||||||
workspace_id="control_first",
|
workspace_id="control_first",
|
||||||
name="control_first",
|
name="control_first",
|
||||||
@@ -1317,7 +1317,7 @@ async def test_create_empty_draft_workspace_rejects_invalid_contract_before_muta
|
|||||||
) -> None:
|
) -> None:
|
||||||
artifact_store = FileWorkflowArtifactStore(tmp_path / "drafts_create_rejected")
|
artifact_store = FileWorkflowArtifactStore(tmp_path / "drafts_create_rejected")
|
||||||
_drafts, _service, authoring = _draft_api(artifact_store)
|
_drafts, _service, authoring = _draft_api(artifact_store)
|
||||||
facade = WorkflowApi(authoring.context)
|
facade = WorkflowApi(authoring.context, drafts=True)
|
||||||
|
|
||||||
with pytest.raises(ValueError):
|
with pytest.raises(ValueError):
|
||||||
await facade.create_empty_draft_workspace(
|
await facade.create_empty_draft_workspace(
|
||||||
@@ -1335,7 +1335,7 @@ async def test_set_draft_start_and_contract_replace_top_level_fields_atomically(
|
|||||||
) -> None:
|
) -> None:
|
||||||
artifact_store = FileWorkflowArtifactStore(tmp_path / "drafts_set_lifecycle")
|
artifact_store = FileWorkflowArtifactStore(tmp_path / "drafts_set_lifecycle")
|
||||||
_drafts, _service, authoring = _draft_api(artifact_store)
|
_drafts, _service, authoring = _draft_api(artifact_store)
|
||||||
facade = WorkflowApi(authoring.context)
|
facade = WorkflowApi(authoring.context, drafts=True)
|
||||||
await facade.create_empty_draft_workspace(
|
await facade.create_empty_draft_workspace(
|
||||||
workspace_id="control_first",
|
workspace_id="control_first",
|
||||||
name="control_first",
|
name="control_first",
|
||||||
@@ -1412,7 +1412,7 @@ async def test_lifecycle_edits_reject_invalid_envelopes_without_mutation(
|
|||||||
tmp_path / f"drafts_lifecycle_rejected_{operation}"
|
tmp_path / f"drafts_lifecycle_rejected_{operation}"
|
||||||
)
|
)
|
||||||
_drafts, _service, authoring = _draft_api(artifact_store)
|
_drafts, _service, authoring = _draft_api(artifact_store)
|
||||||
facade = WorkflowApi(authoring.context)
|
facade = WorkflowApi(authoring.context, drafts=True)
|
||||||
await facade.create_empty_draft_workspace(
|
await facade.create_empty_draft_workspace(
|
||||||
workspace_id="control_first",
|
workspace_id="control_first",
|
||||||
name="control_first",
|
name="control_first",
|
||||||
@@ -1453,7 +1453,7 @@ async def test_lifecycle_edits_report_stale_revision_without_mutation(
|
|||||||
tmp_path / f"drafts_lifecycle_stale_{operation}"
|
tmp_path / f"drafts_lifecycle_stale_{operation}"
|
||||||
)
|
)
|
||||||
_drafts, _service, authoring = _draft_api(artifact_store)
|
_drafts, _service, authoring = _draft_api(artifact_store)
|
||||||
facade = WorkflowApi(authoring.context)
|
facade = WorkflowApi(authoring.context, drafts=True)
|
||||||
await facade.create_empty_draft_workspace(
|
await facade.create_empty_draft_workspace(
|
||||||
workspace_id="control_first",
|
workspace_id="control_first",
|
||||||
name="control_first",
|
name="control_first",
|
||||||
@@ -2187,7 +2187,7 @@ async def test_facade_delegates_semantic_authoring_to_authoring_service(
|
|||||||
service.register_specs("demo.personal", echo_tool, _snapshot_tool)
|
service.register_specs("demo.personal", echo_tool, _snapshot_tool)
|
||||||
|
|
||||||
context = context_from_service(service)
|
context = context_from_service(service)
|
||||||
facade = WorkflowApi(context)
|
facade = WorkflowApi(context, drafts=True)
|
||||||
|
|
||||||
assert facade.draft_authoring is not None
|
assert facade.draft_authoring is not None
|
||||||
assert isinstance(facade.draft_authoring, WorkflowDraftAuthoringApi)
|
assert isinstance(facade.draft_authoring, WorkflowDraftAuthoringApi)
|
||||||
@@ -3469,7 +3469,7 @@ async def test_set_step_input_bindings_rejects_remote_target_reference_without_m
|
|||||||
workspace_id="remote_target",
|
workspace_id="remote_target",
|
||||||
draft=_structured_report_draft("demo.personal.remote_structured_report"),
|
draft=_structured_report_draft("demo.personal.remote_structured_report"),
|
||||||
)
|
)
|
||||||
api = WorkflowApi(authoring.context)
|
api = WorkflowApi(authoring.context, drafts=True)
|
||||||
before = await draft_api.get_draft_workspace(
|
before = await draft_api.get_draft_workspace(
|
||||||
workspace_id="remote_target",
|
workspace_id="remote_target",
|
||||||
include_draft=True,
|
include_draft=True,
|
||||||
@@ -3506,7 +3506,7 @@ async def test_set_step_input_bindings_rejects_non_capability_step_without_mutat
|
|||||||
draft = _structured_report_draft()
|
draft = _structured_report_draft()
|
||||||
draft["steps"]["report"] = {"join": {}}
|
draft["steps"]["report"] = {"join": {}}
|
||||||
await draft_api.create_draft_workspace(workspace_id="non_capability", draft=draft)
|
await draft_api.create_draft_workspace(workspace_id="non_capability", draft=draft)
|
||||||
api = WorkflowApi(authoring.context)
|
api = WorkflowApi(authoring.context, drafts=True)
|
||||||
before = await draft_api.get_draft_workspace(
|
before = await draft_api.get_draft_workspace(
|
||||||
workspace_id="non_capability",
|
workspace_id="non_capability",
|
||||||
include_draft=True,
|
include_draft=True,
|
||||||
@@ -4007,7 +4007,7 @@ async def test_add_step_accepts_every_typed_draft_step(
|
|||||||
) -> None:
|
) -> None:
|
||||||
artifact_store = FileWorkflowArtifactStore(tmp_path / f"draft_add_{step_name}")
|
artifact_store = FileWorkflowArtifactStore(tmp_path / f"draft_add_{step_name}")
|
||||||
draft_api, _service, authoring = _draft_api(artifact_store, register_echo=True)
|
draft_api, _service, authoring = _draft_api(artifact_store, register_echo=True)
|
||||||
api = WorkflowApi(authoring.context)
|
api = WorkflowApi(authoring.context, drafts=True)
|
||||||
await draft_api.create_draft_workspace(workspace_id="draft_ws", draft=_echo_draft())
|
await draft_api.create_draft_workspace(workspace_id="draft_ws", draft=_echo_draft())
|
||||||
|
|
||||||
step = TypeAdapter(DraftStep).validate_python(step_payload)
|
step = TypeAdapter(DraftStep).validate_python(step_payload)
|
||||||
@@ -4033,7 +4033,7 @@ async def test_add_step_routes_incoming_and_outgoing_edges_atomically(
|
|||||||
) -> None:
|
) -> None:
|
||||||
artifact_store = FileWorkflowArtifactStore(tmp_path / "draft_add_routes")
|
artifact_store = FileWorkflowArtifactStore(tmp_path / "draft_add_routes")
|
||||||
draft_api, _service, authoring = _draft_api(artifact_store, register_echo=True)
|
draft_api, _service, authoring = _draft_api(artifact_store, register_echo=True)
|
||||||
api = WorkflowApi(authoring.context)
|
api = WorkflowApi(authoring.context, drafts=True)
|
||||||
await draft_api.create_draft_workspace(workspace_id="draft_ws", draft=_echo_draft())
|
await draft_api.create_draft_workspace(workspace_id="draft_ws", draft=_echo_draft())
|
||||||
|
|
||||||
step = TypeAdapter(DraftStep).validate_python(
|
step = TypeAdapter(DraftStep).validate_python(
|
||||||
@@ -4062,7 +4062,7 @@ async def test_add_step_stale_revision_wins_over_content_preflight(
|
|||||||
) -> None:
|
) -> None:
|
||||||
artifact_store = FileWorkflowArtifactStore(tmp_path / "draft_add_stale")
|
artifact_store = FileWorkflowArtifactStore(tmp_path / "draft_add_stale")
|
||||||
draft_api, _service, authoring = _draft_api(artifact_store, register_echo=True)
|
draft_api, _service, authoring = _draft_api(artifact_store, register_echo=True)
|
||||||
api = WorkflowApi(authoring.context)
|
api = WorkflowApi(authoring.context, drafts=True)
|
||||||
await draft_api.create_draft_workspace(workspace_id="draft_ws", draft=_echo_draft())
|
await draft_api.create_draft_workspace(workspace_id="draft_ws", draft=_echo_draft())
|
||||||
before = await draft_api.get_draft_workspace(
|
before = await draft_api.get_draft_workspace(
|
||||||
workspace_id="draft_ws", include_draft=True
|
workspace_id="draft_ws", include_draft=True
|
||||||
@@ -4135,7 +4135,7 @@ async def test_add_step_adds_missing_incoming_route_parent_atomically(
|
|||||||
) -> None:
|
) -> None:
|
||||||
artifact_store = FileWorkflowArtifactStore(tmp_path / "draft_add_missing_parent")
|
artifact_store = FileWorkflowArtifactStore(tmp_path / "draft_add_missing_parent")
|
||||||
draft_api, _service, authoring = _draft_api(artifact_store, register_echo=True)
|
draft_api, _service, authoring = _draft_api(artifact_store, register_echo=True)
|
||||||
api = WorkflowApi(authoring.context)
|
api = WorkflowApi(authoring.context, drafts=True)
|
||||||
draft = _echo_draft()
|
draft = _echo_draft()
|
||||||
draft["routes"] = {}
|
draft["routes"] = {}
|
||||||
await draft_api.create_draft_workspace(workspace_id="draft_ws", draft=draft)
|
await draft_api.create_draft_workspace(workspace_id="draft_ws", draft=draft)
|
||||||
@@ -4164,7 +4164,7 @@ async def test_add_step_distinguishes_missing_and_explicit_empty_routes(
|
|||||||
) -> None:
|
) -> None:
|
||||||
artifact_store = FileWorkflowArtifactStore(tmp_path / "draft_add_empty_routes")
|
artifact_store = FileWorkflowArtifactStore(tmp_path / "draft_add_empty_routes")
|
||||||
draft_api, _service, authoring = _draft_api(artifact_store, register_echo=True)
|
draft_api, _service, authoring = _draft_api(artifact_store, register_echo=True)
|
||||||
api = WorkflowApi(authoring.context)
|
api = WorkflowApi(authoring.context, drafts=True)
|
||||||
await draft_api.create_draft_workspace(workspace_id="draft_ws", draft=_echo_draft())
|
await draft_api.create_draft_workspace(workspace_id="draft_ws", draft=_echo_draft())
|
||||||
|
|
||||||
step_adapter = TypeAdapter(DraftStep)
|
step_adapter = TypeAdapter(DraftStep)
|
||||||
@@ -4207,7 +4207,7 @@ async def test_add_step_rejects_unknown_incoming_outcome_without_mutation(
|
|||||||
) -> None:
|
) -> None:
|
||||||
artifact_store = FileWorkflowArtifactStore(tmp_path / "draft_add_bad_incoming")
|
artifact_store = FileWorkflowArtifactStore(tmp_path / "draft_add_bad_incoming")
|
||||||
draft_api, _service, authoring = _draft_api(artifact_store, register_echo=True)
|
draft_api, _service, authoring = _draft_api(artifact_store, register_echo=True)
|
||||||
api = WorkflowApi(authoring.context)
|
api = WorkflowApi(authoring.context, drafts=True)
|
||||||
await draft_api.create_draft_workspace(workspace_id="draft_ws", draft=_echo_draft())
|
await draft_api.create_draft_workspace(workspace_id="draft_ws", draft=_echo_draft())
|
||||||
|
|
||||||
draft_store = authoring.drafts._draft_store()
|
draft_store = authoring.drafts._draft_store()
|
||||||
@@ -4277,7 +4277,7 @@ async def test_add_step_rejects_invalid_routing_inputs_atomically(
|
|||||||
) -> None:
|
) -> None:
|
||||||
artifact_store = FileWorkflowArtifactStore(tmp_path / "draft_add_errors")
|
artifact_store = FileWorkflowArtifactStore(tmp_path / "draft_add_errors")
|
||||||
draft_api, _service, authoring = _draft_api(artifact_store, register_echo=True)
|
draft_api, _service, authoring = _draft_api(artifact_store, register_echo=True)
|
||||||
api = WorkflowApi(authoring.context)
|
api = WorkflowApi(authoring.context, drafts=True)
|
||||||
await draft_api.create_draft_workspace(workspace_id="draft_ws", draft=_echo_draft())
|
await draft_api.create_draft_workspace(workspace_id="draft_ws", draft=_echo_draft())
|
||||||
|
|
||||||
await _assert_add_step_rejected_without_mutation(
|
await _assert_add_step_rejected_without_mutation(
|
||||||
@@ -4338,7 +4338,7 @@ async def test_add_step_rejects_routes_for_non_routable_steps_atomically(
|
|||||||
) -> None:
|
) -> None:
|
||||||
artifact_store = FileWorkflowArtifactStore(tmp_path / "draft_add_forbidden_routes")
|
artifact_store = FileWorkflowArtifactStore(tmp_path / "draft_add_forbidden_routes")
|
||||||
draft_api, _service, authoring = _draft_api(artifact_store, register_echo=True)
|
draft_api, _service, authoring = _draft_api(artifact_store, register_echo=True)
|
||||||
api = WorkflowApi(authoring.context)
|
api = WorkflowApi(authoring.context, drafts=True)
|
||||||
await draft_api.create_draft_workspace(workspace_id="draft_ws", draft=_echo_draft())
|
await draft_api.create_draft_workspace(workspace_id="draft_ws", draft=_echo_draft())
|
||||||
|
|
||||||
before = await draft_api.get_draft_workspace(
|
before = await draft_api.get_draft_workspace(
|
||||||
@@ -4367,7 +4367,7 @@ async def test_add_step_accepts_incomplete_declared_route_subset(
|
|||||||
artifact_store = FileWorkflowArtifactStore(tmp_path / "draft_add_partial_routes")
|
artifact_store = FileWorkflowArtifactStore(tmp_path / "draft_add_partial_routes")
|
||||||
draft_api, service, authoring = _draft_api(artifact_store, register_echo=True)
|
draft_api, service, authoring = _draft_api(artifact_store, register_echo=True)
|
||||||
service.register_specs("demo.personal", echo_tool, _snapshot_tool)
|
service.register_specs("demo.personal", echo_tool, _snapshot_tool)
|
||||||
api = WorkflowApi(authoring.context)
|
api = WorkflowApi(authoring.context, drafts=True)
|
||||||
await draft_api.create_draft_workspace(workspace_id="draft_ws", draft=_echo_draft())
|
await draft_api.create_draft_workspace(workspace_id="draft_ws", draft=_echo_draft())
|
||||||
|
|
||||||
step = TypeAdapter(DraftStep).validate_python(
|
step = TypeAdapter(DraftStep).validate_python(
|
||||||
@@ -6300,7 +6300,7 @@ def _browser_click_api(
|
|||||||
_collect_snapshots,
|
_collect_snapshots,
|
||||||
)
|
)
|
||||||
context = context_from_service(service)
|
context = context_from_service(service)
|
||||||
return WorkflowApi(context), service
|
return WorkflowApi(context, drafts=True), service
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import typer
|
|||||||
from typer.core import TyperCommand
|
from typer.core import TyperCommand
|
||||||
|
|
||||||
from wf_api import WorkflowApi
|
from wf_api import WorkflowApi
|
||||||
|
from wf_artifacts import FileWorkflowArtifactStore
|
||||||
from wf_cli.context import (
|
from wf_cli.context import (
|
||||||
CliTyperState,
|
CliTyperState,
|
||||||
config_path_from_context,
|
config_path_from_context,
|
||||||
@@ -128,10 +129,9 @@ def test_load_cli_context_local_uses_workflow_store_override(
|
|||||||
assert context.service is None
|
assert context.service is None
|
||||||
assert isinstance(context.handlers, WorkflowApi)
|
assert isinstance(context.handlers, WorkflowApi)
|
||||||
assert context.handlers.drafts_enabled is True
|
assert context.handlers.drafts_enabled is True
|
||||||
assert (
|
artifact_store = context.handlers.context.artifact_store
|
||||||
context.handlers.context.artifact_store.root
|
assert isinstance(artifact_store, FileWorkflowArtifactStore)
|
||||||
== (tmp_path / ".workflow").resolve()
|
assert artifact_store.root == (tmp_path / ".workflow").resolve()
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
|
|||||||
@@ -329,7 +329,7 @@ def _patch_rpc_client_to_server(monkeypatch, server) -> None:
|
|||||||
url=url,
|
url=url,
|
||||||
timeout_seconds=timeout_seconds,
|
timeout_seconds=timeout_seconds,
|
||||||
http_client=httpx.AsyncClient(
|
http_client=httpx.AsyncClient(
|
||||||
transport=httpx.ASGITransport(app=create_rpc_app(server)),
|
transport=httpx.ASGITransport(app=create_rpc_app(server, drafts=True)),
|
||||||
base_url="http://test",
|
base_url="http://test",
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@@ -340,7 +340,7 @@ def _patch_rpc_client_to_server(monkeypatch, server) -> None:
|
|||||||
|
|
||||||
|
|
||||||
def test_wf_cap_commands_use_rpc_url_override(monkeypatch, tmp_path) -> None:
|
def test_wf_cap_commands_use_rpc_url_override(monkeypatch, tmp_path) -> None:
|
||||||
server = build_local_static_workflow_server(tmp_path / "store")
|
server = build_local_static_workflow_server(tmp_path / "store", drafts=True)
|
||||||
_patch_rpc_client_to_server(monkeypatch, server)
|
_patch_rpc_client_to_server(monkeypatch, server)
|
||||||
config_path = tmp_path / "wf.json"
|
config_path = tmp_path / "wf.json"
|
||||||
config_path.write_text('{"version": 1}', encoding="utf-8")
|
config_path.write_text('{"version": 1}', encoding="utf-8")
|
||||||
@@ -433,7 +433,7 @@ def test_wf_cap_commands_use_rpc_url_override(monkeypatch, tmp_path) -> None:
|
|||||||
|
|
||||||
|
|
||||||
def test_wf_source_commands_use_rpc_url_override(monkeypatch, tmp_path) -> None:
|
def test_wf_source_commands_use_rpc_url_override(monkeypatch, tmp_path) -> None:
|
||||||
server = build_local_static_workflow_server(tmp_path / "store")
|
server = build_local_static_workflow_server(tmp_path / "store", drafts=True)
|
||||||
_patch_rpc_client_to_server(monkeypatch, server)
|
_patch_rpc_client_to_server(monkeypatch, server)
|
||||||
config_path = tmp_path / "wf.json"
|
config_path = tmp_path / "wf.json"
|
||||||
config_path.write_text('{"version": 1}', encoding="utf-8")
|
config_path.write_text('{"version": 1}', encoding="utf-8")
|
||||||
@@ -511,7 +511,7 @@ def test_wf_remote_source_inspect_formats_expected_rpc_error(
|
|||||||
monkeypatch,
|
monkeypatch,
|
||||||
tmp_path,
|
tmp_path,
|
||||||
) -> None:
|
) -> None:
|
||||||
server = build_local_static_workflow_server(tmp_path / "store")
|
server = build_local_static_workflow_server(tmp_path / "store", drafts=True)
|
||||||
_patch_rpc_client_to_server(monkeypatch, server)
|
_patch_rpc_client_to_server(monkeypatch, server)
|
||||||
config_path = tmp_path / "wf.json"
|
config_path = tmp_path / "wf.json"
|
||||||
config_path.write_text('{"version": 1}', encoding="utf-8")
|
config_path.write_text('{"version": 1}', encoding="utf-8")
|
||||||
@@ -607,7 +607,7 @@ def test_wf_verbose_shows_full_traceback_for_unexpected_error(
|
|||||||
|
|
||||||
|
|
||||||
def test_wf_admin_commands_use_rpc_url_override(monkeypatch, tmp_path) -> None:
|
def test_wf_admin_commands_use_rpc_url_override(monkeypatch, tmp_path) -> None:
|
||||||
server = build_local_static_workflow_server(tmp_path / "store")
|
server = build_local_static_workflow_server(tmp_path / "store", drafts=True)
|
||||||
server.events.record_workflow_event(
|
server.events.record_workflow_event(
|
||||||
"workflow_test_event",
|
"workflow_test_event",
|
||||||
capability_id="workflow.demo.v1",
|
capability_id="workflow.demo.v1",
|
||||||
@@ -632,7 +632,7 @@ def test_wf_admin_commands_use_rpc_url_override(monkeypatch, tmp_path) -> None:
|
|||||||
|
|
||||||
|
|
||||||
def test_wf_remote_draft_artifact_deploy_lifecycle(monkeypatch, tmp_path) -> None:
|
def test_wf_remote_draft_artifact_deploy_lifecycle(monkeypatch, tmp_path) -> None:
|
||||||
server = build_local_static_workflow_server(tmp_path / "store")
|
server = build_local_static_workflow_server(tmp_path / "store", drafts=True)
|
||||||
_patch_rpc_client_to_server(monkeypatch, server)
|
_patch_rpc_client_to_server(monkeypatch, server)
|
||||||
config_path = tmp_path / "wf.json"
|
config_path = tmp_path / "wf.json"
|
||||||
config_path.write_text('{"version": 1}', encoding="utf-8")
|
config_path.write_text('{"version": 1}', encoding="utf-8")
|
||||||
@@ -760,7 +760,7 @@ def test_wf_remote_draft_artifact_deploy_lifecycle(monkeypatch, tmp_path) -> Non
|
|||||||
|
|
||||||
|
|
||||||
def test_wf_remote_capability_free_draft_lifecycle(monkeypatch, tmp_path) -> None:
|
def test_wf_remote_capability_free_draft_lifecycle(monkeypatch, tmp_path) -> None:
|
||||||
server = build_local_static_workflow_server(tmp_path / "store")
|
server = build_local_static_workflow_server(tmp_path / "store", drafts=True)
|
||||||
_patch_rpc_client_to_server(monkeypatch, server)
|
_patch_rpc_client_to_server(monkeypatch, server)
|
||||||
config_path = tmp_path / "wf.json"
|
config_path = tmp_path / "wf.json"
|
||||||
config_path.write_text('{"version": 1}', encoding="utf-8")
|
config_path.write_text('{"version": 1}', encoding="utf-8")
|
||||||
@@ -833,7 +833,7 @@ def test_wf_remote_capability_free_draft_lifecycle(monkeypatch, tmp_path) -> Non
|
|||||||
def test_wf_draft_export_uses_remote_get_and_writes_only_draft(
|
def test_wf_draft_export_uses_remote_get_and_writes_only_draft(
|
||||||
monkeypatch, tmp_path
|
monkeypatch, tmp_path
|
||||||
) -> None:
|
) -> None:
|
||||||
server = build_local_static_workflow_server(tmp_path / "store")
|
server = build_local_static_workflow_server(tmp_path / "store", drafts=True)
|
||||||
_patch_rpc_client_to_server(monkeypatch, server)
|
_patch_rpc_client_to_server(monkeypatch, server)
|
||||||
rpc_calls: list[tuple[str, dict[str, Any]]] = []
|
rpc_calls: list[tuple[str, dict[str, Any]]] = []
|
||||||
original_call = RpcClientTransport._call
|
original_call = RpcClientTransport._call
|
||||||
@@ -885,7 +885,7 @@ def test_wf_draft_export_uses_remote_get_and_writes_only_draft(
|
|||||||
def test_wf_draft_import_uses_exact_remote_replacement_payload(
|
def test_wf_draft_import_uses_exact_remote_replacement_payload(
|
||||||
monkeypatch, tmp_path
|
monkeypatch, tmp_path
|
||||||
) -> None:
|
) -> None:
|
||||||
server = build_local_static_workflow_server(tmp_path / "store")
|
server = build_local_static_workflow_server(tmp_path / "store", drafts=True)
|
||||||
asyncio.run(
|
asyncio.run(
|
||||||
server.api.create_empty_draft_workspace(
|
server.api.create_empty_draft_workspace(
|
||||||
workspace_id="source_ws",
|
workspace_id="source_ws",
|
||||||
@@ -956,7 +956,7 @@ def test_wf_draft_import_uses_exact_remote_replacement_payload(
|
|||||||
def test_wf_draft_transfer_round_trip_preserves_document_and_destination_id(
|
def test_wf_draft_transfer_round_trip_preserves_document_and_destination_id(
|
||||||
monkeypatch, tmp_path
|
monkeypatch, tmp_path
|
||||||
) -> None:
|
) -> None:
|
||||||
server = build_local_static_workflow_server(tmp_path / "store")
|
server = build_local_static_workflow_server(tmp_path / "store", drafts=True)
|
||||||
asyncio.run(
|
asyncio.run(
|
||||||
server.api.create_empty_draft_workspace(
|
server.api.create_empty_draft_workspace(
|
||||||
workspace_id="source_ws",
|
workspace_id="source_ws",
|
||||||
@@ -1023,7 +1023,7 @@ def test_wf_draft_transfer_round_trip_preserves_document_and_destination_id(
|
|||||||
|
|
||||||
|
|
||||||
def test_wf_remote_run_resume_interrupted_deployment(monkeypatch, tmp_path) -> None:
|
def test_wf_remote_run_resume_interrupted_deployment(monkeypatch, tmp_path) -> None:
|
||||||
server = build_local_static_workflow_server(tmp_path / "store")
|
server = build_local_static_workflow_server(tmp_path / "store", drafts=True)
|
||||||
asyncio.run(
|
asyncio.run(
|
||||||
server.api.create_artifact_from_plan(
|
server.api.create_artifact_from_plan(
|
||||||
artifact_id="remote_approval",
|
artifact_id="remote_approval",
|
||||||
@@ -1083,7 +1083,7 @@ def test_wf_remote_run_resume_interrupted_deployment(monkeypatch, tmp_path) -> N
|
|||||||
|
|
||||||
|
|
||||||
def test_wf_status_uses_rpc_url_override(monkeypatch, tmp_path) -> None:
|
def test_wf_status_uses_rpc_url_override(monkeypatch, tmp_path) -> None:
|
||||||
server = build_local_static_workflow_server(tmp_path / "store")
|
server = build_local_static_workflow_server(tmp_path / "store", drafts=True)
|
||||||
asyncio.run(
|
asyncio.run(
|
||||||
server.api.create_artifact_from_plan(
|
server.api.create_artifact_from_plan(
|
||||||
artifact_id="status_constant",
|
artifact_id="status_constant",
|
||||||
@@ -1143,7 +1143,7 @@ def test_wf_status_uses_rpc_url_override(monkeypatch, tmp_path) -> None:
|
|||||||
|
|
||||||
|
|
||||||
def test_wf_status_reports_rpc_config_target(monkeypatch, tmp_path) -> None:
|
def test_wf_status_reports_rpc_config_target(monkeypatch, tmp_path) -> None:
|
||||||
server = build_local_static_workflow_server(tmp_path / "store")
|
server = build_local_static_workflow_server(tmp_path / "store", drafts=True)
|
||||||
_patch_rpc_client_to_server(monkeypatch, server)
|
_patch_rpc_client_to_server(monkeypatch, server)
|
||||||
config_path = tmp_path / "wf.json"
|
config_path = tmp_path / "wf.json"
|
||||||
config_path.write_text(
|
config_path.write_text(
|
||||||
@@ -1178,7 +1178,7 @@ def test_wf_status_reports_rpc_config_target(monkeypatch, tmp_path) -> None:
|
|||||||
|
|
||||||
|
|
||||||
def test_wf_draft_delete_requires_confirm(monkeypatch, tmp_path) -> None:
|
def test_wf_draft_delete_requires_confirm(monkeypatch, tmp_path) -> None:
|
||||||
server = build_local_static_workflow_server(tmp_path / "store")
|
server = build_local_static_workflow_server(tmp_path / "store", drafts=True)
|
||||||
_patch_rpc_client_to_server(monkeypatch, server)
|
_patch_rpc_client_to_server(monkeypatch, server)
|
||||||
config_path = tmp_path / "wf.json"
|
config_path = tmp_path / "wf.json"
|
||||||
config_path.write_text('{"version": 1}', encoding="utf-8")
|
config_path.write_text('{"version": 1}', encoding="utf-8")
|
||||||
@@ -1192,7 +1192,7 @@ def test_wf_draft_delete_requires_confirm(monkeypatch, tmp_path) -> None:
|
|||||||
|
|
||||||
|
|
||||||
def test_wf_draft_delete_succeeds_with_confirm(monkeypatch, tmp_path) -> None:
|
def test_wf_draft_delete_succeeds_with_confirm(monkeypatch, tmp_path) -> None:
|
||||||
server = build_local_static_workflow_server(tmp_path / "store")
|
server = build_local_static_workflow_server(tmp_path / "store", drafts=True)
|
||||||
_patch_rpc_client_to_server(monkeypatch, server)
|
_patch_rpc_client_to_server(monkeypatch, server)
|
||||||
config_path = tmp_path / "wf.json"
|
config_path = tmp_path / "wf.json"
|
||||||
config_path.write_text('{"version": 1}', encoding="utf-8")
|
config_path.write_text('{"version": 1}', encoding="utf-8")
|
||||||
@@ -1225,7 +1225,7 @@ def test_wf_draft_delete_succeeds_with_confirm(monkeypatch, tmp_path) -> None:
|
|||||||
|
|
||||||
|
|
||||||
def test_wf_source_diagnose_uses_rpc_url_override(monkeypatch, tmp_path) -> None:
|
def test_wf_source_diagnose_uses_rpc_url_override(monkeypatch, tmp_path) -> None:
|
||||||
server = build_local_static_workflow_server(tmp_path / "store")
|
server = build_local_static_workflow_server(tmp_path / "store", drafts=True)
|
||||||
_patch_rpc_client_to_server(monkeypatch, server)
|
_patch_rpc_client_to_server(monkeypatch, server)
|
||||||
config_path = tmp_path / "wf.json"
|
config_path = tmp_path / "wf.json"
|
||||||
config_path.write_text('{"version": 1}', encoding="utf-8")
|
config_path.write_text('{"version": 1}', encoding="utf-8")
|
||||||
@@ -1300,7 +1300,7 @@ def test_wf_draft_create_reports_optional_inputs_without_binding(
|
|||||||
def test_wf_draft_set_input_bindings_preserves_composite_expression_over_rpc(
|
def test_wf_draft_set_input_bindings_preserves_composite_expression_over_rpc(
|
||||||
monkeypatch, tmp_path
|
monkeypatch, tmp_path
|
||||||
) -> None:
|
) -> None:
|
||||||
server = build_local_static_workflow_server(tmp_path / "store")
|
server = build_local_static_workflow_server(tmp_path / "store", drafts=True)
|
||||||
_patch_rpc_client_to_server(monkeypatch, server)
|
_patch_rpc_client_to_server(monkeypatch, server)
|
||||||
rpc_calls: list[tuple[str, dict[str, Any]]] = []
|
rpc_calls: list[tuple[str, dict[str, Any]]] = []
|
||||||
original_call = RpcClientTransport._call
|
original_call = RpcClientTransport._call
|
||||||
@@ -1381,7 +1381,7 @@ def test_wf_draft_set_input_bindings_preserves_composite_expression_over_rpc(
|
|||||||
|
|
||||||
|
|
||||||
def test_wf_draft_focused_edit_commands_use_rpc_target(monkeypatch, tmp_path) -> None:
|
def test_wf_draft_focused_edit_commands_use_rpc_target(monkeypatch, tmp_path) -> None:
|
||||||
server = build_local_static_workflow_server(tmp_path / "store")
|
server = build_local_static_workflow_server(tmp_path / "store", drafts=True)
|
||||||
_patch_rpc_client_to_server(monkeypatch, server)
|
_patch_rpc_client_to_server(monkeypatch, server)
|
||||||
config_path = tmp_path / "wf.json"
|
config_path = tmp_path / "wf.json"
|
||||||
config_path.write_text('{"version": 1}', encoding="utf-8")
|
config_path.write_text('{"version": 1}', encoding="utf-8")
|
||||||
@@ -1536,7 +1536,7 @@ def test_wf_draft_focused_edit_commands_use_rpc_target(monkeypatch, tmp_path) ->
|
|||||||
def test_wf_draft_set_workflow_output_replaces_canonical_bindings_over_rpc(
|
def test_wf_draft_set_workflow_output_replaces_canonical_bindings_over_rpc(
|
||||||
monkeypatch, tmp_path
|
monkeypatch, tmp_path
|
||||||
) -> None:
|
) -> None:
|
||||||
server = build_local_static_workflow_server(tmp_path / "store")
|
server = build_local_static_workflow_server(tmp_path / "store", drafts=True)
|
||||||
_patch_rpc_client_to_server(monkeypatch, server)
|
_patch_rpc_client_to_server(monkeypatch, server)
|
||||||
rpc_calls: list[tuple[str, dict[str, Any]]] = []
|
rpc_calls: list[tuple[str, dict[str, Any]]] = []
|
||||||
original_call = RpcClientTransport._call
|
original_call = RpcClientTransport._call
|
||||||
@@ -1625,7 +1625,7 @@ def test_wf_draft_set_workflow_output_replaces_canonical_bindings_over_rpc(
|
|||||||
def test_wf_draft_set_workflow_output_merge_uses_compatibility_rpc_target(
|
def test_wf_draft_set_workflow_output_merge_uses_compatibility_rpc_target(
|
||||||
monkeypatch, tmp_path
|
monkeypatch, tmp_path
|
||||||
) -> None:
|
) -> None:
|
||||||
server = build_local_static_workflow_server(tmp_path / "store")
|
server = build_local_static_workflow_server(tmp_path / "store", drafts=True)
|
||||||
_patch_rpc_client_to_server(monkeypatch, server)
|
_patch_rpc_client_to_server(monkeypatch, server)
|
||||||
rpc_methods: list[str] = []
|
rpc_methods: list[str] = []
|
||||||
original_call = RpcClientTransport._call
|
original_call = RpcClientTransport._call
|
||||||
@@ -1685,7 +1685,7 @@ def test_wf_draft_set_workflow_output_merge_uses_compatibility_rpc_target(
|
|||||||
def test_wf_draft_set_workflow_output_merge_reports_canonical_replacement(
|
def test_wf_draft_set_workflow_output_merge_reports_canonical_replacement(
|
||||||
monkeypatch, tmp_path
|
monkeypatch, tmp_path
|
||||||
) -> None:
|
) -> None:
|
||||||
server = build_local_static_workflow_server(tmp_path / "store")
|
server = build_local_static_workflow_server(tmp_path / "store", drafts=True)
|
||||||
_patch_rpc_client_to_server(monkeypatch, server)
|
_patch_rpc_client_to_server(monkeypatch, server)
|
||||||
rpc_methods: list[str] = []
|
rpc_methods: list[str] = []
|
||||||
original_call = RpcClientTransport._call
|
original_call = RpcClientTransport._call
|
||||||
@@ -1760,7 +1760,7 @@ def test_wf_draft_set_workflow_output_merge_reports_canonical_replacement(
|
|||||||
|
|
||||||
|
|
||||||
def test_wf_draft_remove_route_uses_rpc_target(monkeypatch, tmp_path) -> None:
|
def test_wf_draft_remove_route_uses_rpc_target(monkeypatch, tmp_path) -> None:
|
||||||
server = build_local_static_workflow_server(tmp_path / "store")
|
server = build_local_static_workflow_server(tmp_path / "store", drafts=True)
|
||||||
_patch_rpc_client_to_server(monkeypatch, server)
|
_patch_rpc_client_to_server(monkeypatch, server)
|
||||||
config_path = tmp_path / "wf.json"
|
config_path = tmp_path / "wf.json"
|
||||||
config_path.write_text('{"version": 1}', encoding="utf-8")
|
config_path.write_text('{"version": 1}', encoding="utf-8")
|
||||||
@@ -1811,7 +1811,7 @@ def test_wf_draft_remove_route_uses_rpc_target(monkeypatch, tmp_path) -> None:
|
|||||||
|
|
||||||
|
|
||||||
def test_wf_draft_bind_uses_rpc_target(monkeypatch, tmp_path) -> None:
|
def test_wf_draft_bind_uses_rpc_target(monkeypatch, tmp_path) -> None:
|
||||||
server = build_local_static_workflow_server(tmp_path / "store")
|
server = build_local_static_workflow_server(tmp_path / "store", drafts=True)
|
||||||
_patch_rpc_client_to_server(monkeypatch, server)
|
_patch_rpc_client_to_server(monkeypatch, server)
|
||||||
config_path = tmp_path / "wf.json"
|
config_path = tmp_path / "wf.json"
|
||||||
config_path.write_text('{"version": 1}', encoding="utf-8")
|
config_path.write_text('{"version": 1}', encoding="utf-8")
|
||||||
@@ -1868,7 +1868,7 @@ def test_wf_draft_bind_uses_rpc_target(monkeypatch, tmp_path) -> None:
|
|||||||
def test_wf_draft_set_input_preserves_nested_target_over_rpc(
|
def test_wf_draft_set_input_preserves_nested_target_over_rpc(
|
||||||
monkeypatch, tmp_path
|
monkeypatch, tmp_path
|
||||||
) -> None:
|
) -> None:
|
||||||
server = build_local_static_workflow_server(tmp_path / "store")
|
server = build_local_static_workflow_server(tmp_path / "store", drafts=True)
|
||||||
_patch_rpc_client_to_server(monkeypatch, server)
|
_patch_rpc_client_to_server(monkeypatch, server)
|
||||||
config_path = tmp_path / "wf.json"
|
config_path = tmp_path / "wf.json"
|
||||||
config_path.write_text('{"version": 1}', encoding="utf-8")
|
config_path.write_text('{"version": 1}', encoding="utf-8")
|
||||||
@@ -1921,7 +1921,7 @@ def test_wf_draft_set_input_preserves_nested_target_over_rpc(
|
|||||||
def test_wf_draft_set_input_replaces_canonical_bindings_over_rpc(
|
def test_wf_draft_set_input_replaces_canonical_bindings_over_rpc(
|
||||||
monkeypatch, tmp_path
|
monkeypatch, tmp_path
|
||||||
) -> None:
|
) -> None:
|
||||||
server = build_local_static_workflow_server(tmp_path / "store")
|
server = build_local_static_workflow_server(tmp_path / "store", drafts=True)
|
||||||
_patch_rpc_client_to_server(monkeypatch, server)
|
_patch_rpc_client_to_server(monkeypatch, server)
|
||||||
rpc_methods: list[str] = []
|
rpc_methods: list[str] = []
|
||||||
original_call = RpcClientTransport._call
|
original_call = RpcClientTransport._call
|
||||||
@@ -2024,7 +2024,7 @@ def test_wf_draft_set_input_replaces_canonical_bindings_over_rpc(
|
|||||||
def test_wf_draft_set_output_replaces_canonical_bindings_over_rpc(
|
def test_wf_draft_set_output_replaces_canonical_bindings_over_rpc(
|
||||||
monkeypatch, tmp_path
|
monkeypatch, tmp_path
|
||||||
) -> None:
|
) -> None:
|
||||||
server = build_local_static_workflow_server(tmp_path / "store")
|
server = build_local_static_workflow_server(tmp_path / "store", drafts=True)
|
||||||
_patch_rpc_client_to_server(monkeypatch, server)
|
_patch_rpc_client_to_server(monkeypatch, server)
|
||||||
rpc_calls: list[tuple[str, dict[str, Any]]] = []
|
rpc_calls: list[tuple[str, dict[str, Any]]] = []
|
||||||
original_call = RpcClientTransport._call
|
original_call = RpcClientTransport._call
|
||||||
@@ -2117,7 +2117,7 @@ def test_wf_draft_set_output_replaces_canonical_bindings_over_rpc(
|
|||||||
|
|
||||||
|
|
||||||
def test_wf_draft_add_capability_uses_rpc_target(monkeypatch, tmp_path) -> None:
|
def test_wf_draft_add_capability_uses_rpc_target(monkeypatch, tmp_path) -> None:
|
||||||
server = build_local_static_workflow_server(tmp_path / "store")
|
server = build_local_static_workflow_server(tmp_path / "store", drafts=True)
|
||||||
_patch_rpc_client_to_server(monkeypatch, server)
|
_patch_rpc_client_to_server(monkeypatch, server)
|
||||||
rpc_methods: list[str] = []
|
rpc_methods: list[str] = []
|
||||||
original_call = RpcClientTransport._call
|
original_call = RpcClientTransport._call
|
||||||
@@ -2187,7 +2187,7 @@ def test_wf_draft_add_capability_uses_rpc_target(monkeypatch, tmp_path) -> None:
|
|||||||
def test_wf_draft_capability_add_and_update_preserve_rpc_payloads(
|
def test_wf_draft_capability_add_and_update_preserve_rpc_payloads(
|
||||||
monkeypatch, tmp_path
|
monkeypatch, tmp_path
|
||||||
) -> None:
|
) -> None:
|
||||||
server = build_local_static_workflow_server(tmp_path / "store")
|
server = build_local_static_workflow_server(tmp_path / "store", drafts=True)
|
||||||
_patch_rpc_client_to_server(monkeypatch, server)
|
_patch_rpc_client_to_server(monkeypatch, server)
|
||||||
rpc_calls: list[tuple[str, dict[str, Any]]] = []
|
rpc_calls: list[tuple[str, dict[str, Any]]] = []
|
||||||
original_call = RpcClientTransport._call
|
original_call = RpcClientTransport._call
|
||||||
@@ -2289,7 +2289,7 @@ def test_wf_draft_capability_add_and_update_preserve_rpc_payloads(
|
|||||||
def test_wf_draft_add_control_steps_use_generic_rpc_target(
|
def test_wf_draft_add_control_steps_use_generic_rpc_target(
|
||||||
monkeypatch, tmp_path
|
monkeypatch, tmp_path
|
||||||
) -> None:
|
) -> None:
|
||||||
server = build_local_static_workflow_server(tmp_path / "store")
|
server = build_local_static_workflow_server(tmp_path / "store", drafts=True)
|
||||||
_patch_rpc_client_to_server(monkeypatch, server)
|
_patch_rpc_client_to_server(monkeypatch, server)
|
||||||
rpc_methods: list[str] = []
|
rpc_methods: list[str] = []
|
||||||
original_call = RpcClientTransport._call
|
original_call = RpcClientTransport._call
|
||||||
@@ -2544,7 +2544,7 @@ def test_wf_draft_add_control_steps_use_generic_rpc_target(
|
|||||||
def test_wf_draft_add_capability_reports_bare_output_target_without_traceback(
|
def test_wf_draft_add_capability_reports_bare_output_target_without_traceback(
|
||||||
monkeypatch, tmp_path
|
monkeypatch, tmp_path
|
||||||
) -> None:
|
) -> None:
|
||||||
server = build_local_static_workflow_server(tmp_path / "store")
|
server = build_local_static_workflow_server(tmp_path / "store", drafts=True)
|
||||||
_patch_rpc_client_to_server(monkeypatch, server)
|
_patch_rpc_client_to_server(monkeypatch, server)
|
||||||
config_path = tmp_path / "wf.json"
|
config_path = tmp_path / "wf.json"
|
||||||
config_path.write_text('{"version": 1}', encoding="utf-8")
|
config_path.write_text('{"version": 1}', encoding="utf-8")
|
||||||
@@ -2592,7 +2592,7 @@ def test_wf_draft_add_capability_reports_bare_output_target_without_traceback(
|
|||||||
|
|
||||||
|
|
||||||
def test_wf_draft_compile_prints_compiled_plan(monkeypatch, tmp_path) -> None:
|
def test_wf_draft_compile_prints_compiled_plan(monkeypatch, tmp_path) -> None:
|
||||||
server = build_local_static_workflow_server(tmp_path / "store")
|
server = build_local_static_workflow_server(tmp_path / "store", drafts=True)
|
||||||
_patch_rpc_client_to_server(monkeypatch, server)
|
_patch_rpc_client_to_server(monkeypatch, server)
|
||||||
config_path = tmp_path / "wf.json"
|
config_path = tmp_path / "wf.json"
|
||||||
config_path.write_text('{"version": 1}', encoding="utf-8")
|
config_path.write_text('{"version": 1}', encoding="utf-8")
|
||||||
@@ -2625,7 +2625,7 @@ def test_wf_draft_compile_prints_compiled_plan(monkeypatch, tmp_path) -> None:
|
|||||||
def test_wf_draft_compile_invalid_prints_diagnostics_to_stderr(
|
def test_wf_draft_compile_invalid_prints_diagnostics_to_stderr(
|
||||||
monkeypatch, tmp_path
|
monkeypatch, tmp_path
|
||||||
) -> None:
|
) -> None:
|
||||||
server = build_local_static_workflow_server(tmp_path / "store")
|
server = build_local_static_workflow_server(tmp_path / "store", drafts=True)
|
||||||
asyncio.run(
|
asyncio.run(
|
||||||
server.api.create_draft_workspace(
|
server.api.create_draft_workspace(
|
||||||
workspace_id="invalid_compile_ws",
|
workspace_id="invalid_compile_ws",
|
||||||
@@ -2672,7 +2672,7 @@ def test_wf_draft_compile_invalid_prints_diagnostics_to_stderr(
|
|||||||
|
|
||||||
|
|
||||||
def test_wf_deploy_create_alias_saves_deployment(monkeypatch, tmp_path) -> None:
|
def test_wf_deploy_create_alias_saves_deployment(monkeypatch, tmp_path) -> None:
|
||||||
server = build_local_static_workflow_server(tmp_path / "store")
|
server = build_local_static_workflow_server(tmp_path / "store", drafts=True)
|
||||||
asyncio.run(
|
asyncio.run(
|
||||||
server.api.create_artifact_from_plan(
|
server.api.create_artifact_from_plan(
|
||||||
artifact_id="alias_artifact",
|
artifact_id="alias_artifact",
|
||||||
@@ -2710,7 +2710,7 @@ def test_wf_deploy_create_alias_saves_deployment(monkeypatch, tmp_path) -> None:
|
|||||||
|
|
||||||
|
|
||||||
def test_wf_draft_forward_route_invalid_via_rpc(monkeypatch, tmp_path) -> None:
|
def test_wf_draft_forward_route_invalid_via_rpc(monkeypatch, tmp_path) -> None:
|
||||||
server = build_local_static_workflow_server(tmp_path / "store")
|
server = build_local_static_workflow_server(tmp_path / "store", drafts=True)
|
||||||
_patch_rpc_client_to_server(monkeypatch, server)
|
_patch_rpc_client_to_server(monkeypatch, server)
|
||||||
config_path = tmp_path / "wf.json"
|
config_path = tmp_path / "wf.json"
|
||||||
config_path.write_text('{"version": 1}', encoding="utf-8")
|
config_path.write_text('{"version": 1}', encoding="utf-8")
|
||||||
|
|||||||
+154
-2
@@ -5,8 +5,14 @@ from typing import Any, cast
|
|||||||
import httpx
|
import httpx
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from wf_client import App, CapabilitySummary, Page
|
import wf_client
|
||||||
from wf_client.errors import InvalidResponse
|
from wf_client import App, CapabilitySummary, Page, WorkflowClientError
|
||||||
|
from wf_client.errors import (
|
||||||
|
CapabilityNotFound,
|
||||||
|
InvalidResponse,
|
||||||
|
ProtocolError,
|
||||||
|
TransportError,
|
||||||
|
)
|
||||||
from wf_client.protocols import WorkflowClientPort
|
from wf_client.protocols import WorkflowClientPort
|
||||||
from wf_platform import CapabilityRef
|
from wf_platform import CapabilityRef
|
||||||
|
|
||||||
@@ -92,6 +98,152 @@ def test_from_http_jsonrpc_is_lazy(monkeypatch: pytest.MonkeyPatch) -> None:
|
|||||||
assert calls == []
|
assert calls == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_package_does_not_export_internal_port_or_codecs() -> None:
|
||||||
|
assert not hasattr(wf_client, "WorkflowClientPort")
|
||||||
|
assert not hasattr(wf_client, "DecodedRunResult")
|
||||||
|
assert not hasattr(wf_client, "decode_run_result")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_http_app_translates_connection_failure_to_public_error(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
async def fail_post(*args: object, **kwargs: object) -> httpx.Response:
|
||||||
|
raise httpx.ConnectError("connection refused")
|
||||||
|
|
||||||
|
monkeypatch.setattr(httpx.AsyncClient, "post", fail_post)
|
||||||
|
app = App.from_http_jsonrpc("http://unreachable.test/rpc")
|
||||||
|
|
||||||
|
with pytest.raises(WorkflowClientError) as raised:
|
||||||
|
await app.capability("app.default.search")
|
||||||
|
|
||||||
|
assert isinstance(raised.value, TransportError)
|
||||||
|
assert "connection refused" in str(raised.value)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@pytest.mark.parametrize("failure", ["http", "json", "json-array"])
|
||||||
|
async def test_http_app_translates_http_and_json_failures(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
failure: str,
|
||||||
|
) -> None:
|
||||||
|
async def fail_post(*args: object, **kwargs: object) -> httpx.Response:
|
||||||
|
request = httpx.Request("POST", "http://test/rpc")
|
||||||
|
if failure == "http":
|
||||||
|
return httpx.Response(503, request=request)
|
||||||
|
if failure == "json-array":
|
||||||
|
return httpx.Response(200, request=request, json=[])
|
||||||
|
return httpx.Response(200, request=request, content=b"not-json")
|
||||||
|
|
||||||
|
monkeypatch.setattr(httpx.AsyncClient, "post", fail_post)
|
||||||
|
app = App.from_http_jsonrpc("http://test/rpc")
|
||||||
|
|
||||||
|
with pytest.raises(WorkflowClientError) as raised:
|
||||||
|
await app.capability("app.default.search")
|
||||||
|
|
||||||
|
expected_type = ProtocolError if failure == "json-array" else TransportError
|
||||||
|
assert isinstance(raised.value, expected_type)
|
||||||
|
assert "workflow.capabilities.inspect" in str(raised.value)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_http_app_translates_known_workflow_protocol_error(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
async def error_post(*args: object, **kwargs: object) -> httpx.Response:
|
||||||
|
return httpx.Response(
|
||||||
|
200,
|
||||||
|
request=httpx.Request("POST", "http://test/rpc"),
|
||||||
|
json={
|
||||||
|
"jsonrpc": "2.0",
|
||||||
|
"id": "request",
|
||||||
|
"error": {
|
||||||
|
"code": 5000,
|
||||||
|
"message": "Workflow operation failed",
|
||||||
|
"data": {
|
||||||
|
"code": "capability_not_found",
|
||||||
|
"message": "unknown capability app.default.search",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
monkeypatch.setattr(httpx.AsyncClient, "post", error_post)
|
||||||
|
app = App.from_http_jsonrpc("http://test/rpc")
|
||||||
|
|
||||||
|
with pytest.raises(WorkflowClientError) as raised:
|
||||||
|
await app.capability("app.default.search")
|
||||||
|
|
||||||
|
assert isinstance(raised.value, CapabilityNotFound)
|
||||||
|
assert "unknown capability" in str(raised.value)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_http_app_preserves_unknown_protocol_error_details(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
data = {"code": "future_workflow_error", "message": "future detail", "retry": 3}
|
||||||
|
|
||||||
|
async def error_post(*args: object, **kwargs: object) -> httpx.Response:
|
||||||
|
return httpx.Response(
|
||||||
|
200,
|
||||||
|
request=httpx.Request("POST", "http://test/rpc"),
|
||||||
|
json={
|
||||||
|
"jsonrpc": "2.0",
|
||||||
|
"id": "request",
|
||||||
|
"error": {
|
||||||
|
"code": 5999,
|
||||||
|
"message": "Future workflow error",
|
||||||
|
"data": data,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
monkeypatch.setattr(httpx.AsyncClient, "post", error_post)
|
||||||
|
app = App.from_http_jsonrpc("http://test/rpc")
|
||||||
|
|
||||||
|
with pytest.raises(ProtocolError) as raised:
|
||||||
|
await app.capability("app.default.search")
|
||||||
|
|
||||||
|
assert raised.value.code == 5999
|
||||||
|
assert raised.value.message == "Future workflow error"
|
||||||
|
assert raised.value.data == data
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_workflow_rejects_mismatched_inspected_artifact_identity() -> None:
|
||||||
|
class ArtifactPort(_Port):
|
||||||
|
async def inspect_artifact(self, **params: Any) -> object:
|
||||||
|
return {
|
||||||
|
"id": "other",
|
||||||
|
"version": 2,
|
||||||
|
"title": "Other",
|
||||||
|
"kind": "workflow",
|
||||||
|
"description": None,
|
||||||
|
"input_schema": {"type": "object", "properties": {}},
|
||||||
|
"output_schema": {"type": "object", "properties": {}},
|
||||||
|
"outcomes": ["ok"],
|
||||||
|
"plan": {
|
||||||
|
"name": "other",
|
||||||
|
"input_schema": {"type": "object", "properties": {}},
|
||||||
|
"state_schema": {"type": "object", "properties": {}},
|
||||||
|
"output_schema": {"type": "object", "properties": {}},
|
||||||
|
"outcomes": ["ok"],
|
||||||
|
"start": "done",
|
||||||
|
"nodes": [{"id": "done", "type": "end", "outcome": "ok"}],
|
||||||
|
"edges": [],
|
||||||
|
},
|
||||||
|
"required_capabilities": [],
|
||||||
|
"workflow_dependencies": {},
|
||||||
|
"created_from_catalog_version": None,
|
||||||
|
}
|
||||||
|
|
||||||
|
app = App._from_port(cast(WorkflowClientPort, ArtifactPort()))
|
||||||
|
|
||||||
|
with pytest.raises(InvalidResponse, match="workflow.artifacts.inspect"):
|
||||||
|
await app.workflow("report", version=1)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_capability_discovery_returns_rich_page() -> None:
|
async def test_capability_discovery_returns_rich_page() -> None:
|
||||||
page = await _app().capabilities(query="search", limit=10)
|
page = await _app().capabilities(query="search", limit=10)
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import pytest
|
|||||||
|
|
||||||
from wf_authoring import WorkflowBuilder
|
from wf_authoring import WorkflowBuilder
|
||||||
from wf_client import App, ArtifactRef, EditableWorkflow, RemoteCapability
|
from wf_client import App, ArtifactRef, EditableWorkflow, RemoteCapability
|
||||||
|
from wf_client.errors import InvalidResponse
|
||||||
from wf_client.protocols import WorkflowClientPort
|
from wf_client.protocols import WorkflowClientPort
|
||||||
from wf_platform import CapabilityRef
|
from wf_platform import CapabilityRef
|
||||||
|
|
||||||
@@ -20,6 +21,7 @@ class FakePort:
|
|||||||
"workflow_dependencies": {},
|
"workflow_dependencies": {},
|
||||||
}
|
}
|
||||||
self.inspect_artifact_result: dict[str, Any] | None = None
|
self.inspect_artifact_result: dict[str, Any] | None = None
|
||||||
|
self.create_artifact_result: dict[str, Any] | None = None
|
||||||
|
|
||||||
async def validate_artifact_plan(self, **params: Any) -> object:
|
async def validate_artifact_plan(self, **params: Any) -> object:
|
||||||
self.calls.append(("validate_artifact_plan", params))
|
self.calls.append(("validate_artifact_plan", params))
|
||||||
@@ -27,7 +29,11 @@ class FakePort:
|
|||||||
|
|
||||||
async def create_artifact_from_plan(self, **params: Any) -> object:
|
async def create_artifact_from_plan(self, **params: Any) -> object:
|
||||||
self.calls.append(("create_artifact_from_plan", params))
|
self.calls.append(("create_artifact_from_plan", params))
|
||||||
return {"artifact_id": params["artifact_id"], "version": params["version"], "saved": True}
|
return self.create_artifact_result or {
|
||||||
|
"artifact_id": params["artifact_id"],
|
||||||
|
"version": params["version"],
|
||||||
|
"saved": True,
|
||||||
|
}
|
||||||
|
|
||||||
async def inspect_artifact(self, **params: Any) -> object:
|
async def inspect_artifact(self, **params: Any) -> object:
|
||||||
self.calls.append(("inspect_artifact", params))
|
self.calls.append(("inspect_artifact", params))
|
||||||
@@ -43,13 +49,22 @@ def valid_plan(version: int = 1) -> dict[str, Any]:
|
|||||||
"kind": "workflow",
|
"kind": "workflow",
|
||||||
"description": None,
|
"description": None,
|
||||||
"input_schema": {"type": "object", "properties": {}},
|
"input_schema": {"type": "object", "properties": {}},
|
||||||
"output_schema": {"type": "object", "properties": {"value": {"type": "string"}}},
|
"output_schema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {"value": {"type": "string"}},
|
||||||
|
},
|
||||||
"outcomes": ["ok"],
|
"outcomes": ["ok"],
|
||||||
"plan": {
|
"plan": {
|
||||||
"name": "report",
|
"name": "report",
|
||||||
"input_schema": {"type": "object", "properties": {}},
|
"input_schema": {"type": "object", "properties": {}},
|
||||||
"state_schema": {"type": "object", "properties": {"value": {"type": "string"}}},
|
"state_schema": {
|
||||||
"output_schema": {"type": "object", "properties": {"value": {"type": "string"}}},
|
"type": "object",
|
||||||
|
"properties": {"value": {"type": "string"}},
|
||||||
|
},
|
||||||
|
"output_schema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {"value": {"type": "string"}},
|
||||||
|
},
|
||||||
"outcomes": ["ok"],
|
"outcomes": ["ok"],
|
||||||
"output": [{"path": "state.value", "target": "value"}],
|
"output": [{"path": "state.value", "target": "value"}],
|
||||||
"start": "done",
|
"start": "done",
|
||||||
@@ -75,9 +90,7 @@ def remote_plan_without_schema_snapshots(version: int = 1) -> dict[str, Any]:
|
|||||||
{"id": "done", "type": "end", "outcome": "ok"},
|
{"id": "done", "type": "end", "outcome": "ok"},
|
||||||
]
|
]
|
||||||
payload["plan"]["start"] = "remote"
|
payload["plan"]["start"] = "remote"
|
||||||
payload["plan"]["edges"] = [
|
payload["plan"]["edges"] = [{"from": "remote", "outcome": "ok", "to": "done"}]
|
||||||
{"from": "remote", "outcome": "ok", "to": "done"}
|
|
||||||
]
|
|
||||||
payload["required_capabilities"] = [
|
payload["required_capabilities"] = [
|
||||||
{
|
{
|
||||||
"ref": {"source": "app.default", "capability_key": "remote"},
|
"ref": {"source": "app.default", "capability_key": "remote"},
|
||||||
@@ -155,19 +168,73 @@ async def test_edit_and_save_inspects_exact_saved_version() -> None:
|
|||||||
graph = await app.edit_workflow("report", version=1)
|
graph = await app.edit_workflow("report", version=1)
|
||||||
assert isinstance(graph, WorkflowBuilder)
|
assert isinstance(graph, WorkflowBuilder)
|
||||||
assert isinstance(graph, EditableWorkflow)
|
assert isinstance(graph, EditableWorkflow)
|
||||||
assert all(hasattr(graph, name) for name in ("when", "choose", "match", "foreach", "interrupt", "end", "connect", "set_entry_point"))
|
assert all(
|
||||||
|
hasattr(graph, name)
|
||||||
|
for name in (
|
||||||
|
"when",
|
||||||
|
"choose",
|
||||||
|
"match",
|
||||||
|
"foreach",
|
||||||
|
"interrupt",
|
||||||
|
"end",
|
||||||
|
"connect",
|
||||||
|
"set_entry_point",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
port.inspect_artifact_result = valid_plan(version=2)
|
port.inspect_artifact_result = valid_plan(version=2)
|
||||||
saved = await graph.save(version=2)
|
saved = await graph.save(version=2)
|
||||||
|
|
||||||
create = next(params for operation, params in port.calls if operation == "create_artifact_from_plan")
|
create = next(
|
||||||
|
params
|
||||||
|
for operation, params in port.calls
|
||||||
|
if operation == "create_artifact_from_plan"
|
||||||
|
)
|
||||||
assert create["plan"] == valid_plan(version=1)["plan"]
|
assert create["plan"] == valid_plan(version=1)["plan"]
|
||||||
inspect = [params for operation, params in port.calls if operation == "inspect_artifact"][-1]
|
inspect = [
|
||||||
|
params for operation, params in port.calls if operation == "inspect_artifact"
|
||||||
|
][-1]
|
||||||
assert inspect == {"artifact_id": "report", "version": 2}
|
assert inspect == {"artifact_id": "report", "version": 2}
|
||||||
assert saved.ref == ArtifactRef("report", 2)
|
assert saved.ref == ArtifactRef("report", 2)
|
||||||
assert str(saved.workflow.output[0].target) == "value"
|
assert str(saved.workflow.output[0].target) == "value"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_save_rejects_mismatched_create_acknowledgement() -> None:
|
||||||
|
port = FakePort()
|
||||||
|
graph = App._from_port(cast(WorkflowClientPort, port)).new_workflow(
|
||||||
|
"report",
|
||||||
|
input_schema={"type": "object", "properties": {}},
|
||||||
|
state_schema={"type": "object", "properties": {}},
|
||||||
|
output_schema={"type": "object", "properties": {}},
|
||||||
|
)
|
||||||
|
graph.set_entry_point(graph.end("ok", id="done"))
|
||||||
|
port.create_artifact_result = {
|
||||||
|
"artifact_id": "other",
|
||||||
|
"version": 2,
|
||||||
|
"saved": True,
|
||||||
|
}
|
||||||
|
|
||||||
|
with pytest.raises(InvalidResponse, match="workflow.artifacts.create_from_plan"):
|
||||||
|
await graph.save(version=2)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_save_rejects_mismatched_exact_inspection() -> None:
|
||||||
|
port = FakePort()
|
||||||
|
graph = App._from_port(cast(WorkflowClientPort, port)).new_workflow(
|
||||||
|
"report",
|
||||||
|
input_schema={"type": "object", "properties": {}},
|
||||||
|
state_schema={"type": "object", "properties": {}},
|
||||||
|
output_schema={"type": "object", "properties": {}},
|
||||||
|
)
|
||||||
|
graph.set_entry_point(graph.end("ok", id="done"))
|
||||||
|
port.inspect_artifact_result = valid_plan(version=3)
|
||||||
|
|
||||||
|
with pytest.raises(InvalidResponse, match="workflow.artifacts.inspect"):
|
||||||
|
await graph.save(version=2)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_editable_artifact_without_schema_snapshots_remains_saveable() -> None:
|
async def test_editable_artifact_without_schema_snapshots_remains_saveable() -> None:
|
||||||
port = FakePort()
|
port = FakePort()
|
||||||
|
|||||||
@@ -36,14 +36,18 @@ def _inspect_payload() -> dict[str, Any]:
|
|||||||
class _Port:
|
class _Port:
|
||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
self.calls: list[dict[str, Any]] = []
|
self.calls: list[dict[str, Any]] = []
|
||||||
|
self.result_qualified_name = "app.default.search"
|
||||||
|
self.result_source_id = "app.default"
|
||||||
|
self.result_kind = "node_spec"
|
||||||
|
self.result_deployment_id: str | None = None
|
||||||
|
|
||||||
async def call_capability(self, **params: Any) -> object:
|
async def call_capability(self, **params: Any) -> object:
|
||||||
self.calls.append(params)
|
self.calls.append(params)
|
||||||
return {
|
return {
|
||||||
"qualified_name": "app.default.search",
|
"qualified_name": self.result_qualified_name,
|
||||||
"source_id": "app.default",
|
"source_id": self.result_source_id,
|
||||||
"kind": "node_spec",
|
"kind": self.result_kind,
|
||||||
"deployment_id": None,
|
"deployment_id": self.result_deployment_id,
|
||||||
"outcome": "ok",
|
"outcome": "ok",
|
||||||
"output": {"results": ["one"]},
|
"output": {"results": ["one"]},
|
||||||
"diagnostics": [],
|
"diagnostics": [],
|
||||||
@@ -118,6 +122,67 @@ async def test_remote_capability_rejects_mixed_payload_forms() -> None:
|
|||||||
await capability({"query": "workflow"}, query="again")
|
await capability({"query": "workflow"}, query="again")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_remote_capability_rejects_mismatched_call_source() -> None:
|
||||||
|
port = _Port()
|
||||||
|
port.result_source_id = "other.source"
|
||||||
|
capability = RemoteCapability(
|
||||||
|
_port=cast(WorkflowClientPort, port),
|
||||||
|
ref=CapabilityRef.parse("app.default.search"),
|
||||||
|
qualified_name="app.default.search",
|
||||||
|
description=None,
|
||||||
|
input_schema={"type": "object"},
|
||||||
|
output_schema={"type": "object"},
|
||||||
|
outcomes=("ok",),
|
||||||
|
is_async=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(InvalidResponse, match="workflow.capabilities.call"):
|
||||||
|
await capability({})
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_node_capability_rejects_unexpected_result_deployment() -> None:
|
||||||
|
port = _Port()
|
||||||
|
port.result_deployment_id = "unexpected"
|
||||||
|
capability = RemoteCapability(
|
||||||
|
_port=cast(WorkflowClientPort, port),
|
||||||
|
ref=CapabilityRef.parse("app.default.search"),
|
||||||
|
qualified_name="app.default.search",
|
||||||
|
description=None,
|
||||||
|
input_schema={"type": "object"},
|
||||||
|
output_schema={"type": "object"},
|
||||||
|
outcomes=("ok",),
|
||||||
|
is_async=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(InvalidResponse, match="workflow.capabilities.call"):
|
||||||
|
await capability.call({}, deployment_id="ignored-by-node-spec")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_wrapper_capability_requires_exact_result_deployment() -> None:
|
||||||
|
port = _Port()
|
||||||
|
port.result_qualified_name = "workflow.report.v1"
|
||||||
|
port.result_source_id = "workflow"
|
||||||
|
port.result_kind = "wrapper_artifact"
|
||||||
|
port.result_deployment_id = "other.deployment"
|
||||||
|
capability = RemoteCapability(
|
||||||
|
_port=cast(WorkflowClientPort, port),
|
||||||
|
ref=CapabilityRef(source=SourceRef.parse("workflow"), name="report.v1"),
|
||||||
|
qualified_name="workflow.report.v1",
|
||||||
|
description=None,
|
||||||
|
input_schema={"type": "object"},
|
||||||
|
output_schema={"type": "object"},
|
||||||
|
outcomes=("ok",),
|
||||||
|
is_async=False,
|
||||||
|
_kind="wrapper_artifact",
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(InvalidResponse, match="workflow.capabilities.call"):
|
||||||
|
await capability.call({}, deployment_id="report.production")
|
||||||
|
|
||||||
|
|
||||||
def test_remote_capability_rejects_invalid_inspected_schema() -> None:
|
def test_remote_capability_rejects_invalid_inspected_schema() -> None:
|
||||||
with pytest.raises(InvalidResponse, match="invalid JSON Schema"):
|
with pytest.raises(InvalidResponse, match="invalid JSON Schema"):
|
||||||
RemoteCapability(
|
RemoteCapability(
|
||||||
|
|||||||
@@ -5,8 +5,9 @@ from typing import Any, cast
|
|||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from wf_artifacts import WorkflowArtifact as ArtifactModel
|
from wf_artifacts import WorkflowArtifact as ArtifactModel
|
||||||
from wf_client import DeploymentRequired, WorkflowClientPort
|
from wf_client import DeploymentRequired
|
||||||
from wf_client.errors import DeploymentNotRunnable, InvalidResponse
|
from wf_client.errors import DeploymentNotRunnable, InvalidResponse
|
||||||
|
from wf_client.protocols import WorkflowClientPort
|
||||||
from wf_client.workflows import WorkflowArtifact
|
from wf_client.workflows import WorkflowArtifact
|
||||||
from wf_core import Workflow
|
from wf_core import Workflow
|
||||||
|
|
||||||
@@ -193,6 +194,16 @@ async def test_artifact_deploy_rejects_wrong_created_deployment_id() -> None:
|
|||||||
await artifact.deploy("report.production")
|
await artifact.deploy("report.production")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_artifact_deploy_rejects_wrong_created_artifact_identity() -> None:
|
||||||
|
artifact = _artifact()
|
||||||
|
port = cast(_FakePort, artifact._port)
|
||||||
|
port.save_result["artifact_version"] = 2
|
||||||
|
|
||||||
|
with pytest.raises(InvalidResponse, match="workflow.deployments.save"):
|
||||||
|
await artifact.deploy("report.production")
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_artifact_deploy_rejects_wrong_inspected_deployment_id() -> None:
|
async def test_artifact_deploy_rejects_wrong_inspected_deployment_id() -> None:
|
||||||
artifact = _artifact()
|
artifact = _artifact()
|
||||||
@@ -310,3 +321,47 @@ async def test_deployment_run_preserves_server_error_and_diagnostics() -> None:
|
|||||||
assert captured.value.error == "dependency check failed"
|
assert captured.value.error == "dependency check failed"
|
||||||
assert captured.value.outcome == "rejected"
|
assert captured.value.outcome == "rejected"
|
||||||
assert captured.value.diagnostics[0].code == "missing_source"
|
assert captured.value.diagnostics[0].code == "missing_source"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_artifact_snapshot_defensively_copies_nested_models() -> None:
|
||||||
|
artifact = _artifact()
|
||||||
|
|
||||||
|
exposed_artifact = artifact.artifact
|
||||||
|
exposed_workflow = artifact.workflow
|
||||||
|
exposed_artifact.id = "mutated"
|
||||||
|
exposed_artifact.plan["name"] = "mutated"
|
||||||
|
exposed_workflow.name = "mutated"
|
||||||
|
|
||||||
|
assert artifact.ref.artifact_id == "report"
|
||||||
|
assert artifact.inspect().name == "report"
|
||||||
|
assert artifact.edit().name == "report"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_deployment_snapshot_defensively_copies_model_and_diagnostics() -> None:
|
||||||
|
artifact = _artifact()
|
||||||
|
port = cast(_FakePort, artifact._port)
|
||||||
|
port.validation_result["diagnostics"] = [
|
||||||
|
{
|
||||||
|
"severity": "warning",
|
||||||
|
"code": "drift",
|
||||||
|
"logical_ref": "app.default",
|
||||||
|
"bound_source": "company.production",
|
||||||
|
"message": "original",
|
||||||
|
"repair_hint": None,
|
||||||
|
}
|
||||||
|
]
|
||||||
|
deployment = await artifact.deploy("report.production")
|
||||||
|
|
||||||
|
exposed_model = deployment.model
|
||||||
|
exposed_diagnostics = deployment.diagnostics
|
||||||
|
exposed_model.id = "mutated"
|
||||||
|
exposed_model.bindings = []
|
||||||
|
exposed_diagnostics[0].message = "mutated"
|
||||||
|
|
||||||
|
assert deployment.deployment_id == "report.production"
|
||||||
|
assert deployment.bindings == {"app.default": "company.production"}
|
||||||
|
assert deployment.diagnostics[0].message == "original"
|
||||||
|
await deployment.run({})
|
||||||
|
assert port.calls[-1][1]["deployment_id"] == "report.production"
|
||||||
|
|||||||
@@ -126,7 +126,8 @@ def test_repr_does_not_materialize_an_unbounded_iterable() -> None:
|
|||||||
|
|
||||||
|
|
||||||
def test_all_rich_objects_render_without_port_access() -> None:
|
def test_all_rich_objects_render_without_port_access() -> None:
|
||||||
port = cast(WorkflowClientPort, _port())
|
raw_port = _port()
|
||||||
|
port = cast(WorkflowClientPort, raw_port)
|
||||||
diagnostic = WorkflowDiagnostic("error", "bad", "state.x", "broken")
|
diagnostic = WorkflowDiagnostic("error", "bad", "state.x", "broken")
|
||||||
local = ValidationReport()
|
local = ValidationReport()
|
||||||
objects = [
|
objects = [
|
||||||
@@ -153,4 +154,4 @@ def test_all_rich_objects_render_without_port_access() -> None:
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
assert port.calls == []
|
assert raw_port.calls == []
|
||||||
|
|||||||
@@ -4,8 +4,9 @@ from typing import Any, cast
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from wf_client import App, Run, WorkflowClientPort
|
from wf_client import App, Run
|
||||||
from wf_client.errors import DeploymentNotRunnable, InvalidResponse
|
from wf_client.errors import DeploymentNotRunnable, InvalidResponse
|
||||||
|
from wf_client.protocols import WorkflowClientPort
|
||||||
|
|
||||||
|
|
||||||
def _payload(
|
def _payload(
|
||||||
@@ -135,6 +136,22 @@ async def test_resume_rejects_mismatched_result_id() -> None:
|
|||||||
await run.resume({"approved": True})
|
await run.resume({"approved": True})
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@pytest.mark.parametrize("operation", ["refresh", "resume"])
|
||||||
|
async def test_run_lifecycle_rejects_mismatched_deployment_identity(
|
||||||
|
operation: str,
|
||||||
|
) -> None:
|
||||||
|
port = _Port()
|
||||||
|
port.resume_payload["deployment_id"] = "other.deployment"
|
||||||
|
run = Run.from_payload(cast(WorkflowClientPort, port), _payload())
|
||||||
|
|
||||||
|
with pytest.raises(InvalidResponse, match="workflow.runs"):
|
||||||
|
if operation == "refresh":
|
||||||
|
await run.refresh()
|
||||||
|
else:
|
||||||
|
await run.resume({"approved": True})
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_malformed_interrupt_route_is_invalid_response() -> None:
|
async def test_malformed_interrupt_route_is_invalid_response() -> None:
|
||||||
payload = _payload()
|
payload = _payload()
|
||||||
@@ -180,3 +197,59 @@ async def test_trace_rejects_invalid_bounds_before_io() -> None:
|
|||||||
with pytest.raises(ValueError):
|
with pytest.raises(ValueError):
|
||||||
await run.trace(limit=101)
|
await run.trace(limit=101)
|
||||||
assert port.calls == []
|
assert port.calls == []
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("field", "value"),
|
||||||
|
[
|
||||||
|
("run_id", "other-run"),
|
||||||
|
("deployment_id", "other.deployment"),
|
||||||
|
("trace_start", 1),
|
||||||
|
("trace_limit", 26),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
async def test_trace_rejects_mismatched_identity_or_page(
|
||||||
|
field: str,
|
||||||
|
value: object,
|
||||||
|
) -> None:
|
||||||
|
port = _Port()
|
||||||
|
port.trace_payload[field] = value
|
||||||
|
run = Run.from_payload(cast(WorkflowClientPort, port), _payload())
|
||||||
|
|
||||||
|
with pytest.raises(InvalidResponse, match="workflow.runs.trace"):
|
||||||
|
await run.trace(start=0, limit=25)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_run_snapshot_defensively_copies_nested_public_values() -> None:
|
||||||
|
port = _Port()
|
||||||
|
payload = _payload()
|
||||||
|
payload["output"] = {"nested": {"value": "original"}}
|
||||||
|
payload["diagnostics"] = [
|
||||||
|
{
|
||||||
|
"severity": "warning",
|
||||||
|
"code": "drift",
|
||||||
|
"logical_ref": "app.default",
|
||||||
|
"bound_source": "company.production",
|
||||||
|
"message": "original",
|
||||||
|
"repair_hint": None,
|
||||||
|
}
|
||||||
|
]
|
||||||
|
run = Run.from_payload(cast(WorkflowClientPort, port), payload)
|
||||||
|
|
||||||
|
exposed_output = run.output
|
||||||
|
exposed_interrupt = run.interrupt
|
||||||
|
exposed_diagnostics = run.diagnostics
|
||||||
|
assert exposed_output is not None
|
||||||
|
assert exposed_interrupt is not None
|
||||||
|
exposed_output["nested"]["value"] = "mutated"
|
||||||
|
exposed_interrupt.payload["question"] = "mutated"
|
||||||
|
exposed_diagnostics[0].message = "mutated"
|
||||||
|
|
||||||
|
assert run.output == {"nested": {"value": "original"}}
|
||||||
|
assert run.interrupt is not None
|
||||||
|
assert run.interrupt.payload == {"question": "approve?"}
|
||||||
|
assert run.diagnostics[0].message == "original"
|
||||||
|
await run.resume({"approved": True})
|
||||||
|
assert port.calls[-1][1]["run_id"] == "run-1"
|
||||||
|
|||||||
@@ -18,7 +18,9 @@ def _manifest() -> ContractManifest:
|
|||||||
return manifest_from_openrpc(synthetic_openrpc_document())
|
return manifest_from_openrpc(synthetic_openrpc_document())
|
||||||
|
|
||||||
|
|
||||||
def test_write_generates_once_and_writes_requested_contract(monkeypatch, tmp_path: Path) -> None:
|
def test_write_generates_once_and_writes_requested_contract(
|
||||||
|
monkeypatch, tmp_path: Path
|
||||||
|
) -> None:
|
||||||
manifest = _manifest()
|
manifest = _manifest()
|
||||||
calls: list[tuple[object, Path]] = []
|
calls: list[tuple[object, Path]] = []
|
||||||
generate_calls = 0
|
generate_calls = 0
|
||||||
@@ -35,7 +37,10 @@ def test_write_generates_once_and_writes_requested_contract(monkeypatch, tmp_pat
|
|||||||
"wf_contract_manifest.__main__.write_manifest",
|
"wf_contract_manifest.__main__.write_manifest",
|
||||||
lambda value, path: calls.append((value, path)) or path,
|
lambda value, path: calls.append((value, path)) or path,
|
||||||
)
|
)
|
||||||
monkeypatch.setattr("wf_contract_manifest.__main__.DEFAULT_MANIFEST_PATH", tmp_path / "manifest.json")
|
monkeypatch.setattr(
|
||||||
|
"wf_contract_manifest.__main__.DEFAULT_MANIFEST_PATH",
|
||||||
|
tmp_path / "manifest.json",
|
||||||
|
)
|
||||||
|
|
||||||
assert main(["write"]) == 0
|
assert main(["write"]) == 0
|
||||||
assert calls == [(manifest, tmp_path / "manifest.json")]
|
assert calls == [(manifest, tmp_path / "manifest.json")]
|
||||||
|
|||||||
@@ -151,9 +151,7 @@ def test_manifest_separates_recursive_step_inputs_from_workflow_outputs() -> Non
|
|||||||
input_binding_schema = schemas["InputExpressionBinding"]
|
input_binding_schema = schemas["InputExpressionBinding"]
|
||||||
properties = input_binding_schema.get("properties")
|
properties = input_binding_schema.get("properties")
|
||||||
assert isinstance(properties, dict)
|
assert isinstance(properties, dict)
|
||||||
assert properties["expression"] == {
|
assert properties["expression"] == {"$ref": "#/components/schemas/InputExpression"}
|
||||||
"$ref": "#/components/schemas/InputExpression"
|
|
||||||
}
|
|
||||||
expression_schema = schemas["InputExpression"]
|
expression_schema = schemas["InputExpression"]
|
||||||
assert expression_schema["discriminator"] == {
|
assert expression_schema["discriminator"] == {
|
||||||
"mapping": {
|
"mapping": {
|
||||||
|
|||||||
@@ -177,9 +177,7 @@ def test_interrupted_saved_child_blocks_resume_until_pinned_source_returns(
|
|||||||
assert blocked["status"] == "interrupted"
|
assert blocked["status"] == "interrupted"
|
||||||
assert blocked["resume_readiness"] == "blocked"
|
assert blocked["resume_readiness"] == "blocked"
|
||||||
assert blocked["diagnostics"][0]["code"] == "source_disabled"
|
assert blocked["diagnostics"][0]["code"] == "source_disabled"
|
||||||
assert (
|
assert run_store.get_run(paused_run_id).resume_readiness is ResumeReadiness.BLOCKED
|
||||||
run_store.get_run(paused_run_id).resume_readiness is ResumeReadiness.BLOCKED
|
|
||||||
)
|
|
||||||
assert run_store.get_latest_checkpoint(paused_run_id).sequence == 1
|
assert run_store.get_latest_checkpoint(paused_run_id).sequence == 1
|
||||||
|
|
||||||
handlers.service.capability_sources["demo.personal"].enabled = True
|
handlers.service.capability_sources["demo.personal"].enabled = True
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import sys
|
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
@@ -32,25 +31,6 @@ from wf_transport_rpc_http.client.drafts import RpcDraftClientMixin
|
|||||||
from wf_transport_rpc_http.client.sources import RpcSourceAdminClientMixin
|
from wf_transport_rpc_http.client.sources import RpcSourceAdminClientMixin
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(autouse=True)
|
|
||||||
def _draft_enabled_composition(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
||||||
"""Opt draft-focused RPC client tests into the explicit draft surface."""
|
|
||||||
build_local = build_local_static_workflow_server
|
|
||||||
create_app = create_rpc_app
|
|
||||||
|
|
||||||
def draft_local(root, *args, **kwargs):
|
|
||||||
kwargs.setdefault("drafts", True)
|
|
||||||
return build_local(root, *args, **kwargs)
|
|
||||||
|
|
||||||
def draft_app(server, *args, **kwargs):
|
|
||||||
kwargs.setdefault("drafts", True)
|
|
||||||
return create_app(server, *args, **kwargs)
|
|
||||||
|
|
||||||
module = sys.modules[__name__]
|
|
||||||
monkeypatch.setattr(module, "build_local_static_workflow_server", draft_local)
|
|
||||||
monkeypatch.setattr(module, "create_rpc_app", draft_app)
|
|
||||||
|
|
||||||
|
|
||||||
async def test_rpc_client_preserves_structured_jsonrpc_error() -> None:
|
async def test_rpc_client_preserves_structured_jsonrpc_error() -> None:
|
||||||
def handler(request: httpx.Request) -> httpx.Response:
|
def handler(request: httpx.Request) -> httpx.Response:
|
||||||
return httpx.Response(
|
return httpx.Response(
|
||||||
@@ -354,8 +334,8 @@ async def test_rpc_workflow_client_lists_inspects_validates_and_deletes_deployme
|
|||||||
|
|
||||||
|
|
||||||
async def test_rpc_workflow_client_draft_workspace_lifecycle(tmp_path) -> None:
|
async def test_rpc_workflow_client_draft_workspace_lifecycle(tmp_path) -> None:
|
||||||
server = build_local_static_workflow_server(tmp_path / "store")
|
server = build_local_static_workflow_server(tmp_path / "store", drafts=True)
|
||||||
app = create_rpc_app(server)
|
app = create_rpc_app(server, drafts=True)
|
||||||
transport = httpx.ASGITransport(app=app)
|
transport = httpx.ASGITransport(app=app)
|
||||||
async with httpx.AsyncClient(
|
async with httpx.AsyncClient(
|
||||||
transport=transport, base_url="http://test"
|
transport=transport, base_url="http://test"
|
||||||
@@ -550,8 +530,8 @@ async def test_rpc_client_sends_exact_replace_document_payload() -> None:
|
|||||||
|
|
||||||
|
|
||||||
async def test_rpc_client_builds_capability_free_draft_lifecycle(tmp_path) -> None:
|
async def test_rpc_client_builds_capability_free_draft_lifecycle(tmp_path) -> None:
|
||||||
server = build_local_static_workflow_server(tmp_path / "store")
|
server = build_local_static_workflow_server(tmp_path / "store", drafts=True)
|
||||||
app = create_rpc_app(server)
|
app = create_rpc_app(server, drafts=True)
|
||||||
transport = httpx.ASGITransport(app=app)
|
transport = httpx.ASGITransport(app=app)
|
||||||
async with httpx.AsyncClient(
|
async with httpx.AsyncClient(
|
||||||
transport=transport,
|
transport=transport,
|
||||||
@@ -625,8 +605,8 @@ def test_rpc_client_satisfies_draft_surface_static_shape() -> None:
|
|||||||
|
|
||||||
|
|
||||||
async def test_rpc_workflow_client_deletes_draft_workspace(tmp_path) -> None:
|
async def test_rpc_workflow_client_deletes_draft_workspace(tmp_path) -> None:
|
||||||
server = build_local_static_workflow_server(tmp_path / "store")
|
server = build_local_static_workflow_server(tmp_path / "store", drafts=True)
|
||||||
app = create_rpc_app(server)
|
app = create_rpc_app(server, drafts=True)
|
||||||
transport = httpx.ASGITransport(app=app)
|
transport = httpx.ASGITransport(app=app)
|
||||||
async with httpx.AsyncClient(
|
async with httpx.AsyncClient(
|
||||||
transport=transport, base_url="http://test"
|
transport=transport, base_url="http://test"
|
||||||
@@ -772,8 +752,8 @@ async def test_rpc_client_validates_artifact_plan_without_persisting(tmp_path) -
|
|||||||
|
|
||||||
|
|
||||||
async def test_rpc_client_set_workflow_output_map(tmp_path) -> None:
|
async def test_rpc_client_set_workflow_output_map(tmp_path) -> None:
|
||||||
server = build_local_static_workflow_server(tmp_path / "store")
|
server = build_local_static_workflow_server(tmp_path / "store", drafts=True)
|
||||||
app = create_rpc_app(server)
|
app = create_rpc_app(server, drafts=True)
|
||||||
transport = httpx.ASGITransport(app=app)
|
transport = httpx.ASGITransport(app=app)
|
||||||
async with httpx.AsyncClient(
|
async with httpx.AsyncClient(
|
||||||
transport=transport, base_url="http://test"
|
transport=transport, base_url="http://test"
|
||||||
@@ -807,8 +787,8 @@ async def test_rpc_client_set_workflow_output_map(tmp_path) -> None:
|
|||||||
|
|
||||||
|
|
||||||
async def test_rpc_client_draft_workspace_focused_edit_methods(tmp_path) -> None:
|
async def test_rpc_client_draft_workspace_focused_edit_methods(tmp_path) -> None:
|
||||||
server = build_local_static_workflow_server(tmp_path / "store")
|
server = build_local_static_workflow_server(tmp_path / "store", drafts=True)
|
||||||
app = create_rpc_app(server)
|
app = create_rpc_app(server, drafts=True)
|
||||||
transport = httpx.ASGITransport(app=app)
|
transport = httpx.ASGITransport(app=app)
|
||||||
async with httpx.AsyncClient(
|
async with httpx.AsyncClient(
|
||||||
transport=transport, base_url="http://test"
|
transport=transport, base_url="http://test"
|
||||||
@@ -1069,8 +1049,8 @@ async def test_rpc_client_draft_remove_methods(tmp_path) -> None:
|
|||||||
|
|
||||||
|
|
||||||
async def test_rpc_client_draft_workspace_add_step_from_capability(tmp_path) -> None:
|
async def test_rpc_client_draft_workspace_add_step_from_capability(tmp_path) -> None:
|
||||||
server = build_local_static_workflow_server(tmp_path / "store")
|
server = build_local_static_workflow_server(tmp_path / "store", drafts=True)
|
||||||
app = create_rpc_app(server)
|
app = create_rpc_app(server, drafts=True)
|
||||||
transport = httpx.ASGITransport(app=app)
|
transport = httpx.ASGITransport(app=app)
|
||||||
async with httpx.AsyncClient(
|
async with httpx.AsyncClient(
|
||||||
transport=transport, base_url="http://test"
|
transport=transport, base_url="http://test"
|
||||||
|
|||||||
@@ -200,7 +200,7 @@ def _runtime_reuse_server(
|
|||||||
],
|
],
|
||||||
)
|
)
|
||||||
store_roots = config.store_roots
|
store_roots = config.store_roots
|
||||||
workflow_stores = file_workflow_stores(store_roots.workflow_root)
|
workflow_stores = file_workflow_stores(store_roots.workflow_root, drafts=True)
|
||||||
auth_store = FileAuthStore(store_roots.auth_root)
|
auth_store = FileAuthStore(store_roots.auth_root)
|
||||||
catalog_store = FileCatalogStore(store_roots.catalog_cache_root)
|
catalog_store = FileCatalogStore(store_roots.catalog_cache_root)
|
||||||
factory = _RecordingSessionFactory()
|
factory = _RecordingSessionFactory()
|
||||||
@@ -232,7 +232,7 @@ async def test_mcp_backed_rpc_lists_and_mutates_source_registry(tmp_path) -> Non
|
|||||||
SourceRegistryFile(sources=[_registry_entry("demo.registry")])
|
SourceRegistryFile(sources=[_registry_entry("demo.registry")])
|
||||||
)
|
)
|
||||||
server = build_workflow_server_from_config(config)
|
server = build_workflow_server_from_config(config)
|
||||||
app = create_rpc_app(server)
|
app = create_rpc_app(server, drafts=True)
|
||||||
transport = httpx.ASGITransport(app=app)
|
transport = httpx.ASGITransport(app=app)
|
||||||
|
|
||||||
async with httpx.AsyncClient(
|
async with httpx.AsyncClient(
|
||||||
@@ -255,7 +255,7 @@ async def test_mcp_backed_rpc_lists_and_mutates_source_registry(tmp_path) -> Non
|
|||||||
async def test_mcp_backed_rpc_capability_list_filters_by_source(tmp_path) -> None:
|
async def test_mcp_backed_rpc_capability_list_filters_by_source(tmp_path) -> None:
|
||||||
config = BrokerConfig(store_root=tmp_path / "store", connections=[])
|
config = BrokerConfig(store_root=tmp_path / "store", connections=[])
|
||||||
server = build_workflow_server_from_config(config)
|
server = build_workflow_server_from_config(config)
|
||||||
app = create_rpc_app(server)
|
app = create_rpc_app(server, drafts=True)
|
||||||
transport = httpx.ASGITransport(app=app)
|
transport = httpx.ASGITransport(app=app)
|
||||||
|
|
||||||
async with httpx.AsyncClient(
|
async with httpx.AsyncClient(
|
||||||
@@ -285,7 +285,7 @@ async def test_mcp_backed_rpc_reports_connections_and_events(tmp_path) -> None:
|
|||||||
],
|
],
|
||||||
)
|
)
|
||||||
server = build_workflow_server_from_config(config)
|
server = build_workflow_server_from_config(config)
|
||||||
app = create_rpc_app(server)
|
app = create_rpc_app(server, drafts=True)
|
||||||
transport = httpx.ASGITransport(app=app)
|
transport = httpx.ASGITransport(app=app)
|
||||||
|
|
||||||
async with httpx.AsyncClient(
|
async with httpx.AsyncClient(
|
||||||
@@ -299,7 +299,7 @@ async def test_mcp_backed_rpc_reports_connections_and_events(tmp_path) -> None:
|
|||||||
async def test_mcp_backed_rpc_applies_source_registry_changes(tmp_path) -> None:
|
async def test_mcp_backed_rpc_applies_source_registry_changes(tmp_path) -> None:
|
||||||
config = BrokerConfig(store_root=tmp_path / "store", connections=[])
|
config = BrokerConfig(store_root=tmp_path / "store", connections=[])
|
||||||
server = build_workflow_server_from_config(config)
|
server = build_workflow_server_from_config(config)
|
||||||
app = create_rpc_app(server)
|
app = create_rpc_app(server, drafts=True)
|
||||||
|
|
||||||
async with httpx.AsyncClient(
|
async with httpx.AsyncClient(
|
||||||
transport=httpx.ASGITransport(app=app),
|
transport=httpx.ASGITransport(app=app),
|
||||||
@@ -368,7 +368,7 @@ async def test_mcp_backed_rpc_can_be_built_from_neutral_workflow_config(
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
server = build_workflow_server_from_workflow_config(workflow_config)
|
server = build_workflow_server_from_workflow_config(workflow_config)
|
||||||
app = create_rpc_app(server)
|
app = create_rpc_app(server, drafts=True)
|
||||||
transport = httpx.ASGITransport(app=app)
|
transport = httpx.ASGITransport(app=app)
|
||||||
|
|
||||||
async with httpx.AsyncClient(
|
async with httpx.AsyncClient(
|
||||||
@@ -408,7 +408,7 @@ async def test_mcp_backed_rpc_resumes_interrupted_run_after_server_rebuild(
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
async with httpx.AsyncClient(
|
async with httpx.AsyncClient(
|
||||||
transport=httpx.ASGITransport(app=create_rpc_app(first_server)),
|
transport=httpx.ASGITransport(app=create_rpc_app(first_server, drafts=True)),
|
||||||
base_url="http://test",
|
base_url="http://test",
|
||||||
) as http_client:
|
) as http_client:
|
||||||
first_client = RpcWorkflowApiClient(
|
first_client = RpcWorkflowApiClient(
|
||||||
@@ -433,7 +433,7 @@ async def test_mcp_backed_rpc_resumes_interrupted_run_after_server_rebuild(
|
|||||||
|
|
||||||
rebuilt_server = build_workflow_server_from_workflow_config(workflow_config)
|
rebuilt_server = build_workflow_server_from_workflow_config(workflow_config)
|
||||||
async with httpx.AsyncClient(
|
async with httpx.AsyncClient(
|
||||||
transport=httpx.ASGITransport(app=create_rpc_app(rebuilt_server)),
|
transport=httpx.ASGITransport(app=create_rpc_app(rebuilt_server, drafts=True)),
|
||||||
base_url="http://test",
|
base_url="http://test",
|
||||||
) as http_client:
|
) as http_client:
|
||||||
rebuilt_client = RpcWorkflowApiClient(
|
rebuilt_client = RpcWorkflowApiClient(
|
||||||
@@ -463,7 +463,7 @@ async def test_mcp_backed_rpc_workflow_reuses_runtime_session_across_runs(
|
|||||||
assert factory.created_connections[0].id == "fixture.default"
|
assert factory.created_connections[0].id == "fixture.default"
|
||||||
|
|
||||||
async with httpx.AsyncClient(
|
async with httpx.AsyncClient(
|
||||||
transport=httpx.ASGITransport(app=create_rpc_app(server)),
|
transport=httpx.ASGITransport(app=create_rpc_app(server, drafts=True)),
|
||||||
base_url="http://test",
|
base_url="http://test",
|
||||||
) as http_client:
|
) as http_client:
|
||||||
client = RpcWorkflowApiClient(url="http://test/rpc", http_client=http_client)
|
client = RpcWorkflowApiClient(url="http://test/rpc", http_client=http_client)
|
||||||
@@ -597,7 +597,7 @@ async def test_mcp_backed_rpc_workflow_reuses_runtime_session_direct_setup(
|
|||||||
)
|
)
|
||||||
|
|
||||||
async with httpx.AsyncClient(
|
async with httpx.AsyncClient(
|
||||||
transport=httpx.ASGITransport(app=create_rpc_app(server)),
|
transport=httpx.ASGITransport(app=create_rpc_app(server, drafts=True)),
|
||||||
base_url="http://test",
|
base_url="http://test",
|
||||||
) as http_client:
|
) as http_client:
|
||||||
client = RpcWorkflowApiClient(url="http://test/rpc", http_client=http_client)
|
client = RpcWorkflowApiClient(url="http://test/rpc", http_client=http_client)
|
||||||
@@ -696,7 +696,7 @@ async def test_mcp_backed_rpc_deployment_becomes_unrunnable_after_source_removed
|
|||||||
)
|
)
|
||||||
|
|
||||||
async with httpx.AsyncClient(
|
async with httpx.AsyncClient(
|
||||||
transport=httpx.ASGITransport(app=create_rpc_app(server)),
|
transport=httpx.ASGITransport(app=create_rpc_app(server, drafts=True)),
|
||||||
base_url="http://test",
|
base_url="http://test",
|
||||||
) as http_client:
|
) as http_client:
|
||||||
client = RpcWorkflowApiClient(url="http://test/rpc", http_client=http_client)
|
client = RpcWorkflowApiClient(url="http://test/rpc", http_client=http_client)
|
||||||
@@ -748,7 +748,7 @@ async def test_mcp_backed_rpc_workflow_reuses_real_stdio_fixture_session(
|
|||||||
)
|
)
|
||||||
|
|
||||||
async with httpx.AsyncClient(
|
async with httpx.AsyncClient(
|
||||||
transport=httpx.ASGITransport(app=create_rpc_app(server)),
|
transport=httpx.ASGITransport(app=create_rpc_app(server, drafts=True)),
|
||||||
base_url="http://test",
|
base_url="http://test",
|
||||||
) as http_client:
|
) as http_client:
|
||||||
client = RpcWorkflowApiClient(url="http://test/rpc", http_client=http_client)
|
client = RpcWorkflowApiClient(url="http://test/rpc", http_client=http_client)
|
||||||
|
|||||||
Reference in New Issue
Block a user