diff --git a/contracts/workflow-api.manifest.json b/contracts/workflow-api.manifest.json
index 8bf4f81f..152450cb 100644
--- a/contracts/workflow-api.manifest.json
+++ b/contracts/workflow-api.manifest.json
@@ -218,6 +218,45 @@
],
"type": "string"
},
+ "ArtifactPlanDiagnosticPayload": {
+ "description": "Stable diagnostic projected when an artifact plan is invalid.",
+ "properties": {
+ "code": {
+ "type": "string"
+ },
+ "message": {
+ "type": "string"
+ },
+ "path": {
+ "type": "string"
+ },
+ "repair_hint": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ]
+ },
+ "severity": {
+ "enum": [
+ "error",
+ "warning"
+ ],
+ "type": "string"
+ }
+ },
+ "required": [
+ "severity",
+ "code",
+ "path",
+ "message",
+ "repair_hint"
+ ],
+ "type": "object"
+ },
"AuthRecordSummaryPayload": {
"additionalProperties": false,
"description": "Auth record summary without credential payload values.\n\n``metadata`` is explicitly non-secret display data. Credential material\nbelongs in the omitted auth payload and is represented only by key names.",
@@ -4387,6 +4426,43 @@
],
"type": "object"
},
+ "ValidateArtifactPlanResult": {
+ "description": "Non-persisting artifact-plan validation and dependency inventory.",
+ "properties": {
+ "diagnostics": {
+ "items": {
+ "$ref": "#/components/schemas/ArtifactPlanDiagnosticPayload"
+ },
+ "type": "array"
+ },
+ "required_capabilities": {
+ "items": {
+ "$ref": "#/components/schemas/RequiredCapabilityPayload"
+ },
+ "type": "array"
+ },
+ "status": {
+ "enum": [
+ "valid",
+ "invalid"
+ ],
+ "type": "string"
+ },
+ "workflow_dependencies": {
+ "additionalProperties": {
+ "type": "integer"
+ },
+ "type": "object"
+ }
+ },
+ "required": [
+ "status",
+ "diagnostics",
+ "required_capabilities",
+ "workflow_dependencies"
+ ],
+ "type": "object"
+ },
"ValidateDeploymentResult": {
"properties": {
"artifact_id": {
@@ -5714,6 +5790,81 @@
}
}
},
+ {
+ "action": "validate_plan",
+ "errors": [
+ {
+ "$ref": "#/components/errors/5000"
+ }
+ ],
+ "method": "workflow.artifacts.validate_plan",
+ "namespace": [
+ "workflow",
+ "artifacts"
+ ],
+ "params": [
+ {
+ "name": "plan",
+ "required": true,
+ "schema": {
+ "additionalProperties": true,
+ "type": "object"
+ }
+ },
+ {
+ "name": "outcomes",
+ "required": true,
+ "schema": {
+ "items": {
+ "type": "string"
+ },
+ "type": "array"
+ }
+ },
+ {
+ "name": "required_capabilities",
+ "required": false,
+ "schema": {
+ "anyOf": [
+ {
+ "additionalProperties": {
+ "additionalProperties": true,
+ "type": "object"
+ },
+ "type": "object"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "default": null
+ }
+ },
+ {
+ "name": "source_bindings",
+ "required": false,
+ "schema": {
+ "anyOf": [
+ {
+ "additionalProperties": {
+ "type": "string"
+ },
+ "type": "object"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "default": null
+ }
+ }
+ ],
+ "result": {
+ "schema": {
+ "$ref": "#/components/schemas/ValidateArtifactPlanResult"
+ }
+ }
+ },
{
"action": "call",
"errors": [
diff --git a/docs/current_roadmap.md b/docs/current_roadmap.md
index 2bc3d149..e4908eae 100644
--- a/docs/current_roadmap.md
+++ b/docs/current_roadmap.md
@@ -22,6 +22,18 @@ The durable product path is now `wf-rpc-server` plus neutral `wf_config` /
`wf_server` composition. The old `wf-mcp` script remains a legacy/special-purpose
MCP entrypoint and compatibility surface.
+Delivered, with the repository-wide verification gate still open: the async
+`wf_client` Python slice is verified against the real JSON-RPC ASGI application.
+It covers capability discovery, local graph authoring, remote validation,
+immutable artifact save, deployment selection, and durable run execution. Rich
+client objects have bounded, secret-safe `repr()` and `_repr_html_()` views that
+never perform remote I/O. Draft workspaces remain a separate server/admin
+surface and are not part of the client; server registration is explicit so the
+artifact -> deployment -> run path does not require draft storage. All
+slice-owned checks pass, while the full repository suite still has its
+pre-existing missing-thesis-PDF failure; mark this slice complete only when that
+repository asset gate also passes.
+
## Active Initiative: Workflow Console And Defense Demo
The next product-facing push is a local-first web console and defense demo that
diff --git a/docs/project_map.md b/docs/project_map.md
index f807179a..00d26321 100644
--- a/docs/project_map.md
+++ b/docs/project_map.md
@@ -25,6 +25,7 @@ For verified Python 3.14 dependency constraints and their removal criteria, see
| `wf_api` | Workflow application surface over core/artifacts/platform: capabilities, drafts, artifacts, deployments, runs, and source/admin surfaces. | `wf_cli`, `wf_server`, JSON-RPC clients, future transports. |
| `wf_server` | Durable server composition boundary around `WorkflowApi` plus optional admin/source-registry surfaces. Owns the `wf-rpc-server` startup CLI/policy. | Transport packages and server startup code. |
| `wf_transport_rpc_http` | JSON-RPC-over-HTTP app/client and compatibility CLI shim. | Remote `wf` clients and local server smoke tests. |
+| `wf_client` | Async-native Python client for capability discovery, local authoring, immutable artifacts, deployments, and durable runs. | Python applications and notebooks using a workflow server. |
| `wf_sources_mcp` | MCP-as-upstream-source implementation: ids, registry DTOs, auth/catalog stores, discovery, SDK client/facade, runtime pool, wrappers. | `wf_server`, broker glue, MCP source tests. |
| `wf_mcp` | MCP frontend/compatibility package: legacy `wf-mcp` entrypoints, broker glue, proxy/admin tools, and shims while extraction continues. | Compatibility callers and MCP transport work. |
| `wf_cli` | Command-line frontend over local or remote workflow APIs. | Humans, scripts, agent skills. |
@@ -87,6 +88,8 @@ permanent graph node or expose it as a final workflow-output source.
- `wf_server.WorkflowServer`: durable workflow server composition object.
- `wf_transport_rpc_http.RpcWorkflowApiClient`: JSON-RPC client implementing
the workflow/admin surfaces over HTTP.
+- `wf_client.App`: transport-independent async Python facade over capabilities,
+ authored workflows, saved artifacts, deployments, and durable runs.
- `wf_transport_rpc_http.create_rpc_app`: JSON-RPC HTTP adapter over an existing
`WorkflowServer`.
- `wf_sources_mcp.McpRuntimePool`: persistent MCP source runtime for stateful
@@ -121,6 +124,57 @@ permanent graph node or expose it as a final workflow-output source.
`--keep-temp` to preserve the generated config/store on failure.
- `examples/browser_click_workflow/` is a serial browser-click workflow
example with bounded before/after snapshots and full lifecycle tests.
+
+### Python client walkthrough
+
+The Python client is intended for an application that already has a running
+workflow server. Hypothetically, an application that wants to turn a constant
+capability into a durable run would use this complete call shape; the schema
+arguments may be JSON Schema dictionaries or the application's schema model
+values:
+
+```python
+from wf_client import App
+from wf_authoring import input_from, input_value, output_to, state_path
+
+app = App.from_http_jsonrpc("http://localhost:8765/rpc")
+capability = await app.capability("wf.std.constant")
+graph = app.new_workflow(
+ "example",
+ input_schema={"type": "object", "properties": {}},
+ state_schema={"type": "object", "properties": {"value": {"type": "string"}}},
+ output_schema={
+ "type": "object",
+ "properties": {"value": {"type": "string"}},
+ "required": ["value"],
+ },
+)
+step = graph.use(
+ capability,
+ id="constant",
+ input=[input_value("value", "hello")],
+ output=[output_to("value", state_path("value"))],
+)
+end = graph.end("ok", id="end_ok")
+graph.set_entry_point(step)
+graph.connect(step, "ok", end)
+graph.set_output([input_from(state_path("value"), "value")])
+validation = await graph.validate()
+validation.raise_for_errors()
+artifact = await graph.save(version=1)
+run = await artifact.run({})
+```
+
+The graph is a local, mutable builder. `validate()` checks its structure locally
+and then asks the server to validate the serialized plan. `save()` persists an
+immutable artifact version; it does not deploy or execute the graph.
+`artifact.run()` selects or creates a deployment, validates its source bindings,
+and starts a durable run. The returned run is a loaded snapshot; call
+`refresh()`, `resume()`, or bounded `trace(start=..., limit=...)` when more
+server state is needed.
+
+Draft workspaces are intentionally not part of `wf_client`. They are a separate
+server/admin surface and must be explicitly enabled when composing a server.
- `examples/agent_challenges/` contains reusable opencode challenge harnesses
for evaluating whether agents can use the public workflow CLI/server path.
diff --git a/docs/source_architecture.md b/docs/source_architecture.md
index a91bdf46..ed964a71 100644
--- a/docs/source_architecture.md
+++ b/docs/source_architecture.md
@@ -51,6 +51,7 @@ OpenAPI, or a built-in source. It should see `CapabilitySource` and executable
| `wf_sources_python` | Trusted Python module registry loading and projection to `CapabilitySource`. | Authoring primitives, registry mutation/apply, sandboxing. |
| `wf_server` | `WorkflowServer` composition from config/store/source providers. | JSON-RPC method definitions, MCP protocol frontend. |
| `wf_transport_rpc_http` | JSON-RPC HTTP app/client around an existing `WorkflowServer`. | Server startup policy, source-provider composition. |
+| `wf_client` | Async Python facade over a narrow client port: capability discovery, local authoring, artifact/deployment snapshots, and durable runs. | Draft workspace authoring, server composition, transport registration. |
| `wf_mcp` | Legacy/special-purpose MCP frontend, broker glue, proxy, compatibility shims. | New durable product behavior unless explicitly retiring old callers. |
## Data Flow
@@ -65,6 +66,17 @@ wf_config.server.sources[]
-> transport or CLI
```
+Python applications consume the same server through `wf_client.App`; the
+client's local builder and immutable snapshots sit above the transport:
+
+```text
+wf_client.App
+ -> WorkflowClientPort
+ -> RpcWorkflowApiClient
+ -> JSON-RPC HTTP
+ -> WorkflowServer / WorkflowApi
+```
+
The first shared provider seam is intentionally static:
```python
@@ -212,3 +224,18 @@ Then add:
Do not add source-family branches inside `wf_api` run execution. Source-specific
logic belongs in the provider package or server composition layer.
+
+## Python client boundary
+
+`wf_client` is a consumer-facing layer, not another server API.
+`App.from_http_jsonrpc()` creates a lazy HTTP transport; the first
+capability/artifact/deployment/run operation performs I/O. `EditableWorkflow`
+keeps graph construction local and uses the port only for remote validation and
+artifact persistence. The server remains responsible for durable artifact,
+deployment, and run stores and for resolving concrete source bindings.
+
+The client intentionally omits draft workspace operations. Drafts are a
+server-side authoring/admin surface, and a server composition must opt in to
+registering their JSON-RPC methods. This keeps a normal Python application
+focused on the stable artifact -> deployment -> run lifecycle while retaining
+the underlying draft implementation for explicit server users.
diff --git a/docs/superpowers/plans/2026-08-30-python-workflow-client.md b/docs/superpowers/plans/2026-08-30-python-workflow-client.md
index 87f5b561..7a0be881 100644
--- a/docs/superpowers/plans/2026-08-30-python-workflow-client.md
+++ b/docs/superpowers/plans/2026-08-30-python-workflow-client.md
@@ -1301,7 +1301,13 @@ artifact = await graph.save(version=1)
run = await artifact.run({})
```
-State explicitly that drafts are not part of `wf_client` but remain registered in the server until the separate draft opt-out plan is executed. Update `docs/current_roadmap.md` to mark the Python-client slice complete only after every verification step below passes.
+Drafts are not part of `wf_client`. The shipped server composition uses
+`drafts=False` by default, which excludes draft storage, domain modules, and RPC
+registration; real legacy draft consumers opt in explicitly with `drafts=True`.
+The earlier plan assumption that draft methods would remain registered pending a
+separate opt-out slice is historical. Update `docs/current_roadmap.md` to mark
+the Python-client slice complete only after every verification step below
+passes.
- [ ] **Step 6: Run focused and cross-layer verification**
diff --git a/docs/superpowers/specs/2026-08-30-python-workflow-client-design.md b/docs/superpowers/specs/2026-08-30-python-workflow-client-design.md
index fe7ab62b..96e10574 100644
--- a/docs/superpowers/specs/2026-08-30-python-workflow-client-design.md
+++ b/docs/superpowers/specs/2026-08-30-python-workflow-client-design.md
@@ -633,12 +633,11 @@ capability, expose secrets, or fetch an unbounded trace.
authoring compiles a complete workflow and saves it through the existing
artifact-from-plan operation.
-Making draft support uninitialized by default is a separate server-composition
-slice. Today `WorkflowApi` constructs draft modules unconditionally, durable
-context validation requires a draft store, and the JSON-RPC app always
-registers draft methods. That follow-up must make draft storage, domain modules,
-and RPC registration opt-in without weakening artifact, deployment, or run
-durability.
+Draft support is uninitialized by default across storage, domain modules, and
+JSON-RPC registration. Normal composition uses `drafts=False`; callers that
+still operate the legacy draft workspace surface must opt in explicitly with
+`drafts=True`. This keeps artifact, deployment, and run durability independent
+from draft storage.
## Testing Strategy
diff --git a/docs/wf_api_architecture.md b/docs/wf_api_architecture.md
index 3131c5c8..0c18acc1 100644
--- a/docs/wf_api_architecture.md
+++ b/docs/wf_api_architecture.md
@@ -20,6 +20,7 @@ frontends can share.
| `wf_sources_mcp` | MCP-as-upstream-source implementation: source ids, source registry DTOs, auth/catalog stores, discovery, SDK client/facade, persistent runtime pool, and tool-wrapper helpers. |
| `wf_mcp` | MCP frontend/compatibility package: old `wf-mcp` server entry points, broker glue around MCP-hosted services, proxy/admin tools, and compatibility shims while callers migrate. |
| `wf_transport_rpc_http` | JSON-RPC-over-HTTP transport adapter and remote client over `WorkflowApiSurface`, not a reimplementation of workflow business logic. |
+| `wf_client` | Async Python consumer facade over a narrow capability/artifact/deployment/run port. It reconstructs immutable snapshots and keeps representations bounded and inert. |
| future `wf_http` / WebSocket / MCP server transports | Additional transports over `WorkflowApiSurface`, not new workflow application APIs. |
| `wf_cli` | CLI frontend over `WorkflowApiSurface`; it may run locally against process-local stores or target a remote JSON-RPC backend. |
@@ -107,6 +108,56 @@ Important rules:
Do not add a catch-all `service` field to the context. If a domain API needs a
new dependency, add a narrow protocol or explicit field.
+## Python client lifecycle
+
+Hypothetically, an application that wants to turn a discovered capability into
+a durable run would use the following complete flow:
+
+```python
+from wf_client import App
+from wf_authoring import input_from, input_value, output_to, state_path
+
+app = App.from_http_jsonrpc("http://localhost:8765/rpc")
+capability = await app.capability("wf.std.constant")
+graph = app.new_workflow(
+ "example",
+ input_schema={"type": "object", "properties": {}},
+ state_schema={"type": "object", "properties": {"value": {"type": "string"}}},
+ output_schema={
+ "type": "object",
+ "properties": {"value": {"type": "string"}},
+ "required": ["value"],
+ },
+)
+step = graph.use(
+ capability,
+ id="constant",
+ input=[input_value("value", "hello")],
+ output=[output_to("value", state_path("value"))],
+)
+end = graph.end("ok", id="end_ok")
+graph.set_entry_point(step)
+graph.connect(step, "ok", end)
+graph.set_output([input_from(state_path("value"), "value")])
+validation = await graph.validate()
+validation.raise_for_errors()
+artifact = await graph.save(version=1)
+run = await artifact.run({})
+```
+
+The graph is an in-process builder. Validation is local structural checking
+plus a server plan check. Saving creates an immutable, versioned artifact; it
+does not execute anything. A deployment is the server's runnable configuration
+for one exact artifact version, including logical-to-concrete source bindings
+and drift policy. A run is a durable execution record for that deployment;
+inspection and bounded trace reads return snapshots, while resume is an
+explicit operation for interrupted runs.
+
+`wf_client` does not expose draft workspaces. Draft API classes remain useful
+to server/admin and console callers, but normal server composition keeps draft
+JSON-RPC registration opt-in so artifact, deployment, and run durability do not
+depend on a draft store.
+
## WorkflowApiSurface And Domain Services
`WorkflowApiSurface` is the public application contract shared by local and
@@ -154,8 +205,8 @@ contract itself.
```text
WorkflowApi
capabilities: WorkflowCapabilityApi
- drafts: WorkflowDraftApi
- draft_authoring: WorkflowDraftAuthoringApi
+ drafts: WorkflowDraftApi | None # only when drafts=True
+ draft_authoring: WorkflowDraftAuthoringApi | None # only when drafts=True
artifacts: WorkflowArtifactApi
deployments: WorkflowDeploymentApi
runs: WorkflowRunApi
diff --git a/examples/lda_report_workflow/build_workflow.py b/examples/lda_report_workflow/build_workflow.py
index 5585600e..d5a97569 100644
--- a/examples/lda_report_workflow/build_workflow.py
+++ b/examples/lda_report_workflow/build_workflow.py
@@ -19,12 +19,7 @@ WORKFLOW_OUTPUT = [
def build_workflow() -> Workflow:
- """Build the demo workflow with the public authoring API.
-
- `WorkflowBuilder` does not yet expose a workflow-output setter, so this
- module adds the final output projection in `_with_workflow_output()` after
- compiling the graph. Keep that seam small and validated.
- """
+ """Build the demo workflow with the public authoring API."""
builder = WorkflowBuilder(
name="lda_report_case_study",
input_schema={
@@ -185,13 +180,8 @@ def build_workflow() -> Workflow:
builder.connect(create_issues, "ok", finalise)
builder.connect(finalise, "ok", end_completed)
builder.connect(revision_requested, "ok", end_cancelled)
- return _with_workflow_output(builder.compile())
-
-
-def _with_workflow_output(workflow: Workflow) -> Workflow:
- payload = workflow.model_dump(mode="json", by_alias=True)
- payload["output"] = WORKFLOW_OUTPUT
- return Workflow.model_validate(payload)
+ builder.set_output(WORKFLOW_OUTPUT)
+ return builder.compile()
def workflow_plan_payload() -> dict[str, Any]:
diff --git a/src/wf_api/artifacts.py b/src/wf_api/artifacts.py
index 97c1f9f9..37bf068e 100644
--- a/src/wf_api/artifacts.py
+++ b/src/wf_api/artifacts.py
@@ -9,10 +9,13 @@ from __future__ import annotations
from collections.abc import Mapping, Sequence
from typing import Any
+from pydantic import ValidationError
+
from wf_artifacts import (
ArtifactKind,
RequiredCapability,
WorkflowArtifact,
+ WorkflowPlanValidationError,
artifact_catalog_entry,
)
from wf_artifacts import (
@@ -25,6 +28,7 @@ from .capability_requirements import observed_node_specs
from .drafts import WorkflowDraftApi
from .listing import matches_query, paged_list_payload
from .models import (
+ ArtifactPlanDiagnosticPayload,
CreateArtifactFromWorkspaceResult,
DeleteArtifactResult,
JsonProjector,
@@ -33,6 +37,7 @@ from .models import (
SaveArtifactResult,
SavedDraftArtifactResult,
UnsavedDraftArtifactResult,
+ ValidateArtifactPlanResult,
WorkflowArtifactPayload,
)
from .operation_context import WorkflowOperationContext
@@ -41,10 +46,82 @@ _PROJECT_ARTIFACT = JsonProjector(WorkflowArtifactPayload)
_PROJECT_ARTIFACT_LIST = JsonProjector(ListArtifactsResult)
_PROJECT_ARTIFACT_SAVE = JsonProjector(SaveArtifactResult)
_PROJECT_ARTIFACT_DELETE = JsonProjector(DeleteArtifactResult)
+_PROJECT_VALIDATE_ARTIFACT = JsonProjector(ValidateArtifactPlanResult)
_PROJECT_UNSAVED_DRAFT_ARTIFACT = JsonProjector(UnsavedDraftArtifactResult)
_PROJECT_SAVED_DRAFT_ARTIFACT = JsonProjector(SavedDraftArtifactResult)
+def _prepare_artifact_from_plan(
+ context: WorkflowOperationContext,
+ *,
+ artifact_id: str,
+ version: int,
+ title: str,
+ kind: ArtifactKind,
+ description: str | None,
+ plan: RawWorkflowPlan | dict[str, Any],
+ outcomes: Sequence[str],
+ required_capabilities: Mapping[str, RequiredCapability | dict[str, Any]] | None,
+ source_bindings: dict[str, str] | None,
+ created_from_catalog_version: str | None,
+) -> WorkflowArtifact:
+ """Prepare one artifact through the shared plan normalization seam."""
+ typed_plan = (
+ plan
+ if isinstance(plan, RawWorkflowPlan)
+ else RawWorkflowPlan.model_validate(plan)
+ )
+ return build_workflow_artifact_from_plan(
+ artifact_id=artifact_id,
+ version=version,
+ title=title,
+ kind=kind,
+ description=description,
+ plan=typed_plan.model_dump(mode="json", by_alias=True),
+ outcomes=tuple(outcomes),
+ required_capabilities={
+ name: (
+ capability
+ if isinstance(capability, RequiredCapability)
+ else RequiredCapability.model_validate(capability)
+ )
+ for name, capability in (required_capabilities or {}).items()
+ },
+ source_bindings=source_bindings,
+ observed_node_specs=observed_node_specs(context),
+ created_from_catalog_version=created_from_catalog_version,
+ )
+
+
+def _invalid_artifact_plan_payload(
+ diagnostic: ArtifactPlanDiagnosticPayload,
+) -> dict[str, Any]:
+ """Build the complete invalid result, including empty derived inventories."""
+ return {
+ "status": "invalid",
+ "diagnostics": [diagnostic],
+ "required_capabilities": [],
+ "workflow_dependencies": {},
+ }
+
+
+def _diagnostic_from_validation_error(
+ exc: ValidationError,
+ *,
+ root: str = "plan",
+) -> ArtifactPlanDiagnosticPayload:
+ """Project the first typed model error beneath its request-field root."""
+ error = exc.errors()[0]
+ location = ".".join(str(part) for part in error["loc"])
+ return {
+ "severity": "error",
+ "code": "artifact_plan_invalid",
+ "path": f"{root}.{location}" if location else root,
+ "message": str(error["msg"]),
+ "repair_hint": None,
+ }
+
+
class WorkflowArtifactApi:
"""Saved workflow artifact operations.
@@ -52,9 +129,19 @@ class WorkflowArtifactApi:
WorkflowOperationContext so this module stays protocol-neutral.
"""
- def __init__(self, context: WorkflowOperationContext) -> None:
+ def __init__(
+ self, context: WorkflowOperationContext, *, drafts: bool = False
+ ) -> None:
self.context = context
- self.drafts = WorkflowDraftApi(context)
+ self.drafts: WorkflowDraftApi | None = (
+ WorkflowDraftApi(context) if drafts else None
+ )
+
+ def _require_drafts(self) -> WorkflowDraftApi:
+ """Return draft helpers for the explicitly enabled authoring surface."""
+ if self.drafts is None:
+ raise ValueError("workflow draft APIs are disabled; pass drafts=True")
+ return self.drafts
def _artifact_store(self):
if self.context.artifact_store is None:
@@ -133,25 +220,17 @@ class WorkflowArtifactApi:
source_bindings: dict[str, str] | None = None,
created_from_catalog_version: str | None = None,
) -> SaveArtifactResult:
- typed_plan = (
- plan
- if isinstance(plan, RawWorkflowPlan)
- else RawWorkflowPlan.model_validate(plan)
- )
- workflow_artifact = build_workflow_artifact_from_plan(
+ workflow_artifact = _prepare_artifact_from_plan(
+ self.context,
artifact_id=artifact_id,
version=version,
title=title,
kind=kind,
description=description,
- plan=typed_plan.model_dump(mode="json", by_alias=True),
- outcomes=tuple(outcomes),
- required_capabilities={
- name: RequiredCapability.model_validate(capability)
- for name, capability in (required_capabilities or {}).items()
- },
+ plan=plan,
+ outcomes=outcomes,
+ required_capabilities=required_capabilities,
source_bindings=source_bindings,
- observed_node_specs=observed_node_specs(self.context),
created_from_catalog_version=created_from_catalog_version,
)
self._artifact_store().save_artifact(workflow_artifact)
@@ -172,6 +251,80 @@ class WorkflowArtifactApi:
}
)
+ 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:
+ """Validate and inventory a plan without writing the artifact store."""
+ try:
+ typed_plan = RawWorkflowPlan.model_validate(plan)
+ except ValidationError as exc:
+ return _PROJECT_VALIDATE_ARTIFACT(
+ _invalid_artifact_plan_payload(_diagnostic_from_validation_error(exc))
+ )
+
+ typed_requirements: dict[str, RequiredCapability] = {}
+ for name, capability in (required_capabilities or {}).items():
+ try:
+ typed_requirements[name] = RequiredCapability.model_validate(capability)
+ except ValidationError as exc:
+ return _PROJECT_VALIDATE_ARTIFACT(
+ _invalid_artifact_plan_payload(
+ _diagnostic_from_validation_error(
+ exc,
+ root=f"required_capabilities.{name}",
+ )
+ )
+ )
+
+ try:
+ # These identity fields satisfy the shared artifact factory only;
+ # validation never calls the store or emits a saved-artifact event.
+ artifact = _prepare_artifact_from_plan(
+ self.context,
+ artifact_id="__validation__",
+ version=1,
+ title="Validation",
+ kind="workflow",
+ description=None,
+ plan=typed_plan,
+ outcomes=outcomes,
+ required_capabilities=typed_requirements,
+ source_bindings=source_bindings,
+ created_from_catalog_version=None,
+ )
+ except ValidationError as exc:
+ return _PROJECT_VALIDATE_ARTIFACT(
+ _invalid_artifact_plan_payload(_diagnostic_from_validation_error(exc))
+ )
+ except WorkflowPlanValidationError as exc:
+ return _PROJECT_VALIDATE_ARTIFACT(
+ _invalid_artifact_plan_payload(
+ {
+ "severity": "error",
+ "code": "artifact_plan_invalid",
+ "path": "plan",
+ "message": str(exc),
+ "repair_hint": None,
+ }
+ )
+ )
+ return _PROJECT_VALIDATE_ARTIFACT(
+ {
+ "status": "valid",
+ "diagnostics": [],
+ "required_capabilities": [
+ capability.model_dump(mode="json")
+ for capability in artifact.required_capability_map().values()
+ ],
+ "workflow_dependencies": dict(artifact.workflow_dependencies),
+ }
+ )
+
async def create_artifact_from_draft(
self,
*,
@@ -253,7 +406,7 @@ class WorkflowArtifactApi:
if store is None:
raise KeyError("draft workspace store is not configured")
workspace = store.get_workspace(workspace_id)
- validation = await self.drafts.validate_draft(draft=workspace.draft)
+ validation = await self._require_drafts().validate_draft(draft=workspace.draft)
if validation["status"] != "valid":
return _PROJECT_UNSAVED_DRAFT_ARTIFACT(
{
diff --git a/src/wf_api/capabilities.py b/src/wf_api/capabilities.py
index 301b28d9..c5d2ddf6 100644
--- a/src/wf_api/capabilities.py
+++ b/src/wf_api/capabilities.py
@@ -98,10 +98,24 @@ class WorkflowCapabilityApi:
tool schemas stay outside wf_api.
"""
- def __init__(self, context: WorkflowOperationContext) -> None:
+ def __init__(
+ self, context: WorkflowOperationContext, *, drafts: bool = False
+ ) -> None:
self.context = context
- self.drafts = WorkflowDraftApi(context)
- self.draft_authoring = WorkflowDraftAuthoringApi(context, self.drafts)
+ self.drafts: WorkflowDraftApi | None = (
+ WorkflowDraftApi(context) if drafts else None
+ )
+ self.draft_authoring: WorkflowDraftAuthoringApi | None = (
+ WorkflowDraftAuthoringApi(context, self.drafts)
+ if self.drafts is not None
+ else None
+ )
+
+ def _require_draft_authoring(self) -> WorkflowDraftAuthoringApi:
+ """Return draft helpers for the explicitly enabled authoring surface."""
+ if self.draft_authoring is None:
+ raise ValueError("workflow draft APIs are disabled; pass drafts=True")
+ return self.draft_authoring
async def list_capabilities(
self,
@@ -441,7 +455,7 @@ class WorkflowCapabilityApi:
# Validate capability-derived guidance before workspace creation. The
# workspace result is already projected by the draft-workspace API.
hints = _PROJECT_WRAPPER_HINTS(capability["wrapper_hints"])
- result = await self.draft_authoring.create_minimal_draft_workspace(
+ result = await self._require_draft_authoring().create_minimal_draft_workspace(
workspace_id=workspace_id,
name=name or _draft_name_from_capability(capability_name),
capability_name=capability_name,
diff --git a/src/wf_api/deployments.py b/src/wf_api/deployments.py
index 83665f0c..bb1d15f8 100644
--- a/src/wf_api/deployments.py
+++ b/src/wf_api/deployments.py
@@ -62,9 +62,7 @@ class WorkflowDeploymentApi:
.model_dump(mode="json"),
)
- async def save_deployment(
- self, deployment: dict[str, Any]
- ) -> SaveDeploymentResult:
+ async def save_deployment(self, deployment: dict[str, Any]) -> SaveDeploymentResult:
workflow_deployment = WorkflowDeployment.model_validate(deployment)
self._artifact_store().save_deployment(workflow_deployment)
self.context.events.record_workflow_event(
@@ -83,9 +81,7 @@ class WorkflowDeploymentApi:
"saved": True,
}
- async def delete_deployment(
- self, *, deployment_id: str
- ) -> DeleteDeploymentResult:
+ async def delete_deployment(self, *, deployment_id: str) -> DeleteDeploymentResult:
"""Delete one mutable deployment environment binding."""
self._artifact_store().delete_deployment(deployment_id)
self.context.events.record_workflow_event(
diff --git a/src/wf_api/durable_context.py b/src/wf_api/durable_context.py
index 026e390f..ef62f5b3 100644
--- a/src/wf_api/durable_context.py
+++ b/src/wf_api/durable_context.py
@@ -5,25 +5,27 @@ from .service import WorkflowApi
from .stores import WorkflowStores
-def require_workflow_stores(context: WorkflowOperationContext) -> WorkflowStores:
+def require_workflow_stores(
+ context: WorkflowOperationContext,
+ *,
+ drafts: bool = False,
+) -> WorkflowStores:
"""Return required stores or fail before constructing durable frontends.
`WorkflowOperationContext` keeps stores optional for compatibility tests and
- lightweight MCP surfaces. Durable API surfaces need all stores up front so a
- run cannot start without somewhere to persist artifacts, drafts, and stopped
- execution state.
+ lightweight MCP surfaces. Durable API surfaces need artifact/run stores up
+ front; a draft store is only required when draft APIs are enabled.
"""
missing = []
if context.artifact_store is None:
missing.append("artifact_store")
- if context.draft_workspace_store is None:
+ if drafts and context.draft_workspace_store is None:
missing.append("draft_workspace_store")
if context.run_store is None:
missing.append("run_store")
if missing:
raise ValueError("durable workflow API requires stores: " + ", ".join(missing))
assert context.artifact_store is not None
- assert context.draft_workspace_store is not None
assert context.run_store is not None
return WorkflowStores(
artifact_store=context.artifact_store,
@@ -32,10 +34,14 @@ def require_workflow_stores(context: WorkflowOperationContext) -> WorkflowStores
)
-def durable_workflow_api(context: WorkflowOperationContext) -> WorkflowApi:
- """Construct a WorkflowApi only after durable store dependencies exist."""
- require_workflow_stores(context)
- return WorkflowApi(context)
+def durable_workflow_api(
+ context: WorkflowOperationContext,
+ *,
+ drafts: bool = False,
+) -> WorkflowApi:
+ """Construct a durable API, optionally omitting the draft product surface."""
+ require_workflow_stores(context, drafts=drafts)
+ return WorkflowApi(context, drafts=drafts)
__all__ = ["durable_workflow_api", "require_workflow_stores"]
diff --git a/src/wf_api/models/__init__.py b/src/wf_api/models/__init__.py
index dd2a9452..a06b3ae5 100644
--- a/src/wf_api/models/__init__.py
+++ b/src/wf_api/models/__init__.py
@@ -14,12 +14,14 @@ from .admin import (
from .artifacts import (
ArtifactCatalogEntryPayload,
ArtifactKindPayload,
+ ArtifactPlanDiagnosticPayload,
CapabilityKindPayload,
CapabilityRefPayload,
DeleteArtifactResult,
ListArtifactsResult,
RequiredCapabilityPayload,
SaveArtifactResult,
+ ValidateArtifactPlanResult,
WorkflowArtifactPayload,
)
from .authoring_contracts import (
@@ -139,6 +141,7 @@ __all__ = [
"AuthoringStepContractPayload",
"AuthRecordSummaryPayload",
"ArtifactCatalogEntryPayload",
+ "ArtifactPlanDiagnosticPayload",
"ArtifactKindPayload",
"CapabilityKindPayload",
"CapabilityCallResult",
@@ -205,6 +208,7 @@ __all__ = [
"RequiredCapabilityPayload",
"RemoveRegistryEntryResult",
"SaveArtifactResult",
+ "ValidateArtifactPlanResult",
"SavedDraftArtifactResult",
"SaveDeploymentResult",
"SourceBindingPayload",
diff --git a/src/wf_api/models/artifacts.py b/src/wf_api/models/artifacts.py
index b28358ce..19c2fc66 100644
--- a/src/wf_api/models/artifacts.py
+++ b/src/wf_api/models/artifacts.py
@@ -80,6 +80,25 @@ class SaveArtifactResult(TypedDict):
saved: bool
+class ArtifactPlanDiagnosticPayload(TypedDict):
+ """Stable diagnostic projected when an artifact plan is invalid."""
+
+ severity: Literal["error", "warning"]
+ code: str
+ path: str
+ message: str
+ repair_hint: str | None
+
+
+class ValidateArtifactPlanResult(TypedDict):
+ """Non-persisting artifact-plan validation and dependency inventory."""
+
+ status: Literal["valid", "invalid"]
+ diagnostics: list[ArtifactPlanDiagnosticPayload]
+ required_capabilities: list[RequiredCapabilityPayload]
+ workflow_dependencies: dict[str, int]
+
+
class DeleteArtifactResult(TypedDict):
artifact_id: str
version: int
diff --git a/src/wf_api/service.py b/src/wf_api/service.py
index f51716d5..74780849 100644
--- a/src/wf_api/service.py
+++ b/src/wf_api/service.py
@@ -46,6 +46,7 @@ from .models import (
SaveArtifactResult,
SavedDraftArtifactResult,
SaveDeploymentResult,
+ ValidateArtifactPlanResult,
ValidateDeploymentResult,
ValidateDraftResult,
WorkflowArtifactPayload,
@@ -99,15 +100,39 @@ class WorkflowApi:
callers share one operation surface without importing wf_mcp.
"""
- def __init__(self, context: WorkflowOperationContext) -> None:
+ def __init__(
+ self,
+ context: WorkflowOperationContext,
+ *,
+ drafts: bool = False,
+ ) -> None:
self.context = context
- self.capabilities = WorkflowCapabilityApi(context)
- self.drafts = WorkflowDraftApi(context)
- self.draft_authoring = WorkflowDraftAuthoringApi(context, self.drafts)
- self.artifacts = WorkflowArtifactApi(context)
+ self.capabilities = WorkflowCapabilityApi(context, drafts=drafts)
+ # ``drafts`` keeps server composition explicit. Disabled APIs are None,
+ # so artifact/deployment/run initialization has no draft dependency.
+ self.drafts_enabled = drafts
+ if self.drafts_enabled:
+ self.drafts = WorkflowDraftApi(context)
+ self.draft_authoring = WorkflowDraftAuthoringApi(context, self.drafts)
+ else:
+ self.drafts = None
+ self.draft_authoring = None
+ self.artifacts = WorkflowArtifactApi(context, drafts=drafts)
self.deployments = WorkflowDeploymentApi(context)
self.runs = WorkflowRunApi(context)
+ def _require_drafts(self) -> WorkflowDraftApi:
+ """Return the draft service for an explicitly draft-enabled API."""
+ if self.drafts is None:
+ raise ValueError("workflow draft APIs are disabled; pass drafts=True")
+ return self.drafts
+
+ def _require_draft_authoring(self) -> WorkflowDraftAuthoringApi:
+ """Return draft authoring for an explicitly draft-enabled API."""
+ if self.draft_authoring is None:
+ raise ValueError("workflow draft APIs are disabled; pass drafts=True")
+ return self.draft_authoring
+
# -- capabilities --
async def list_capabilities(
@@ -217,6 +242,21 @@ class WorkflowApi:
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.artifacts.validate_artifact_plan(
+ plan=plan,
+ outcomes=outcomes,
+ required_capabilities=required_capabilities,
+ source_bindings=source_bindings,
+ )
+
async def create_artifact_from_draft(
self,
*,
@@ -303,14 +343,14 @@ class WorkflowApi:
*,
draft: dict[str, Any],
) -> ValidateDraftResult:
- return await self.drafts.validate_draft(draft=draft)
+ return await self._require_drafts().validate_draft(draft=draft)
async def compile_draft(
self,
*,
draft: dict[str, Any],
) -> CompileDraftWorkspaceSuccess:
- return await self.drafts.compile_draft(draft=draft)
+ return await self._require_drafts().compile_draft(draft=draft)
async def patch_draft(
self,
@@ -318,12 +358,12 @@ class WorkflowApi:
draft: dict[str, Any],
patch: list[dict[str, Any]],
) -> PatchDraftResult:
- return await self.drafts.patch_draft(draft=draft, patch=patch)
+ return await self._require_drafts().patch_draft(draft=draft, patch=patch)
# -- draft workspaces --
async def list_draft_workspaces(self) -> ListDraftWorkspacesResult:
- return await self.drafts.list_draft_workspaces()
+ return await self._require_drafts().list_draft_workspaces()
async def create_draft_workspace(
self,
@@ -332,7 +372,7 @@ class WorkflowApi:
draft: dict[str, Any],
title: str | None = None,
) -> DraftWorkspaceResult:
- return await self.drafts.create_draft_workspace(
+ return await self._require_drafts().create_draft_workspace(
workspace_id=workspace_id,
draft=draft,
title=title,
@@ -349,7 +389,7 @@ class WorkflowApi:
output_schema: dict[str, Any] | None = None,
outcomes: Sequence[str] = ("ok",),
) -> DraftWorkspaceResult:
- return await self.drafts.create_empty_draft_workspace(
+ return await self._require_drafts().create_empty_draft_workspace(
workspace_id=workspace_id,
name=name,
title=title,
@@ -381,7 +421,7 @@ class WorkflowApi:
workspace_id: str,
include_draft: bool = False,
) -> DraftWorkspaceResult | DraftWorkspaceWithDocument:
- return await self.drafts.get_draft_workspace(
+ return await self._require_drafts().get_draft_workspace(
workspace_id=workspace_id,
include_draft=include_draft,
)
@@ -399,7 +439,7 @@ class WorkflowApi:
selected step. This preserves the draft APIs' canonical conflict
precedence when an authoring client is holding an old revision.
"""
- checked = self.drafts._workspace_if_revision_matches(
+ checked = self._require_drafts()._workspace_if_revision_matches(
workspace_id=workspace_id,
revision=revision,
)
@@ -523,21 +563,27 @@ class WorkflowApi:
*,
workspace_id: str,
) -> DeleteDraftWorkspaceResult:
- return await self.drafts.delete_draft_workspace(workspace_id=workspace_id)
+ return await self._require_drafts().delete_draft_workspace(
+ workspace_id=workspace_id
+ )
async def validate_draft_workspace(
self,
*,
workspace_id: str,
) -> DraftWorkspaceResult:
- return await self.drafts.validate_draft_workspace(workspace_id=workspace_id)
+ return await self._require_drafts().validate_draft_workspace(
+ workspace_id=workspace_id
+ )
async def compile_draft_workspace(
self,
*,
workspace_id: str,
) -> CompileDraftWorkspaceResult:
- return await self.drafts.compile_draft_workspace(workspace_id=workspace_id)
+ return await self._require_drafts().compile_draft_workspace(
+ workspace_id=workspace_id
+ )
async def patch_draft_workspace(
self,
@@ -546,7 +592,7 @@ class WorkflowApi:
revision: int,
patch: list[dict[str, Any]],
) -> DraftWorkspaceResult:
- return await self.drafts.patch_draft_workspace(
+ return await self._require_drafts().patch_draft_workspace(
workspace_id=workspace_id,
revision=revision,
patch=patch,
@@ -560,7 +606,7 @@ class WorkflowApi:
draft: dict[str, Any],
) -> DraftWorkspaceResult:
"""Replace and semantically revalidate one complete workspace draft."""
- return await self.drafts.replace_draft_workspace_document(
+ return await self._require_drafts().replace_draft_workspace_document(
workspace_id=workspace_id,
revision=revision,
draft=draft,
@@ -573,7 +619,7 @@ class WorkflowApi:
revision: int,
name: str,
) -> DraftWorkspaceResult:
- return await self.drafts.set_draft_name(
+ return await self._require_drafts().set_draft_name(
workspace_id=workspace_id,
revision=revision,
name=name,
@@ -586,7 +632,7 @@ class WorkflowApi:
revision: int,
step_id: str,
) -> DraftWorkspaceResult:
- return await self.drafts.set_draft_start(
+ return await self._require_drafts().set_draft_start(
workspace_id=workspace_id,
revision=revision,
step_id=step_id,
@@ -602,7 +648,7 @@ class WorkflowApi:
output_schema: dict[str, Any] | None = None,
outcomes: Sequence[str] | None = None,
) -> DraftWorkspaceResult:
- return await self.drafts.set_draft_contract(
+ return await self._require_drafts().set_draft_contract(
workspace_id=workspace_id,
revision=revision,
input_schema=input_schema,
@@ -620,7 +666,7 @@ class WorkflowApi:
outcome: str,
target: str,
) -> DraftWorkspaceResult:
- return await self.drafts.set_draft_route(
+ return await self._require_drafts().set_draft_route(
workspace_id=workspace_id,
revision=revision,
step_id=step_id,
@@ -637,7 +683,7 @@ class WorkflowApi:
input_map: dict[str, str],
merge: bool = False,
) -> DraftWorkspaceResult:
- return await self.drafts.set_step_input_map(
+ return await self._require_drafts().set_step_input_map(
workspace_id=workspace_id,
revision=revision,
step_id=step_id,
@@ -653,7 +699,7 @@ class WorkflowApi:
step_id: str,
bindings: Sequence[StepInputBinding],
) -> DraftWorkspaceResult:
- return await self.draft_authoring.set_step_input_bindings(
+ return await self._require_draft_authoring().set_step_input_bindings(
workspace_id=workspace_id,
revision=revision,
step_id=step_id,
@@ -668,7 +714,7 @@ class WorkflowApi:
step_id: str,
bindings: Sequence[OutputBinding],
) -> DraftWorkspaceResult:
- return await self.draft_authoring.set_step_output_bindings(
+ return await self._require_draft_authoring().set_step_output_bindings(
workspace_id=workspace_id,
revision=revision,
step_id=step_id,
@@ -684,7 +730,7 @@ class WorkflowApi:
update: CapabilityStepUpdate,
) -> DraftWorkspaceResult:
"""Return the updated workspace summary or a revision-conflict payload."""
- return await self.draft_authoring.update_capability_step(
+ return await self._require_draft_authoring().update_capability_step(
workspace_id=workspace_id,
revision=revision,
step_id=step_id,
@@ -700,7 +746,7 @@ class WorkflowApi:
output_map: dict[str, str],
merge: bool = False,
) -> DraftWorkspaceResult:
- return await self.drafts.set_step_output_map(
+ return await self._require_drafts().set_step_output_map(
workspace_id=workspace_id,
revision=revision,
step_id=step_id,
@@ -716,7 +762,7 @@ class WorkflowApi:
output_map: dict[str, str],
merge: bool = False,
) -> DraftWorkspaceResult:
- return await self.drafts.set_workflow_output_map(
+ return await self._require_drafts().set_workflow_output_map(
workspace_id=workspace_id,
revision=revision,
output_map=output_map,
@@ -730,7 +776,7 @@ class WorkflowApi:
revision: int,
bindings: Sequence[InputBinding],
) -> DraftWorkspaceResult:
- return await self.draft_authoring.set_workflow_output_bindings(
+ return await self._require_draft_authoring().set_workflow_output_bindings(
workspace_id=workspace_id,
revision=revision,
bindings=bindings,
@@ -745,7 +791,7 @@ class WorkflowApi:
source_path: str,
target_path: str,
) -> DraftWorkspaceResult:
- return await self.draft_authoring.bind_draft(
+ return await self._require_draft_authoring().bind_draft(
workspace_id=workspace_id,
revision=revision,
step_id=step_id,
@@ -770,7 +816,7 @@ class WorkflowApi:
retry: int | None = None,
timeout_seconds: int | None = None,
) -> DraftWorkspaceResult:
- return await self.draft_authoring.add_step_from_capability(
+ return await self._require_draft_authoring().add_step_from_capability(
workspace_id=workspace_id,
revision=revision,
step_id=step_id,
@@ -796,7 +842,7 @@ class WorkflowApi:
incoming: RouteSource | None = None,
routes: dict[str, str] | None = None,
) -> DraftWorkspaceResult:
- return await self.draft_authoring.add_step(
+ return await self._require_draft_authoring().add_step(
workspace_id=workspace_id,
revision=revision,
step_id=step_id,
@@ -813,7 +859,7 @@ class WorkflowApi:
step_id: str,
routes: dict[str, str],
) -> DraftWorkspaceResult:
- return await self.draft_authoring.branch_draft(
+ return await self._require_draft_authoring().branch_draft(
workspace_id=workspace_id,
revision=revision,
step_id=step_id,
@@ -831,7 +877,7 @@ class WorkflowApi:
refs = [
RouteSource(step_id=b["step_id"], outcome=b["outcome"]) for b in branches
]
- return await self.draft_authoring.handle_draft(
+ return await self._require_draft_authoring().handle_draft(
workspace_id=workspace_id,
revision=revision,
branches=refs,
@@ -854,7 +900,7 @@ class WorkflowApi:
error_message_source: Any | None = None,
title: str | None = None,
) -> DraftWorkspaceResult:
- return await self.draft_authoring.create_minimal_draft_workspace(
+ return await self._require_draft_authoring().create_minimal_draft_workspace(
workspace_id=workspace_id,
name=name,
capability_name=capability_name,
@@ -877,7 +923,7 @@ class WorkflowApi:
step_id: str,
outcome: str,
) -> DraftWorkspaceResult:
- return await self.draft_authoring.remove_draft_route(
+ return await self._require_draft_authoring().remove_draft_route(
workspace_id=workspace_id,
revision=revision,
step_id=step_id,
@@ -891,7 +937,7 @@ class WorkflowApi:
revision: int,
step_id: str,
) -> DraftWorkspaceResult:
- return await self.draft_authoring.remove_draft_step(
+ return await self._require_draft_authoring().remove_draft_step(
workspace_id=workspace_id,
revision=revision,
step_id=step_id,
@@ -906,7 +952,7 @@ class WorkflowApi:
inputs: Sequence[str] = (),
outputs: Sequence[str] = (),
) -> DraftWorkspaceResult:
- return await self.draft_authoring.remove_draft_binding(
+ return await self._require_draft_authoring().remove_draft_binding(
workspace_id=workspace_id,
revision=revision,
step_id=step_id,
diff --git a/src/wf_api/stores.py b/src/wf_api/stores.py
index 805a2ea7..8d3cb34d 100644
--- a/src/wf_api/stores.py
+++ b/src/wf_api/stores.py
@@ -18,16 +18,25 @@ class WorkflowStores:
"""Protocol-neutral persistence dependencies for workflow APIs."""
artifact_store: WorkflowArtifactStore
- draft_workspace_store: DraftWorkspaceStore
+ draft_workspace_store: DraftWorkspaceStore | None
run_store: RunStore
-def file_workflow_stores(root: str | Path) -> WorkflowStores:
- """Create process-local file-backed workflow stores under one root."""
+def file_workflow_stores(
+ root: str | Path,
+ *,
+ drafts: bool = False,
+) -> WorkflowStores:
+ """Create file-backed workflow stores, opting into draft persistence.
+
+ Artifact and run stores are needed by every durable workflow server. Draft
+ workspaces are a separate product surface, so avoid constructing their
+ store (which creates its directory) unless a caller explicitly enables it.
+ """
store_root = Path(root)
return WorkflowStores(
artifact_store=FileWorkflowArtifactStore(store_root),
- draft_workspace_store=FileDraftWorkspaceStore(store_root),
+ draft_workspace_store=(FileDraftWorkspaceStore(store_root) if drafts else None),
run_store=FileRunStore(store_root),
)
diff --git a/src/wf_api/surface.py b/src/wf_api/surface.py
index d0331e48..59732c22 100644
--- a/src/wf_api/surface.py
+++ b/src/wf_api/surface.py
@@ -45,6 +45,7 @@ from .models import (
SaveArtifactResult,
SaveDeploymentResult,
SourceDiagnosisResult,
+ ValidateArtifactPlanResult,
ValidateDeploymentResult,
ValidateDraftResult,
WorkflowArtifactPayload,
@@ -459,6 +460,15 @@ class WorkflowArtifactSurface(Protocol):
created_from_catalog_version: str | None = None,
) -> SaveArtifactResult: ...
+ 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: ...
+
class WorkflowDeploymentSurface(Protocol):
"""Deployment methods exposed by workflow frontends."""
diff --git a/src/wf_artifacts/__init__.py b/src/wf_artifacts/__init__.py
index d3b79a16..7e9d98b6 100644
--- a/src/wf_artifacts/__init__.py
+++ b/src/wf_artifacts/__init__.py
@@ -21,7 +21,7 @@ from .drafts import (
patch_workflow_draft,
validate_workflow_draft,
)
-from .factory import create_workflow_artifact_from_plan
+from .factory import WorkflowPlanValidationError, create_workflow_artifact_from_plan
from .models import (
ArtifactKind,
AvailableCapability,
@@ -78,6 +78,7 @@ __all__ = [
"WorkflowArtifact",
"WorkflowArtifactCatalogEntry",
"WorkflowArtifactStore",
+ "WorkflowPlanValidationError",
"WorkflowCapabilityRef",
"WorkflowDeployment",
"WorkflowDraftWorkspace",
diff --git a/src/wf_artifacts/factory.py b/src/wf_artifacts/factory.py
index 0ca0e67e..105ba529 100644
--- a/src/wf_artifacts/factory.py
+++ b/src/wf_artifacts/factory.py
@@ -2,13 +2,20 @@ from __future__ import annotations
from collections.abc import Mapping
+from pydantic import ValidationError
+
from wf_core import ReducerRef, Workflow
+from wf_core.models.workflow_refs import WorkflowRef
from wf_platform import CapabilityRef, NodeSpecInventory, hash_json_schema
from .models import ArtifactKind, JsonObject, RequiredCapability, WorkflowArtifact
from .references import normalize_plan_node_refs
+class WorkflowPlanValidationError(ValueError):
+ """Expected structural validation failure while preparing a workflow plan."""
+
+
def create_workflow_artifact_from_plan(
*,
artifact_id: str,
@@ -47,6 +54,7 @@ def create_workflow_artifact_from_plan(
outcomes=outcomes,
plan=normalized_plan,
required_capabilities=list(required.values()),
+ workflow_dependencies=_workflow_dependencies_from_plan(normalized_plan),
created_from_catalog_version=created_from_catalog_version,
)
@@ -54,19 +62,21 @@ def create_workflow_artifact_from_plan(
def _required_object_field(plan: JsonObject, field_name: str) -> JsonObject:
value = plan.get(field_name)
if not isinstance(value, dict):
- raise ValueError(f"workflow plan is missing object field {field_name!r}")
+ raise WorkflowPlanValidationError(
+ f"workflow plan is missing object field {field_name!r}"
+ )
return value
def _validate_workflow_plan(plan: JsonObject) -> None:
try:
workflow = Workflow.model_validate(plan)
- except Exception as exc:
- raise ValueError(f"invalid workflow plan: {exc}") from exc
+ except ValidationError as exc:
+ raise WorkflowPlanValidationError(f"invalid workflow plan: {exc}") from exc
node_ids = {node.id for node in workflow.nodes}
if workflow.start not in node_ids:
- raise ValueError(
+ raise WorkflowPlanValidationError(
f"invalid workflow plan: start node {workflow.start!r} does not exist"
)
@@ -76,15 +86,38 @@ def _validate_workflow_plan(plan: JsonObject) -> None:
edge_sources = set(node_ids)
for edge in workflow.edges:
if edge.from_ not in edge_sources:
- raise ValueError(
+ raise WorkflowPlanValidationError(
f"invalid workflow plan: edge source {edge.from_!r} does not exist"
)
if edge.to not in node_ids and edge.to != "__end__":
- raise ValueError(
+ raise WorkflowPlanValidationError(
f"invalid workflow plan: edge destination {edge.to!r} does not exist"
)
+def _workflow_dependencies_from_plan(plan: JsonObject) -> dict[str, int]:
+ """Collect immutable artifact pins from native saved-subgraph steps."""
+ nodes = plan.get("nodes")
+ if not isinstance(nodes, list):
+ return {}
+
+ dependencies: dict[str, int] = {}
+ for node in nodes:
+ if not isinstance(node, dict) or node.get("type") != "subgraph":
+ continue
+ workflow_ref = WorkflowRef.model_validate(node.get("workflow"))
+ if workflow_ref.artifact_id is not None and workflow_ref.version is not None:
+ pinned = dependencies.get(workflow_ref.artifact_id)
+ if pinned is not None and pinned != workflow_ref.version:
+ raise WorkflowPlanValidationError(
+ "invalid workflow plan: conflicting versions "
+ f"{pinned} and {workflow_ref.version} pinned for child "
+ f"artifact {workflow_ref.artifact_id!r}"
+ )
+ dependencies[workflow_ref.artifact_id] = workflow_ref.version
+ return dependencies
+
+
def _required_reducers_from_plan(plan: JsonObject) -> dict[str, RequiredCapability]:
"""Infer reducer dependencies from declared state fields in one plan."""
state_schema = plan.get("state_schema")
diff --git a/src/wf_authoring/builder/__init__.py b/src/wf_authoring/builder/__init__.py
index 53554983..d00fb2ab 100644
--- a/src/wf_authoring/builder/__init__.py
+++ b/src/wf_authoring/builder/__init__.py
@@ -1,4 +1,5 @@
from .core import WorkflowBuilder
+from .mapping import auto_input_map_from_schema, auto_output_map_from_schema
from .refs import BranchRef, BranchResult, DecisionResult, HandleResult, StepRef
__all__ = [
@@ -8,4 +9,6 @@ __all__ = [
"HandleResult",
"StepRef",
"WorkflowBuilder",
+ "auto_input_map_from_schema",
+ "auto_output_map_from_schema",
]
diff --git a/src/wf_authoring/builder/core.py b/src/wf_authoring/builder/core.py
index 30093a45..8b4a0a8f 100644
--- a/src/wf_authoring/builder/core.py
+++ b/src/wf_authoring/builder/core.py
@@ -15,6 +15,7 @@ from wf_core import (
ForeachItemErrorPolicy,
ForeachNode,
InterruptNode,
+ NodeDef,
NodeHandler,
NodeUse,
PreparedSubgraph,
@@ -22,6 +23,7 @@ from wf_core import (
SchemaRef,
StateSchema,
SubgraphNode,
+ ValidationReport,
Workflow,
WorkflowRef,
execute_workflow,
@@ -31,6 +33,7 @@ from wf_core.errors import WorkflowExecutionError
from wf_core.models.conditions import BinaryCondition, ExistsCondition, PathOperand
from wf_core.models.conditions import Condition as CoreCondition
from wf_core.models.steps import (
+ InputBinding,
InputPathBinding,
InputValueBinding,
OutputBinding,
@@ -54,7 +57,9 @@ from .mapping import (
OutputBindingArg,
StepInputBindingArg,
auto_input_map,
+ auto_input_map_from_schema,
auto_output_map,
+ auto_output_map_from_schema,
coerce_path,
normalize_input_mapping,
normalize_input_values,
@@ -136,6 +141,11 @@ def _canonical_output_bindings(
]
+def _node_defs_compatible(left: NodeDef, right: NodeDef) -> bool:
+ """Compare contracts by their serialized canonical content."""
+ return left.model_dump(mode="json") == right.model_dump(mode="json")
+
+
def _reject_mixed_binding_styles(
*,
input: object | None,
@@ -213,6 +223,8 @@ class WorkflowBuilder:
node_specs: dict[str, NodeSpec[Any, Any]] = field(default_factory=dict)
nodes: list[Step] = field(default_factory=list)
edges: list[Edge] = field(default_factory=list)
+ workflow_output: list[InputBinding] = field(default_factory=list)
+ seeded_node_defs: dict[str, NodeDef] = field(default_factory=dict, repr=False)
prepared_subgraphs: dict[str, PreparedSubgraph[NodeHandler]] = field(
default_factory=dict
)
@@ -392,6 +404,134 @@ class WorkflowBuilder:
self.nodes.append(node)
return node
+ def use_contract(
+ self,
+ node_def: NodeDef,
+ *,
+ id: str | None = None,
+ input: Sequence[StepInputBindingArg] | None = None,
+ output: Sequence[OutputBindingArg] | None = None,
+ desc: str | None = None,
+ ) -> NodeUse:
+ """Use a schema-backed external node contract without a local handler."""
+ existing = self.seeded_node_defs.get(node_def.name)
+ if existing is not None and not _node_defs_compatible(existing, node_def):
+ raise ValueError(
+ f"incompatible duplicate node definition {node_def.name!r}"
+ )
+ self.seeded_node_defs[node_def.name] = node_def.model_copy(deep=True)
+ normalized_input_schema = cast(SchemaRef, self.input_schema)
+ normalized_state_schema = cast(StateSchema, self.state_schema)
+ node_input = (
+ normalize_step_input_bindings(input)
+ if input is not None
+ else _canonical_input_bindings(
+ normalize_input_mapping(
+ auto_input_map_from_schema(
+ node_def.input_schema,
+ input_schema=normalized_input_schema,
+ state_schema=normalized_state_schema,
+ )
+ ),
+ {},
+ )
+ )
+ node_output = (
+ normalize_output_bindings(output)
+ if output is not None
+ else _canonical_output_bindings(
+ normalize_output_mapping(
+ auto_output_map_from_schema(
+ node_def.output_schema,
+ state_schema=normalized_state_schema,
+ )
+ )
+ )
+ )
+ return self.use_ref(
+ node_def.name,
+ id=id,
+ input=node_input,
+ output=node_output,
+ desc=desc,
+ )
+
+ @classmethod
+ def from_workflow(cls, workflow: Workflow) -> WorkflowBuilder:
+ """Create an independently editable builder from a canonical workflow."""
+ return cls(
+ name=workflow.name,
+ input_schema=workflow.input_schema.model_copy(deep=True),
+ state_schema=workflow.state_schema.model_copy(deep=True),
+ output_schema=workflow.output_schema.model_copy(deep=True),
+ outcomes=tuple(workflow.outcomes),
+ start=workflow.start,
+ nodes=[node.model_copy(deep=True) for node in workflow.nodes],
+ edges=[edge.model_copy(deep=True) for edge in workflow.edges],
+ workflow_output=[
+ binding.model_copy(deep=True) for binding in workflow.output
+ ],
+ seeded_node_defs={
+ node_def.name: node_def.model_copy(deep=True)
+ for node_def in workflow.node_defs
+ },
+ )
+
+ def set_output(self, bindings: Sequence[StepInputBindingArg]) -> None:
+ """Replace final workflow output projection bindings."""
+ self.workflow_output = cast(
+ list[InputBinding], normalize_step_input_bindings(bindings)
+ )
+
+ def set_route(self, source: StepRef, outcome: str, target: StepRef) -> None:
+ """Replace the unique route for one source/outcome pair."""
+ source_id = step_id(source)
+ target_id = step_id(target)
+ self.edges = [
+ edge
+ for edge in self.edges
+ if not (edge.from_ == source_id and edge.outcome == outcome)
+ ]
+ self.edges.append(
+ Edge.model_validate(
+ {"from": source_id, "outcome": outcome, "to": target_id}
+ )
+ )
+
+ def remove_route(self, source: StepRef, outcome: str) -> None:
+ """Remove one route, raising when the requested route is absent."""
+ source_id = step_id(source)
+ matching = [
+ edge
+ for edge in self.edges
+ if edge.from_ == source_id and edge.outcome == outcome
+ ]
+ if not matching:
+ raise ValueError(
+ f"route from step {source_id!r} with outcome {outcome!r} not found"
+ )
+ self.edges = [
+ edge
+ for edge in self.edges
+ if not (edge.from_ == source_id and edge.outcome == outcome)
+ ]
+
+ def remove_step(self, step: StepRef) -> None:
+ """Remove an unreferenced step, rejecting dangling graph references."""
+ step_id_value = step_id(step)
+ if not any(node.id == step_id_value for node in self.nodes):
+ raise ValueError(f"step {step_id_value!r} not found")
+ if self.start == step_id_value:
+ raise ValueError(
+ f"step {step_id_value!r} is still referenced as workflow start"
+ )
+ if any(
+ edge.from_ == step_id_value or edge.to == step_id_value
+ for edge in self.edges
+ ):
+ raise ValueError(f"step {step_id_value!r} is still referenced by route")
+ self.nodes = [node for node in self.nodes if node.id != step_id_value]
+
def subgraph(
self,
*,
@@ -869,21 +1009,45 @@ class WorkflowBuilder:
)
return self.match(value, cases, id=id, default=default)
+ def _build_workflow(self, *, start: str) -> Workflow:
+ """Build a canonical workflow snapshot from current builder state."""
+ node_defs = [
+ node_def.model_copy(deep=True)
+ for node_def in self.seeded_node_defs.values()
+ ]
+ by_name = {node_def.name: node_def for node_def in node_defs}
+ for spec in self.node_specs.values():
+ node_def = spec.to_node_def()
+ existing = by_name.get(node_def.name)
+ if existing is not None:
+ if not _node_defs_compatible(existing, node_def):
+ raise ValueError(
+ f"incompatible duplicate node definition {node_def.name!r}"
+ )
+ continue
+ by_name[node_def.name] = node_def
+ node_defs.append(node_def)
+ return Workflow(
+ name=self.name,
+ input_schema=cast(SchemaRef, self.input_schema).model_copy(deep=True),
+ state_schema=cast(StateSchema, self.state_schema).model_copy(deep=True),
+ output_schema=cast(SchemaRef, self.output_schema).model_copy(deep=True),
+ outcomes=list(self.outcomes),
+ node_defs=node_defs,
+ start=start,
+ output=[binding.model_copy(deep=True) for binding in self.workflow_output],
+ nodes=[node.model_copy(deep=True) for node in self.nodes],
+ edges=[edge.model_copy(deep=True) for edge in self.edges],
+ )
+
+ def validate_structure(self) -> ValidationReport:
+ """Return structural issues, including an unset workflow start."""
+ return self._build_workflow(start=self.start or "").validate_structure()
+
def compile(self) -> Workflow:
if self.start is None:
raise WorkflowExecutionError(
"workflow builder requires an explicit start; "
"call set_entry_point(...) or pass start=..."
)
- node_defs = [spec.to_node_def() for spec in self.node_specs.values()]
- return Workflow(
- name=self.name,
- input_schema=cast(SchemaRef, self.input_schema),
- state_schema=cast(StateSchema, self.state_schema),
- output_schema=cast(SchemaRef, self.output_schema),
- outcomes=list(self.outcomes),
- node_defs=node_defs,
- start=self.start,
- nodes=self.nodes,
- edges=self.edges,
- )
+ return self._build_workflow(start=self.start)
diff --git a/src/wf_authoring/builder/mapping.py b/src/wf_authoring/builder/mapping.py
index fe45d516..06ebba7c 100644
--- a/src/wf_authoring/builder/mapping.py
+++ b/src/wf_authoring/builder/mapping.py
@@ -143,11 +143,25 @@ def auto_input_map(
state_schema: StateSchema,
) -> dict[str, str]:
"""Map node input fields from state first, then workflow input."""
+ return auto_input_map_from_schema(
+ spec.to_node_def().input_schema,
+ input_schema=input_schema,
+ state_schema=state_schema,
+ )
+
+
+def auto_input_map_from_schema(
+ capability_input_schema: SchemaRef,
+ *,
+ input_schema: SchemaRef,
+ state_schema: StateSchema,
+) -> dict[str, str]:
+ """Map schema-declared capability inputs from state or workflow input."""
return {
_auto_source_path(
field, input_schema=input_schema, state_schema=state_schema
): field
- for field in spec.input_model.model_json_schema().get("properties", {})
+ for field in capability_input_schema.properties
}
@@ -157,10 +171,22 @@ def auto_output_map(
state_schema: StateSchema,
) -> dict[str, str]:
"""Map node output fields back into matching state fields."""
+ return auto_output_map_from_schema(
+ spec.to_node_def().output_schema,
+ state_schema=state_schema,
+ )
+
+
+def auto_output_map_from_schema(
+ capability_output_schema: SchemaRef,
+ *,
+ state_schema: StateSchema,
+) -> dict[str, str]:
+ """Map schema-declared capability outputs into matching state fields."""
state_fields = state_schema.field_map()
return {
field: f"state.{field}"
- for field in spec.output_model.model_json_schema().get("properties", {})
+ for field in capability_output_schema.properties
if field in state_fields
}
diff --git a/src/wf_cli/context.py b/src/wf_cli/context.py
index ec5e3e68..76441472 100644
--- a/src/wf_cli/context.py
+++ b/src/wf_cli/context.py
@@ -97,7 +97,9 @@ def build_workflow_server_from_workflow_config(
"""Build the local server without importing the server runtime at CLI startup."""
from wf_server.config import build_workflow_server_from_workflow_config as build
- return build(config)
+ # Local CLI exposes the full draft command group, so it is an explicit
+ # draft-bearing composition even though the neutral server default is not.
+ return build(config, drafts=True)
def load_cli_context(
@@ -150,7 +152,8 @@ def load_cli_context(
return CliContext(
config_path=resolved_config_path,
service=service,
- handlers=WorkflowApi(context_from_service(service)),
+ # Legacy MCP CLI commands include draft authoring operations.
+ handlers=WorkflowApi(context_from_service(service), drafts=True),
source_admin=WorkflowSourceAdminApi(context_from_service(service)),
admin=WorkflowAdminApi(
connections=service.connection_service,
diff --git a/src/wf_client/__init__.py b/src/wf_client/__init__.py
new file mode 100644
index 00000000..91b42b82
--- /dev/null
+++ b/src/wf_client/__init__.py
@@ -0,0 +1,59 @@
+"""Transport-independent workflow client primitives."""
+
+from wf_platform import CapabilityRef, Page
+
+from .app import App
+from .authoring import EditableWorkflow
+from .capabilities import CapabilityResult, CapabilitySummary, RemoteCapability
+from .deployments import Deployment, DeploymentValidation
+from .errors import (
+ ArtifactNotFound,
+ ArtifactVersionConflict,
+ CapabilityNotFound,
+ DeploymentNotRunnable,
+ DeploymentRequired,
+ InvalidResponse,
+ ProtocolError,
+ RevisionConflict,
+ TransportError,
+ ValidationFailed,
+ WorkflowClientError,
+)
+from .runs import Run, TracePage
+from .workflows import (
+ ArtifactRef,
+ Diagnostic,
+ WorkflowArtifact,
+ WorkflowDiagnostic,
+ WorkflowValidation,
+)
+
+__all__ = [
+ "ArtifactNotFound",
+ "ArtifactVersionConflict",
+ "App",
+ "ArtifactRef",
+ "CapabilityNotFound",
+ "CapabilityRef",
+ "CapabilityResult",
+ "CapabilitySummary",
+ "Diagnostic",
+ "DeploymentNotRunnable",
+ "DeploymentRequired",
+ "Deployment",
+ "DeploymentValidation",
+ "InvalidResponse",
+ "Page",
+ "ProtocolError",
+ "RemoteCapability",
+ "Run",
+ "RevisionConflict",
+ "TransportError",
+ "ValidationFailed",
+ "WorkflowClientError",
+ "EditableWorkflow",
+ "WorkflowArtifact",
+ "WorkflowDiagnostic",
+ "WorkflowValidation",
+ "TracePage",
+]
diff --git a/src/wf_client/_http_port.py b/src/wf_client/_http_port.py
new file mode 100644
index 00000000..48afcb42
--- /dev/null
+++ b/src/wf_client/_http_port.py
@@ -0,0 +1,284 @@
+"""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,
+ CapabilityNotFound,
+ ProtocolError,
+ 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 operation-specific errors emitted by the current server."""
+ code, detail = _server_detail(error)
+ # 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, code=error.code, data=error.data)
+ if operation == "workflow.artifacts.inspect" and (
+ "unknown workflow artifact" in detail
+ ):
+ return ArtifactNotFound(detail, code=error.code, data=error.data)
+ 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,
+ )
diff --git a/src/wf_client/_identity.py b/src/wf_client/_identity.py
new file mode 100644
index 00000000..a49b095d
--- /dev/null
+++ b/src/wf_client/_identity.py
@@ -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}"
+ ),
+ )
diff --git a/src/wf_client/_repr.py b/src/wf_client/_repr.py
new file mode 100644
index 00000000..c2ede7b8
--- /dev/null
+++ b/src/wf_client/_repr.py
@@ -0,0 +1,124 @@
+"""Small, inert renderers shared by the public workflow-client snapshots.
+
+Representations are a debugging aid, not another client operation. This
+module deliberately accepts already-loaded values and never knows about the
+workflow transport port. The same bounded projector is used for plain and
+HTML representations so notebooks cannot accidentally expose an unbounded
+trace, output, or credential-shaped value.
+"""
+
+from __future__ import annotations
+
+import html
+import json
+import re
+from collections.abc import Mapping, Sequence
+from itertools import islice
+
+_SENSITIVE_KEYS = {
+ "authorization",
+ "cookie",
+ "set_cookie",
+ "token",
+ "access_token",
+ "refresh_token",
+ "secret",
+ "password",
+ "api_key",
+}
+_MAX_DEPTH = 2
+_MAX_ITEMS = 8
+_MAX_STRING = 160
+_MAX_RENDERED = 1_200
+
+
+def _canonical_key(key: object) -> str:
+ """Normalize snake/kebab/camel spellings to the shared evidence keys."""
+ value = re.sub(r"([a-z0-9])([A-Z])", r"\1_\2", str(key))
+ return value.replace(" ", "_").replace("-", "_").lower()
+
+
+def _secret_key(key: object) -> bool:
+ # Match the evidence policy's exact key set; substring matching would
+ # incorrectly redact harmless fields such as ``tokenCount`` or ``secretary``.
+ return _canonical_key(key) in _SENSITIVE_KEYS
+
+
+def bounded_value(value: object, *, depth: int = 0) -> object:
+ """Project loaded JSON-like data into a small, secret-safe preview."""
+ if depth >= _MAX_DEPTH:
+ return "[truncated]"
+ if isinstance(value, str):
+ return value if len(value) <= _MAX_STRING else value[:_MAX_STRING] + "…"
+ if value is None or isinstance(value, bool | int | float):
+ return value
+ if isinstance(value, Mapping):
+ iterator = iter(value.items())
+ items = list(islice(iterator, _MAX_ITEMS + 1))
+ preview = {
+ str(key): "[redacted]"
+ if _secret_key(key)
+ else bounded_value(item, depth=depth + 1)
+ for key, item in items[:_MAX_ITEMS]
+ }
+ if len(items) > _MAX_ITEMS:
+ preview["…"] = "more entries"
+ return preview
+ if isinstance(value, Sequence) and not isinstance(value, str | bytes | bytearray):
+ iterator = iter(value)
+ items = list(islice(iterator, _MAX_ITEMS + 1))
+ preview = [bounded_value(item, depth=depth + 1) for item in items[:_MAX_ITEMS]]
+ if len(items) > _MAX_ITEMS:
+ preview.append("… more items")
+ return preview
+ if isinstance(value, Sequence):
+ return "[truncated sequence]"
+ if hasattr(value, "__iter__"):
+ iterator = iter(value) # type: ignore[call-overload]
+ items = list(islice(iterator, _MAX_ITEMS + 1))
+ preview = [bounded_value(item, depth=depth + 1) for item in items[:_MAX_ITEMS]]
+ if len(items) > _MAX_ITEMS:
+ preview.append("… more items")
+ return preview
+ rendered = repr(value)
+ return rendered if len(rendered) <= _MAX_STRING else rendered[:_MAX_STRING] + "…"
+
+
+def preview(value: object) -> str:
+ """Render a bounded value without allowing an object's repr to grow freely."""
+ try:
+ rendered = json.dumps(bounded_value(value), sort_keys=True, default=str)
+ except TypeError, ValueError:
+ rendered = str(bounded_value(value))
+ return rendered[:_MAX_RENDERED] + ("…" if len(rendered) > _MAX_RENDERED else "")
+
+
+def short_repr(type_name: str, **fields: object) -> str:
+ """Build a compact Python repr from already-loaded field values."""
+ body = ", ".join(f"{name}={preview(value)}" for name, value in fields.items())
+ rendered = f"{type_name}({body})"
+ return rendered[:_MAX_RENDERED] + ("…" if len(rendered) > _MAX_RENDERED else "")
+
+
+def html_repr(type_name: str, **fields: object) -> str:
+ """Build a bounded HTML table suitable for IPython rich display."""
+
+ def html_preview(value: object) -> str:
+ rendered = preview(value)
+ return rendered[:400] + ("…" if len(rendered) > 400 else "")
+
+ rows = "".join(
+ '
| '
+ + html.escape(name)
+ + " | "
+ + html.escape(html_preview(value))
+ + " |
"
+ for name, value in fields.items()
+ )
+ return (
+ ''
+ + html.escape(type_name)
+ + " "
+ )
diff --git a/src/wf_client/app.py b/src/wf_client/app.py
new file mode 100644
index 00000000..1849efd4
--- /dev/null
+++ b/src/wf_client/app.py
@@ -0,0 +1,221 @@
+"""The public entry point for transport-independent workflow clients."""
+
+from __future__ import annotations
+
+from collections.abc import Sequence
+from dataclasses import dataclass, field
+from typing import TYPE_CHECKING, Any
+
+from wf_platform import CapabilityRef, Page, SourceRef
+from wf_transport_rpc_http import RpcWorkflowApiClient
+
+from ._http_port import PublicErrorWorkflowClientPort
+from ._identity import require_response_identity
+from .authoring import EditableWorkflow
+from .capabilities import CapabilitySummary, RemoteCapability
+from .codec import (
+ decode_capabilities_page,
+ decode_capability_inspect,
+ decode_workflow_artifact,
+)
+from .errors import InvalidResponse
+from .protocols import WorkflowClientPort
+from .workflows import WorkflowArtifact
+
+if TYPE_CHECKING:
+ from .deployments import Deployment
+ from .runs import Run
+
+
+def _capability_ref(qualified_name: str, source_id: str) -> CapabilityRef:
+ """Build a ref by removing the exact source prefix, preserving dotted keys."""
+ prefix = f"{source_id}."
+ if not qualified_name.startswith(prefix):
+ raise InvalidResponse(
+ operation="workflow.capabilities.inspect",
+ details=(
+ f"qualified name {qualified_name!r} does not belong to "
+ f"source {source_id!r}"
+ ),
+ )
+ try:
+ return CapabilityRef(
+ source=SourceRef.parse(source_id),
+ name=qualified_name.removeprefix(prefix),
+ )
+ except ValueError as exc:
+ raise InvalidResponse(
+ operation="workflow.capabilities.inspect",
+ details=f"invalid capability reference: {exc}",
+ ) from exc
+
+
+def _summary_from_wire(row: object) -> CapabilitySummary:
+ """Project one wire discovery row into an attribute-bearing value object."""
+ data = dict(row) if isinstance(row, dict) else {}
+ return CapabilitySummary(
+ qualified_name=data["name"],
+ source_id=data["source_id"],
+ kind=data["kind"],
+ description=data["description"],
+ outcomes=tuple(data["outcomes"]),
+ is_async=data["is_async"],
+ input_fields=tuple(data["input_fields"]),
+ output_fields=tuple(data["output_fields"]),
+ artifact_id=data.get("artifact_id"),
+ version=data.get("version"),
+ title=data.get("title"),
+ )
+
+
+@dataclass(frozen=True, slots=True)
+class App:
+ """Connected workflow service facade; all remote methods are asynchronous."""
+
+ _port: WorkflowClientPort = field(repr=False)
+ endpoint: str
+
+ @classmethod
+ def from_http_jsonrpc(
+ cls,
+ url: str,
+ *,
+ timeout_seconds: float = 30.0,
+ ) -> App:
+ """Configure a lazy HTTP JSON-RPC connection without performing I/O."""
+ return cls(
+ _port=PublicErrorWorkflowClientPort(
+ RpcWorkflowApiClient(
+ url=url,
+ timeout_seconds=timeout_seconds,
+ )
+ ),
+ endpoint=url,
+ )
+
+ @classmethod
+ def _from_port(cls, port: WorkflowClientPort) -> App:
+ """Construct an app over an in-memory/test port."""
+ return cls(_port=port, endpoint="in-process")
+
+ async def capability(self, name: str) -> RemoteCapability:
+ """Inspect and reconstruct one callable remote capability."""
+ wire = decode_capability_inspect(
+ await self._port.inspect_capability(qualified_name=name)
+ )
+ qualified_name = wire["name"]
+ if qualified_name != name:
+ raise InvalidResponse(
+ operation="workflow.capabilities.inspect",
+ details=(
+ f"inspected capability {qualified_name!r} does not match "
+ f"requested {name!r}"
+ ),
+ )
+ source_id = wire["source_id"]
+ return RemoteCapability(
+ _port=self._port,
+ ref=_capability_ref(qualified_name, source_id),
+ qualified_name=qualified_name,
+ description=wire["description"],
+ input_schema=dict(wire["input_schema"]),
+ output_schema=dict(wire["output_schema"]),
+ outcomes=tuple(wire["outcomes"]),
+ is_async=wire["is_async"],
+ _kind=wire["kind"],
+ )
+
+ async def capabilities(
+ self,
+ *,
+ query: str | None = None,
+ source_id: str | None = None,
+ cursor: str | None = None,
+ limit: int = 50,
+ ) -> Page[CapabilitySummary]:
+ """List planner-visible capabilities as immutable rich rows."""
+ wire = decode_capabilities_page(
+ await self._port.list_capabilities(
+ query=query,
+ source_id=source_id,
+ cursor=cursor,
+ limit=limit,
+ )
+ )
+ return Page(
+ items=tuple(_summary_from_wire(row) for row in wire["capabilities"]),
+ next_cursor=wire["next_cursor"],
+ total=wire["total"],
+ )
+
+ def new_workflow(
+ self,
+ name: str,
+ *,
+ input_schema: Any,
+ state_schema: Any,
+ output_schema: Any,
+ outcomes: Sequence[str] = ("ok",),
+ ) -> EditableWorkflow:
+ """Construct a local builder; remote operations remain opt-in/async."""
+ return EditableWorkflow(
+ _port=self._port,
+ name=name,
+ input_schema=input_schema,
+ state_schema=state_schema,
+ output_schema=output_schema,
+ outcomes=outcomes,
+ )
+
+ async def workflow(self, artifact_id: str, *, version: int) -> WorkflowArtifact:
+ """Inspect and reconstruct one exact immutable workflow artifact version."""
+ artifact, workflow = decode_workflow_artifact(
+ await self._port.inspect_artifact(
+ artifact_id=artifact_id,
+ 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)
+
+ async def edit_workflow(
+ self,
+ artifact_id: str,
+ *,
+ version: int,
+ ) -> EditableWorkflow:
+ """Inspect an exact artifact version and seed an editable builder."""
+ return (await self.workflow(artifact_id, version=version)).edit()
+
+ async def deployment(self, deployment_id: str) -> Deployment:
+ """Inspect and reconstruct one immutable deployment snapshot."""
+ from .deployments import Deployment
+
+ deployment = Deployment.from_payload(
+ self._port,
+ await self._port.inspect_deployment(deployment_id=deployment_id),
+ )
+ if deployment.deployment_id != deployment_id:
+ raise InvalidResponse(
+ operation="workflow.deployments.inspect",
+ details=(
+ f"inspected deployment {deployment.deployment_id!r} does not "
+ f"match requested {deployment_id!r}"
+ ),
+ )
+ return deployment
+
+ async def run(self, run_id: str) -> Run:
+ """Inspect and reconstruct one immutable durable run snapshot."""
+ from .runs import Run
+
+ return Run.from_payload(
+ self._port,
+ await self._port.inspect_run(run_id=run_id),
+ expected_run_id=run_id,
+ operation="workflow.runs.inspect",
+ )
diff --git a/src/wf_client/authoring.py b/src/wf_client/authoring.py
new file mode 100644
index 00000000..20b0b1bd
--- /dev/null
+++ b/src/wf_client/authoring.py
@@ -0,0 +1,323 @@
+"""Editable workflow authoring over the transport-independent builder."""
+
+from __future__ import annotations
+
+from collections.abc import Sequence
+from copy import deepcopy
+from dataclasses import dataclass, field
+from typing import Any, overload
+
+from wf_authoring import WorkflowBuilder
+from wf_authoring.builder.mapping import OutputBindingArg, StepInputBindingArg
+from wf_authoring.nodes import NodeSpec
+from wf_core import (
+ NodeDef,
+ NodeUse,
+ SchemaRef,
+ SubgraphNode,
+ ValidationReport,
+ Workflow,
+)
+
+from ._identity import require_response_identity
+from .capabilities import RemoteCapability
+from .codec import (
+ decode_save_artifact,
+ decode_validate_artifact_plan,
+ decode_workflow_artifact,
+)
+from .protocols import WorkflowClientPort
+from .workflows import (
+ ArtifactRef,
+ WorkflowArtifact,
+ WorkflowDiagnostic,
+ WorkflowValidation,
+)
+
+
+@dataclass(slots=True)
+class EditableWorkflow(WorkflowBuilder):
+ """A mutable ``WorkflowBuilder`` carrying the client port used to save it."""
+
+ _port: WorkflowClientPort = field(repr=False, kw_only=True)
+ based_on: ArtifactRef | 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)
+ _source_plan: dict[str, Any] | None = field(default=None, repr=False, kw_only=True)
+ _source_workflow: Workflow | None = field(default=None, repr=False, kw_only=True)
+ _permissive_node_defs: set[str] = field(
+ default_factory=set, repr=False, kw_only=True
+ )
+
+ @classmethod
+ def from_artifact(cls, artifact: WorkflowArtifact) -> EditableWorkflow:
+ """Copy every canonical graph field from an immutable artifact snapshot."""
+ builder = WorkflowBuilder.from_workflow(artifact.workflow)
+ permissive_node_defs = _seed_remote_node_defs(builder, artifact)
+ return cls(
+ _port=artifact._port,
+ based_on=artifact.ref,
+ artifact_title=artifact.title,
+ artifact_description=artifact.description,
+ name=builder.name,
+ input_schema=builder.input_schema,
+ state_schema=builder.state_schema,
+ output_schema=builder.output_schema,
+ outcomes=builder.outcomes,
+ start=builder.start,
+ reducers=builder.reducers,
+ node_specs=dict(builder.node_specs),
+ nodes=builder.nodes,
+ edges=builder.edges,
+ workflow_output=builder.workflow_output,
+ seeded_node_defs=builder.seeded_node_defs,
+ prepared_subgraphs=builder.prepared_subgraphs,
+ _source_plan=deepcopy(artifact.artifact.plan),
+ _source_workflow=builder._build_workflow(start=builder.start or ""),
+ _permissive_node_defs=permissive_node_defs,
+ )
+
+ @overload
+ def use(
+ self,
+ spec: NodeSpec[Any, Any],
+ *,
+ id: str | None = None,
+ input: Sequence[StepInputBindingArg] | None = None,
+ output: Sequence[OutputBindingArg] | None = None,
+ desc: str | None = None,
+ ) -> NodeUse: ...
+
+ @overload
+ def use(
+ self,
+ spec: RemoteCapability,
+ *,
+ id: str | None = None,
+ input: Sequence[StepInputBindingArg] | None = None,
+ output: Sequence[OutputBindingArg] | None = None,
+ desc: str | None = None,
+ ) -> NodeUse: ...
+
+ def use(
+ self,
+ spec: NodeSpec[Any, Any] | RemoteCapability,
+ **kwargs: Any,
+ ) -> NodeUse:
+ if isinstance(spec, RemoteCapability):
+ return self.use_contract(spec.node_def(), **kwargs)
+ return super().use(spec, **kwargs)
+
+ def use_contract(self, node_def: NodeDef, **kwargs: Any) -> NodeUse:
+ """Upgrade an artifact placeholder before normal duplicate checks."""
+ if node_def.name in self._permissive_node_defs:
+ self.seeded_node_defs.pop(node_def.name, None)
+ self._permissive_node_defs.remove(node_def.name)
+ return super().use_contract(node_def, **kwargs)
+
+ def subgraph(
+ self,
+ workflow: WorkflowArtifact,
+ *,
+ id: str | None = None,
+ input: Sequence[StepInputBindingArg] | None = None,
+ output: Sequence[OutputBindingArg] | None = None,
+ desc: str | None = None,
+ ) -> SubgraphNode:
+ """Add a native subgraph pinned to an immutable artifact version."""
+ return super().subgraph(
+ workflow=workflow.workflow,
+ workflow_ref={
+ "artifact_id": workflow.ref.artifact_id,
+ "version": workflow.ref.version,
+ },
+ id=id,
+ input=input,
+ output=output,
+ desc=desc,
+ )
+
+ def validate_local(self) -> ValidationReport:
+ """Validate graph structure locally without touching the transport."""
+ return self.validate_structure()
+
+ def _plan(self) -> tuple[Workflow, dict[str, Any]]:
+ workflow = self.compile()
+ if (
+ self._source_plan is not None
+ and self._source_workflow is not None
+ and workflow.model_dump(mode="json", by_alias=True)
+ == self._source_workflow.model_dump(mode="json", by_alias=True)
+ ):
+ # Pydantic canonical models add omitted defaults during a round trip
+ # (for example ``required=[]``). Keep an untouched artifact's raw
+ # plan byte-for-byte structural equivalent until it is edited.
+ return workflow, deepcopy(self._source_plan)
+ plan = workflow.model_dump(mode="json", by_alias=True)
+ # Node definitions are local execution metadata, not part of the raw
+ # persisted artifact plan; the server inventories these from node refs.
+ plan.pop("node_defs", None)
+ return workflow, plan
+
+ async def validate(self) -> WorkflowValidation:
+ local = self.validate_local()
+ if not local.ok:
+ return WorkflowValidation(local, "not_run", ())
+
+ _workflow, plan = self._plan()
+ wire = decode_validate_artifact_plan(
+ await self._port.validate_artifact_plan(
+ plan=plan,
+ outcomes=tuple(self.outcomes),
+ )
+ )
+ diagnostics = tuple(
+ WorkflowDiagnostic(
+ severity=item["severity"],
+ code=item["code"],
+ path=item["path"],
+ message=item["message"],
+ repair_hint=item["repair_hint"],
+ )
+ for item in wire["diagnostics"]
+ )
+ return WorkflowValidation(local, wire["status"], diagnostics)
+
+ async def save(
+ self,
+ *,
+ artifact_id: str | None = None,
+ version: int,
+ title: str | None = None,
+ description: str | None = None,
+ ) -> WorkflowArtifact:
+ validation = await self.validate()
+ validation.raise_for_errors()
+ _workflow, plan = self._plan()
+ 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_description = (
+ description if description is not None else self.artifact_description
+ )
+ acknowledgement = decode_save_artifact(
+ await self._port.create_artifact_from_plan(
+ artifact_id=saved_id,
+ version=version,
+ title=saved_title,
+ plan=plan,
+ outcomes=tuple(self.outcomes),
+ 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
+ # requested version ensures server normalization is retained losslessly.
+ inspected = await self._port.inspect_artifact(
+ artifact_id=saved_id,
+ version=version,
+ )
+ 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)
+
+
+def _seed_remote_node_defs(
+ builder: WorkflowBuilder,
+ artifact: WorkflowArtifact,
+) -> set[str]:
+ """Restore remote node contracts retained as artifact dependency snapshots."""
+ node_name_by_step_id = {
+ node.id: node.node
+ for node in artifact.workflow.nodes
+ if isinstance(node, NodeUse)
+ }
+ outcomes_by_node: dict[str, list[str]] = {}
+ for edge in artifact.workflow.edges:
+ node_name = node_name_by_step_id.get(edge.from_)
+ if node_name is not None:
+ outcomes_by_node.setdefault(node_name, []).append(edge.outcome)
+ permissive_names: set[str] = set()
+ for requirement in artifact.required_capabilities:
+ if requirement.kind != "node_spec":
+ continue
+ name = str(requirement.capability_ref())
+ node_uses = [
+ node
+ for node in artifact.workflow.nodes
+ if isinstance(node, NodeUse) and node.node == name
+ ]
+ input_fields = {
+ field
+ for node in node_uses
+ for binding in node.input
+ if (field := _binding_root_field(binding.target)) is not None
+ }
+ output_fields = {
+ field
+ for node in node_uses
+ for binding in node.output
+ if (field := _binding_root_field(binding.source)) is not None
+ }
+ input_schema = _snapshot_or_permissive_schema(
+ requirement.input_schema_snapshot,
+ input_fields,
+ )
+ output_schema = _snapshot_or_permissive_schema(
+ requirement.output_schema_snapshot,
+ output_fields,
+ )
+ if name in builder.seeded_node_defs:
+ # A contract explicitly carried by the plan is authoritative even
+ # when the server omitted dependency snapshots for this capability.
+ continue
+ if not isinstance(requirement.input_schema_snapshot, dict) or not isinstance(
+ requirement.output_schema_snapshot, dict
+ ):
+ permissive_names.add(name)
+ builder.seeded_node_defs[name] = NodeDef(
+ name=name,
+ input_schema=SchemaRef.model_validate(input_schema),
+ output_schema=SchemaRef.model_validate(output_schema),
+ outcomes=outcomes_by_node.get(name, ["ok"]),
+ )
+ return permissive_names
+
+
+def _binding_root_field(path: object) -> str | None:
+ """Return a local binding's first field, excluding whole-payload ``.``."""
+ parts = getattr(path, "parts", ())
+ if not parts:
+ return None
+ return parts[0]
+
+
+def _snapshot_or_permissive_schema(
+ snapshot: object,
+ fields: set[str],
+) -> dict[str, Any]:
+ """Use a saved snapshot or an unconstrained schema for its used fields.
+
+ A missing server snapshot carries no type information. Declaring only the
+ fields already referenced by graph bindings lets local structural checks
+ proceed without inventing validation constraints for remote data.
+ """
+ if isinstance(snapshot, dict):
+ return snapshot
+ return {
+ "type": "object",
+ "properties": {field: {} for field in sorted(fields)},
+ }
diff --git a/src/wf_client/capabilities.py b/src/wf_client/capabilities.py
new file mode 100644
index 00000000..83156042
--- /dev/null
+++ b/src/wf_client/capabilities.py
@@ -0,0 +1,252 @@
+"""Rich, transport-independent objects for remote workflow capabilities."""
+
+from __future__ import annotations
+
+from collections.abc import Mapping
+from copy import deepcopy
+from dataclasses import dataclass, field
+from typing import Any
+
+from jsonschema import Draft202012Validator, SchemaError, ValidationError
+
+from wf_artifacts.models import DependencyDiagnostic
+from wf_core.models.schemas import NodeDef, SchemaRef
+from wf_platform import CapabilityRef
+
+from ._identity import require_response_identity
+from ._repr import html_repr, short_repr
+from .codec import decode_capability_call, decode_capability_diagnostics
+from .errors import InvalidResponse
+from .protocols import WorkflowClientPort
+
+
+@dataclass(frozen=True, slots=True)
+class CapabilitySummary:
+ """Compact immutable discovery row for a planner-visible capability."""
+
+ qualified_name: str
+ source_id: str
+ kind: str
+ description: str | None
+ outcomes: tuple[str, ...]
+ is_async: bool
+ input_fields: tuple[str, ...]
+ output_fields: tuple[str, ...]
+ artifact_id: str | None = None
+ version: int | None = None
+ title: str | None = None
+
+ @property
+ def name(self) -> str:
+ """Compatibility alias for the wire row's ``name`` field."""
+ return self.qualified_name
+
+ def __repr__(self) -> str:
+ return short_repr(
+ type(self).__name__,
+ name=self.qualified_name,
+ source=self.source_id,
+ outcomes=self.outcomes,
+ )
+
+ def _repr_html_(self) -> str:
+ return html_repr(
+ type(self).__name__,
+ name=self.qualified_name,
+ source=self.source_id,
+ outcomes=self.outcomes,
+ inputs=f"{len(self.input_fields)} fields",
+ outputs=f"{len(self.output_fields)} fields",
+ )
+
+
+@dataclass(frozen=True, slots=True)
+class CapabilityResult:
+ """Validated result of invoking one remote capability."""
+
+ outcome: str
+ output: dict[str, Any] | None
+ diagnostics: tuple[DependencyDiagnostic, ...]
+
+ def __repr__(self) -> str:
+ return short_repr(
+ type(self).__name__,
+ outcome=self.outcome,
+ output=self.output,
+ diagnostics=f"{len(self.diagnostics)} diagnostics",
+ )
+
+ def _repr_html_(self) -> str:
+ return html_repr(
+ type(self).__name__,
+ outcome=self.outcome,
+ output=self.output,
+ diagnostics=f"{len(self.diagnostics)} diagnostics",
+ )
+
+
+def _check_schema(schema: object, *, operation: str) -> dict[str, Any]:
+ if not isinstance(schema, Mapping):
+ raise InvalidResponse(
+ operation=operation,
+ details="capability schema must be a JSON object",
+ )
+ schema_copy = deepcopy(dict(schema))
+ try:
+ Draft202012Validator.check_schema(schema_copy)
+ except SchemaError as exc:
+ raise InvalidResponse(
+ operation=operation,
+ details=f"invalid JSON Schema: {exc.message}",
+ ) from exc
+ return schema_copy
+
+
+@dataclass(frozen=True, slots=True)
+class RemoteCapability:
+ """Inspected remote capability that validates calls against its contract."""
+
+ _port: WorkflowClientPort = field(repr=False, compare=False)
+ ref: CapabilityRef
+ qualified_name: str
+ description: str | None
+ input_schema: dict[str, Any]
+ output_schema: dict[str, Any]
+ outcomes: tuple[str, ...]
+ is_async: bool
+ _kind: str = field(default="node_spec", repr=False, compare=False)
+
+ def __post_init__(self) -> None:
+ # Freeze the public container shape at construction. The nested JSON
+ # values remain ordinary JSON objects because callers expect to inspect
+ # and pass schemas directly to existing pydantic/core APIs.
+ if not self.outcomes:
+ raise InvalidResponse(
+ operation="workflow.capabilities.inspect",
+ details="capability contract must declare at least one outcome",
+ )
+ object.__setattr__(
+ self,
+ "input_schema",
+ _check_schema(
+ self.input_schema,
+ operation="workflow.capabilities.inspect",
+ ),
+ )
+ object.__setattr__(
+ self,
+ "output_schema",
+ _check_schema(
+ self.output_schema,
+ operation="workflow.capabilities.inspect",
+ ),
+ )
+ object.__setattr__(self, "outcomes", tuple(self.outcomes))
+
+ def __repr__(self) -> str:
+ return short_repr(
+ type(self).__name__,
+ name=self.qualified_name,
+ outcomes=self.outcomes,
+ input_schema=f"{len(self.input_schema)} keys",
+ output_schema=f"{len(self.output_schema)} keys",
+ )
+
+ def _repr_html_(self) -> str:
+ return html_repr(
+ type(self).__name__,
+ name=self.qualified_name,
+ description=self.description,
+ outcomes=self.outcomes,
+ **{
+ "input schema": f"{len(self.input_schema)} keys",
+ "output schema": f"{len(self.output_schema)} keys",
+ },
+ )
+
+ async def __call__(
+ self,
+ payload: Mapping[str, Any] | None = None,
+ /,
+ **fields: Any,
+ ) -> CapabilityResult:
+ if payload is not None and fields:
+ raise TypeError("pass a payload mapping or keyword fields, not both")
+ return await self.call(dict(payload) if payload is not None else fields)
+
+ async def call(
+ self,
+ payload: Mapping[str, Any],
+ *,
+ deployment_id: str | None = None,
+ ) -> CapabilityResult:
+ """Validate input locally, invoke remotely, and validate its result."""
+ input_payload = dict(payload)
+ input_validator = Draft202012Validator(self.input_schema)
+ # jsonschema.ValidationError intentionally remains the local input
+ # error: no transport operation has happened when it is raised.
+ input_validator.validate(input_payload)
+
+ wire = decode_capability_call(
+ await self._port.call_capability(
+ qualified_name=self.qualified_name,
+ payload=input_payload,
+ deployment_id=deployment_id,
+ )
+ )
+ if wire["qualified_name"] != self.qualified_name:
+ raise InvalidResponse(
+ operation="workflow.capabilities.call",
+ details=(
+ f"result qualified name {wire['qualified_name']!r} does not "
+ 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:
+ raise InvalidResponse(
+ operation="workflow.capabilities.call",
+ details=f"unknown capability outcome {wire['outcome']!r}",
+ )
+
+ output = wire["output"]
+ if output is not None:
+ try:
+ Draft202012Validator(self.output_schema).validate(output)
+ except ValidationError as exc:
+ # Schema validation errors are expected server-contract
+ # failures; do not leak jsonschema internals as public output.
+ raise InvalidResponse(
+ operation="workflow.capabilities.call",
+ details=f"output does not match capability schema: {exc}",
+ ) from exc
+ return CapabilityResult(
+ outcome=wire["outcome"],
+ output=deepcopy(output) if output is not None else None,
+ diagnostics=decode_capability_diagnostics(wire["diagnostics"]),
+ )
+
+ def node_def(self) -> NodeDef:
+ """Return the schema contract consumed by ``WorkflowBuilder.use_contract``."""
+ return NodeDef(
+ name=self.qualified_name,
+ input_schema=SchemaRef.model_validate(deepcopy(self.input_schema)),
+ output_schema=SchemaRef.model_validate(deepcopy(self.output_schema)),
+ outcomes=list(self.outcomes),
+ )
diff --git a/src/wf_client/codec.py b/src/wf_client/codec.py
new file mode 100644
index 00000000..e9a6edf1
--- /dev/null
+++ b/src/wf_client/codec.py
@@ -0,0 +1,283 @@
+"""Validated conversions from workflow API wire payloads to domain values."""
+
+from __future__ import annotations
+
+from dataclasses import dataclass
+from dataclasses import fields as dataclass_fields
+from typing import Any, TypeVar
+
+from pydantic import BaseModel, TypeAdapter, ValidationError
+
+from wf_api.models import (
+ CapabilityCallResult,
+ DependencyDiagnosticPayload,
+ InspectCapabilityResult,
+ ListCapabilitiesResult,
+ ListDeploymentsResult,
+ RawWorkflowPlan,
+ RunResult,
+ RunTraceResult,
+ SaveArtifactResult,
+ SaveDeploymentResult,
+ ValidateArtifactPlanResult,
+ ValidateDeploymentResult,
+ WorkflowArtifactPayload,
+ WorkflowDeploymentPayload,
+)
+from wf_artifacts.models import (
+ DependencyDiagnostic,
+ WorkflowArtifact,
+ WorkflowDeployment,
+)
+from wf_core.models.workflow import Workflow
+
+from .errors import InvalidResponse
+
+
+@dataclass(frozen=True, slots=True)
+class _DecodedRunFields:
+ """Domain-shaped run fields shared by normal and trace responses."""
+
+ artifact_id: str
+ artifact_version: int
+ deployment_id: str
+ status: str
+ run_id: str | None
+ resume_readiness: str | None
+ interrupt: dict[str, Any] | None
+ outcome: str | None
+ error: str | None
+ output: dict[str, Any] | None
+ trace_count: int
+ diagnostics: tuple[DependencyDiagnostic, ...]
+ next_actions: dict[str, Any]
+ trace: tuple[dict[str, Any], ...] | None = None
+ trace_start: int | None = None
+ trace_limit: int | None = None
+ trace_truncated: bool | None = None
+
+
+@dataclass(frozen=True, slots=True)
+class DecodedRunResult(_DecodedRunFields):
+ """Validated run result without exposing a wire ``TypedDict``."""
+
+
+@dataclass(frozen=True, slots=True)
+class DecodedTracePage(_DecodedRunFields):
+ """Validated bounded trace result without exposing wire dictionaries."""
+
+
+_PayloadT = TypeVar("_PayloadT")
+
+
+def _validate(payload: object, schema: object, operation: str) -> _PayloadT:
+ try:
+ return TypeAdapter(schema).validate_python(payload)
+ except (TypeError, ValueError, ValidationError) as exc:
+ raise InvalidResponse(operation=operation, details=str(exc)) from exc
+
+
+def decode_capability_inspect(payload: object) -> InspectCapabilityResult:
+ """Validate one capability contract returned by discovery inspection."""
+ return _validate(
+ payload,
+ InspectCapabilityResult,
+ "workflow.capabilities.inspect",
+ )
+
+
+def decode_capability_call(payload: object) -> CapabilityCallResult:
+ """Validate one direct capability-call result at the transport boundary."""
+ return _validate(
+ payload,
+ CapabilityCallResult,
+ "workflow.capabilities.call",
+ )
+
+
+def decode_capability_diagnostics(
+ payload: object,
+) -> tuple[DependencyDiagnostic, ...]:
+ """Decode diagnostics attached to a capability invocation."""
+ return _decode_dependency_diagnostics(
+ payload,
+ "workflow.capabilities.call",
+ )
+
+
+def decode_capabilities_page(payload: object) -> ListCapabilitiesResult:
+ """Validate one cursor-paged capability discovery response."""
+ return _validate(
+ payload,
+ ListCapabilitiesResult,
+ "workflow.capabilities.list",
+ )
+
+
+def decode_validate_artifact_plan(payload: object) -> ValidateArtifactPlanResult:
+ """Validate a non-persisting artifact-plan response at the client boundary."""
+ return _validate(
+ payload,
+ ValidateArtifactPlanResult,
+ "workflow.artifacts.validate_plan",
+ )
+
+
+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)
+
+
+def _model_validate(model: type[_ModelT], payload: object, operation: str) -> _ModelT:
+ try:
+ # Pydantic models are the canonical domain validation boundary. This
+ # helper keeps all malformed-response errors tied to their operation.
+ return model.model_validate(payload)
+ except (TypeError, ValueError, ValidationError) as exc:
+ raise InvalidResponse(operation=operation, details=str(exc)) from exc
+
+
+def decode_workflow_artifact(
+ payload: object,
+) -> tuple[WorkflowArtifact, Workflow]:
+ """Validate an artifact envelope and reconstruct its executable workflow."""
+ operation = "workflow.artifacts.inspect"
+ wire = _validate(payload, WorkflowArtifactPayload, operation)
+ artifact = _model_validate(WorkflowArtifact, wire, operation)
+ raw_plan = _model_validate(RawWorkflowPlan, wire["plan"], operation)
+ workflow_payload = raw_plan.model_dump(mode="python", by_alias=True)
+ # Raw artifact plans normally omit node definitions because the server
+ # inventories remote contracts. Preserve them when a caller supplies them
+ # so an inspect/edit/save round trip remains lossless.
+ raw_node_defs = wire["plan"].get("node_defs")
+ if isinstance(raw_node_defs, list):
+ workflow_payload["node_defs"] = raw_node_defs
+ workflow = _model_validate(Workflow, workflow_payload, operation)
+ return artifact, workflow
+
+
+def decode_deployment(payload: object) -> WorkflowDeployment:
+ """Validate and reconstruct one immutable deployment model."""
+ operation = "workflow.deployments.inspect"
+ wire = _validate(payload, WorkflowDeploymentPayload, operation)
+ return _model_validate(WorkflowDeployment, wire, operation)
+
+
+def decode_deployments(payload: object) -> ListDeploymentsResult:
+ """Validate compact deployment discovery rows at the client boundary."""
+ return _validate(payload, ListDeploymentsResult, "workflow.deployments.list")
+
+
+def decode_deployment_validation(payload: object) -> ValidateDeploymentResult:
+ """Validate one deployment readiness response."""
+ return _validate(
+ payload,
+ ValidateDeploymentResult,
+ "workflow.deployments.validate",
+ )
+
+
+def decode_dependency_diagnostics(
+ payload: object,
+) -> tuple[DependencyDiagnostic, ...]:
+ """Decode deployment diagnostics into domain models."""
+ return _decode_dependency_diagnostics(payload, "workflow.deployments.validate")
+
+
+def _decode_dependency_diagnostics(
+ payload: object,
+ operation: str,
+) -> tuple[DependencyDiagnostic, ...]:
+ wire = _validate(payload, list[DependencyDiagnosticPayload], operation)
+ return tuple(
+ _model_validate(DependencyDiagnostic, diagnostic, operation)
+ for diagnostic in wire
+ )
+
+
+def _decode_run_fields(
+ wire: RunResult | RunTraceResult,
+ *,
+ trace_required: bool,
+ operation: str,
+) -> _DecodedRunFields:
+ diagnostics = _decode_dependency_diagnostics(wire["diagnostics"], operation)
+ raw_trace = wire.get("trace")
+ trace = None
+ if raw_trace is not None:
+ # Copy validated TypedDict values into ordinary dictionaries at the
+ # boundary so callers never receive transport DTO instances/types.
+ trace = tuple(dict(entry) for entry in raw_trace)
+ if trace_required and trace is None:
+ # RunTraceResult validation makes this unreachable; retain a defensive
+ # guard should its contract evolve.
+ raise InvalidResponse(
+ operation=operation,
+ details="trace result did not include a trace page",
+ )
+ return _DecodedRunFields(
+ artifact_id=wire["artifact_id"],
+ artifact_version=wire["artifact_version"],
+ deployment_id=wire["deployment_id"],
+ status=wire["status"],
+ run_id=wire["run_id"],
+ resume_readiness=wire["resume_readiness"],
+ interrupt=dict(wire["interrupt"]) if wire["interrupt"] is not None else None,
+ outcome=wire["outcome"],
+ error=wire["error"],
+ output=dict(wire["output"]) if wire["output"] is not None else None,
+ trace_count=wire["trace_count"],
+ diagnostics=diagnostics,
+ next_actions=dict(wire["next_actions"]),
+ trace=trace,
+ trace_start=wire.get("trace_start"),
+ trace_limit=wire.get("trace_limit"),
+ trace_truncated=wire.get("trace_truncated"),
+ )
+
+
+def decode_run_result(
+ payload: object,
+ *,
+ operation: str = "workflow.runs.inspect",
+) -> DecodedRunResult:
+ """Validate and decode a start/inspect/resume run response."""
+ wire = _validate(payload, RunResult, operation)
+ fields = _decode_run_fields(
+ wire,
+ trace_required=False,
+ operation=operation,
+ )
+ return DecodedRunResult(
+ *(getattr(fields, field.name) for field in dataclass_fields(_DecodedRunFields))
+ )
+
+
+def decode_trace_result(payload: object) -> DecodedTracePage:
+ """Validate and decode a bounded run trace response."""
+ operation = "workflow.runs.trace"
+ wire = _validate(payload, RunTraceResult, operation)
+ fields = _decode_run_fields(
+ wire,
+ trace_required=True,
+ operation=operation,
+ )
+ return DecodedTracePage(
+ *(getattr(fields, field.name) for field in dataclass_fields(_DecodedRunFields))
+ )
diff --git a/src/wf_client/deployments.py b/src/wf_client/deployments.py
new file mode 100644
index 00000000..38fd49fb
--- /dev/null
+++ b/src/wf_client/deployments.py
@@ -0,0 +1,333 @@
+"""Immutable deployment snapshots and strict artifact deployment selection."""
+
+from __future__ import annotations
+
+from collections.abc import Mapping
+from dataclasses import dataclass, field
+from typing import Any
+
+from wf_artifacts import DependencyDiagnostic, DriftPolicy, WorkflowDeployment
+
+from ._repr import html_repr, short_repr
+from .codec import (
+ decode_dependency_diagnostics,
+ decode_deployment,
+ decode_deployment_validation,
+ decode_deployments,
+)
+from .errors import DeploymentNotRunnable, DeploymentRequired, InvalidResponse
+from .protocols import WorkflowClientPort
+from .runs import Run
+
+
+@dataclass(frozen=True, slots=True)
+class DeploymentValidation:
+ """Server readiness result for one saved deployment snapshot."""
+
+ deployment_id: str
+ artifact_id: str
+ artifact_version: int
+ status: str
+ diagnostics: tuple[DependencyDiagnostic, ...]
+
+ @property
+ def runnable(self) -> bool:
+ return self.status == "runnable"
+
+ def __repr__(self) -> str:
+ return short_repr(
+ type(self).__name__,
+ deployment_id=self.deployment_id,
+ status=self.status,
+ diagnostics=f"{len(self.diagnostics)} diagnostics",
+ )
+
+ def _repr_html_(self) -> str:
+ return html_repr(
+ type(self).__name__,
+ deployment_id=self.deployment_id,
+ status=self.status,
+ diagnostics=f"{len(self.diagnostics)} diagnostics",
+ )
+
+
+@dataclass(frozen=True, slots=True, init=False)
+class Deployment:
+ """Immutable snapshot of one configured artifact deployment."""
+
+ _port: WorkflowClientPort = field(repr=False, compare=False)
+ _model: WorkflowDeployment = field(repr=False)
+ _diagnostics: tuple[DependencyDiagnostic, ...] = field(repr=False)
+ 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
+ def from_payload(cls, port: WorkflowClientPort, payload: object) -> Deployment:
+ 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
+ def deployment_id(self) -> str:
+ return self._model.id
+
+ def __repr__(self) -> str:
+ return short_repr(
+ type(self).__name__,
+ deployment_id=self.deployment_id,
+ artifact=f"{self.artifact_id}.v{self.artifact_version}",
+ runnable=self.runnable,
+ diagnostics=f"{len(self._diagnostics)} diagnostics",
+ )
+
+ def _repr_html_(self) -> str:
+ return html_repr(
+ type(self).__name__,
+ deployment_id=self.deployment_id,
+ artifact=f"{self.artifact_id}.v{self.artifact_version}",
+ bindings=f"{len(self.bindings)} bindings",
+ runnable=self.runnable,
+ diagnostics=f"{len(self._diagnostics)} diagnostics",
+ )
+
+ @property
+ def artifact_id(self) -> str:
+ return self._model.artifact_id
+
+ @property
+ def artifact_version(self) -> int:
+ return self._model.artifact_version
+
+ @property
+ def bindings(self) -> dict[str, str]:
+ return self._model.binding_map()
+
+ @property
+ def drift_policy(self) -> DriftPolicy:
+ return self._model.drift_policy
+
+ async def validate(self) -> DeploymentValidation:
+ payload = await self._port.validate_deployment(
+ deployment_id=self.deployment_id,
+ )
+ result = decode_deployment_validation(payload)
+ if result["deployment_id"] != self.deployment_id:
+ raise InvalidResponse(
+ operation="workflow.deployments.validate",
+ details=(
+ f"validated deployment {result['deployment_id']!r} does not "
+ f"match requested {self.deployment_id!r}"
+ ),
+ )
+ if (
+ result["artifact_id"] != self.artifact_id
+ or result["artifact_version"] != self.artifact_version
+ ):
+ raise InvalidResponse(
+ operation="workflow.deployments.validate",
+ details=(
+ f"validation for {self.deployment_id!r} targets artifact "
+ f"{result['artifact_id']!r} version {result['artifact_version']}, "
+ f"expected {self.artifact_id!r} version {self.artifact_version}"
+ ),
+ )
+ diagnostics = decode_dependency_diagnostics(result["diagnostics"])
+ return DeploymentValidation(
+ deployment_id=result["deployment_id"],
+ artifact_id=result["artifact_id"],
+ artifact_version=result["artifact_version"],
+ status=result["status"],
+ diagnostics=diagnostics,
+ )
+
+ async def run(self, workflow_input: Mapping[str, Any]) -> Run:
+ from .codec import decode_run_result
+ from .runs import _run_from_decoded
+
+ decoded = decode_run_result(
+ await self._port.run_deployment(
+ deployment_id=self.deployment_id,
+ workflow_input=dict(workflow_input),
+ ),
+ operation="workflow.runs.start",
+ )
+ if decoded.deployment_id != self.deployment_id:
+ raise InvalidResponse(
+ operation="workflow.runs.start",
+ details=(
+ f"returned deployment {decoded.deployment_id!r} does not "
+ f"match requested {self.deployment_id!r}"
+ ),
+ )
+ if (
+ decoded.artifact_id != self.artifact_id
+ or decoded.artifact_version != self.artifact_version
+ ):
+ raise InvalidResponse(
+ operation="workflow.runs.start",
+ details=(
+ f"start result for {self.deployment_id!r} targets artifact "
+ f"{decoded.artifact_id!r} version {decoded.artifact_version}, "
+ f"expected {self.artifact_id!r} version {self.artifact_version}"
+ ),
+ )
+ if decoded.run_id is None or decoded.status in {"unrunnable", "rejected"}:
+ raise DeploymentNotRunnable(
+ deployment_id=self.deployment_id,
+ diagnostics=tuple(
+ diagnostic.model_copy(deep=True)
+ for diagnostic in decoded.diagnostics
+ ),
+ outcome=decoded.outcome,
+ error=decoded.error,
+ )
+ return _run_from_decoded(
+ self._port,
+ decoded,
+ operation="workflow.runs.start",
+ )
+
+
+def _decode_summaries(payload: object) -> list[dict[str, Any]]:
+ """Validate list metadata while keeping summaries as local dictionaries."""
+ result = decode_deployments(payload)
+ return [dict(item) for item in result["deployments"]]
+
+
+async def run_artifact(
+ artifact: Any,
+ workflow_input: Mapping[str, Any],
+ *,
+ deployment_id: str | None,
+ bindings: Mapping[str, str] | None,
+ drift_policy: DriftPolicy,
+) -> Run:
+ """Apply the artifact's strict deployment-selection policy."""
+ if deployment_id is not None:
+ deployment = Deployment.from_payload(
+ artifact._port,
+ await artifact._port.inspect_deployment(deployment_id=deployment_id),
+ )
+ if deployment.deployment_id != deployment_id:
+ raise InvalidResponse(
+ operation="workflow.deployments.inspect",
+ details=(
+ f"inspected deployment {deployment.deployment_id!r} does not "
+ f"match requested {deployment_id!r}"
+ ),
+ )
+ if (
+ deployment.artifact_id != artifact.ref.artifact_id
+ or deployment.artifact_version != artifact.ref.version
+ ):
+ raise InvalidResponse(
+ operation="workflow.deployments.inspect",
+ details=(
+ f"deployment {deployment_id!r} does not target artifact "
+ f"{artifact.ref.artifact_id!r} version {artifact.ref.version}"
+ ),
+ )
+ return await deployment.run(workflow_input)
+
+ summaries = _decode_summaries(await artifact._port.list_deployments())
+ matches = sorted(
+ (
+ summary
+ for summary in summaries
+ if summary["artifact_id"] == artifact.ref.artifact_id
+ and summary["artifact_version"] == artifact.ref.version
+ ),
+ key=lambda summary: summary["id"],
+ )
+ if len(matches) > 1:
+ raise DeploymentRequired(
+ candidate_deployment_ids=tuple(summary["id"] for summary in matches)
+ )
+ default_id = f"{artifact.ref.artifact_id}.v{artifact.ref.version}.default"
+ if not matches:
+ conflicting = next(
+ (
+ summary
+ for summary in summaries
+ if summary["id"] == default_id
+ and (
+ summary["artifact_id"] != artifact.ref.artifact_id
+ or summary["artifact_version"] != artifact.ref.version
+ )
+ ),
+ None,
+ )
+ if conflicting is not None:
+ raise DeploymentRequired(candidate_deployment_ids=(default_id,))
+ deployment = await artifact.deploy(
+ default_id,
+ bindings=bindings,
+ drift_policy=drift_policy,
+ )
+ if deployment.runnable is not True:
+ raise DeploymentRequired(diagnostics=deployment.diagnostics)
+ return await deployment.run(workflow_input)
+
+ deployment = Deployment.from_payload(
+ artifact._port,
+ await artifact._port.inspect_deployment(deployment_id=matches[0]["id"]),
+ )
+ if deployment.deployment_id != matches[0]["id"]:
+ raise InvalidResponse(
+ operation="workflow.deployments.inspect",
+ details=(
+ f"inspected deployment {deployment.deployment_id!r} does not "
+ f"match requested {matches[0]['id']!r}"
+ ),
+ )
+ if (
+ deployment.artifact_id != artifact.ref.artifact_id
+ or deployment.artifact_version != artifact.ref.version
+ ):
+ raise InvalidResponse(
+ operation="workflow.deployments.inspect",
+ details=(
+ f"deployment {matches[0]['id']!r} does not target artifact "
+ f"{artifact.ref.artifact_id!r} version {artifact.ref.version}"
+ ),
+ )
+ return await deployment.run(workflow_input)
diff --git a/src/wf_client/errors.py b/src/wf_client/errors.py
new file mode 100644
index 00000000..55de7027
--- /dev/null
+++ b/src/wf_client/errors.py
@@ -0,0 +1,137 @@
+"""Stable exceptions raised by the transport-independent workflow client."""
+
+from __future__ import annotations
+
+from copy import deepcopy
+from dataclasses import dataclass
+
+from wf_artifacts import DependencyDiagnostic
+
+
+class WorkflowClientError(Exception):
+ """Base class for errors that can be handled by workflow callers."""
+
+ code: int | str | None
+ data: object
+
+ def __init__(
+ self,
+ message: str = "",
+ *,
+ code: int | str | None = None,
+ data: object = None,
+ ) -> None:
+ self.code = code
+ self.data = deepcopy(data)
+ super().__init__(message)
+
+
+class TransportError(WorkflowClientError):
+ """The client could not communicate with the workflow service."""
+
+
+class ProtocolError(WorkflowClientError):
+ """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.message = message
+ super().__init__(message, code=code, data=data)
+ self.args = (str(self),)
+
+ def __str__(self) -> str:
+ if isinstance(self.data, dict) and isinstance(self.data.get("message"), str):
+ return f"{self.message}: {self.data['message']}"
+ return self.message
+
+
+@dataclass(slots=True)
+class InvalidResponse(ProtocolError):
+ """A response failed validation for one named workflow operation."""
+
+ operation: str
+ details: str
+
+ def __post_init__(self) -> None:
+ object.__setattr__(self, "args", (str(self),))
+
+ def __str__(self) -> str:
+ return f"invalid response from {self.operation}: {self.details}"
+
+
+class CapabilityNotFound(WorkflowClientError):
+ """The requested remote capability does not exist."""
+
+
+class ArtifactNotFound(WorkflowClientError):
+ """The requested immutable artifact version does not exist."""
+
+
+class ArtifactVersionConflict(WorkflowClientError):
+ """An artifact version conflicts with an existing saved version."""
+
+
+@dataclass(slots=True)
+class DeploymentRequired(WorkflowClientError):
+ """An operation requires an unambiguous or repairable deployment."""
+
+ candidate_deployment_ids: tuple[str, ...] = ()
+ diagnostics: tuple[DependencyDiagnostic, ...] = ()
+ unresolved_logical_sources: tuple[str, ...] = ()
+
+ def __post_init__(self) -> None:
+ object.__setattr__(self, "args", (str(self),))
+
+ def __str__(self) -> str:
+ parts: list[str] = []
+ if self.candidate_deployment_ids:
+ parts.append(
+ "candidate deployments: " + ", ".join(self.candidate_deployment_ids)
+ )
+ if self.unresolved_logical_sources:
+ parts.append(
+ "unresolved sources: " + ", ".join(self.unresolved_logical_sources)
+ )
+ if self.diagnostics:
+ parts.append(
+ "diagnostics: "
+ + "; ".join(diagnostic.message for diagnostic in self.diagnostics)
+ )
+ return "deployment required" + (f" ({'; '.join(parts)})" if parts else ".")
+
+
+@dataclass(slots=True)
+class DeploymentNotRunnable(WorkflowClientError):
+ """A selected deployment failed its readiness checks or returned no run."""
+
+ deployment_id: str = ""
+ diagnostics: tuple[DependencyDiagnostic, ...] = ()
+ outcome: str | None = None
+ error: str | None = None
+
+ def __post_init__(self) -> None:
+ object.__setattr__(self, "args", (str(self),))
+
+ def __str__(self) -> str:
+ detail = self.error or self.outcome or "deployment is not runnable"
+ if self.diagnostics:
+ detail += ": " + "; ".join(
+ diagnostic.message for diagnostic in self.diagnostics
+ )
+ return f"deployment {self.deployment_id!r} not runnable: {detail}"
+
+
+class ValidationFailed(WorkflowClientError):
+ """A workflow or dependency validation operation reported errors."""
+
+
+class RevisionConflict(WorkflowClientError):
+ """An editable workflow revision is stale."""
diff --git a/src/wf_client/protocols.py b/src/wf_client/protocols.py
new file mode 100644
index 00000000..795edbea
--- /dev/null
+++ b/src/wf_client/protocols.py
@@ -0,0 +1,131 @@
+"""The narrow transport port consumed by rich workflow client objects."""
+
+from __future__ import annotations
+
+from collections.abc import Sequence
+from typing import Any, Literal, Protocol
+
+from wf_api.models import (
+ CapabilityCallResult,
+ InspectCapabilityResult,
+ ListCapabilitiesResult,
+ ListDeploymentsResult,
+ RunResult,
+ RunTraceResult,
+ SaveArtifactResult,
+ SaveDeploymentResult,
+ ValidateArtifactPlanResult,
+ ValidateDeploymentResult,
+ WorkflowArtifactPayload,
+ WorkflowDeploymentPayload,
+)
+from wf_api.runs import TraceRangeLike
+
+
+class WorkflowClientPort(Protocol):
+ """Minimum operation port required by transport-independent rich objects.
+
+ This deliberately does not inherit ``WorkflowApiSurface``: that protocol
+ includes draft, source-admin, registry, and other operations that rich
+ workflow objects do not need.
+ """
+
+ async def list_capabilities(
+ self,
+ *,
+ query: str | None = None,
+ source_id: str | None = None,
+ cursor: str | None = None,
+ limit: int = 50,
+ ) -> ListCapabilitiesResult: ...
+
+ async def inspect_capability(
+ self,
+ *,
+ qualified_name: str,
+ ) -> InspectCapabilityResult: ...
+
+ async def call_capability(
+ self,
+ *,
+ qualified_name: str,
+ payload: dict[str, Any],
+ deployment_id: str | None = None,
+ ) -> CapabilityCallResult: ...
+
+ async def inspect_artifact(
+ self,
+ *,
+ artifact_id: str,
+ version: int,
+ ) -> WorkflowArtifactPayload: ...
+
+ 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: ...
+
+ 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: ...
+
+ async def list_deployments(self) -> ListDeploymentsResult: ...
+
+ async def inspect_deployment(
+ self,
+ *,
+ deployment_id: str,
+ ) -> WorkflowDeploymentPayload: ...
+
+ async def save_deployment(
+ self,
+ deployment: dict[str, Any],
+ ) -> SaveDeploymentResult: ...
+
+ async def validate_deployment(
+ self,
+ *,
+ deployment_id: str,
+ live_check: bool = False,
+ ) -> ValidateDeploymentResult: ...
+
+ async def run_deployment(
+ self,
+ *,
+ deployment_id: str,
+ workflow_input: dict[str, Any],
+ trace_range: TraceRangeLike | None = None,
+ ) -> RunResult: ...
+
+ async def inspect_run(self, *, run_id: str) -> RunResult: ...
+
+ async def resume_run(
+ self,
+ *,
+ run_id: str,
+ resume_payload: dict[str, Any],
+ resume_outcome: str = "submitted",
+ trace_range: TraceRangeLike | None = None,
+ ) -> RunResult: ...
+
+ async def read_run_trace(
+ self,
+ *,
+ run_id: str,
+ trace_range: TraceRangeLike,
+ ) -> RunTraceResult: ...
diff --git a/src/wf_client/runs.py b/src/wf_client/runs.py
new file mode 100644
index 00000000..a389f44e
--- /dev/null
+++ b/src/wf_client/runs.py
@@ -0,0 +1,291 @@
+"""Immutable snapshots for durable workflow runs and bounded traces."""
+
+from __future__ import annotations
+
+from collections.abc import Mapping
+from copy import deepcopy
+from dataclasses import dataclass, field
+from typing import Any
+
+from pydantic import ValidationError
+
+from wf_api import TraceRange
+from wf_artifacts import DependencyDiagnostic
+from wf_core import InterruptRequest, InterruptRoute, TraceEntry, WorkflowRef
+
+from ._identity import require_response_identity
+from ._repr import html_repr, short_repr
+from .codec import DecodedRunResult, decode_run_result, decode_trace_result
+from .errors import DeploymentNotRunnable, InvalidResponse
+from .protocols import WorkflowClientPort
+
+
+@dataclass(frozen=True, slots=True)
+class TracePage:
+ """One bounded, already-loaded slice of a durable run's execution trace."""
+
+ start: int
+ limit: int
+ frames: tuple[TraceEntry, ...]
+ truncated: bool
+ trace_count: int
+
+ def __repr__(self) -> str:
+ return short_repr(
+ type(self).__name__,
+ start=self.start,
+ limit=self.limit,
+ frames=f"{len(self.frames)} loaded/{self.trace_count} total",
+ truncated=self.truncated,
+ )
+
+ def _repr_html_(self) -> str:
+ return html_repr(
+ type(self).__name__,
+ start=self.start,
+ limit=self.limit,
+ frames=f"{len(self.frames)} loaded/{self.trace_count} total",
+ truncated=self.truncated,
+ )
+
+
+def _interrupt(
+ payload: Mapping[str, Any] | None,
+ *,
+ operation: str,
+) -> InterruptRequest | None:
+ if payload is None:
+ return None
+ data = dict(payload)
+ route_data = data.get("route")
+ route = None
+ if route_data is not None:
+ route_values = dict(route_data)
+ workflow_ref = route_values.get("workflow_ref")
+ try:
+ route = InterruptRoute(
+ frame_id=route_values["frame_id"],
+ node_id=route_values["node_id"],
+ scope_id=route_values["scope_id"],
+ lineage_id=route_values["lineage_id"],
+ parent_frame_id=route_values["parent_frame_id"],
+ workflow_ref=WorkflowRef.model_validate(workflow_ref),
+ )
+ except (KeyError, TypeError, ValueError, ValidationError) as exc:
+ raise InvalidResponse(
+ operation=operation,
+ details=f"invalid interrupt route: {exc}",
+ ) from exc
+ data["route"] = route
+ # ``InterruptPayload`` is intentionally consumed at this boundary; public
+ # clients receive the core runtime request instead of a wire TypedDict.
+ return InterruptRequest(**data)
+
+
+def _run_from_decoded(
+ port: WorkflowClientPort,
+ decoded: DecodedRunResult,
+ *,
+ expected_run_id: str | None = None,
+ expected_deployment_id: str | None = None,
+ operation: str = "workflow.runs.inspect",
+) -> Run:
+ if expected_run_id is not None and decoded.run_id != expected_run_id:
+ require_response_identity(
+ operation=operation,
+ actual={"run_id": decoded.run_id},
+ expected={"run_id": expected_run_id},
+ )
+ 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:
+ raise DeploymentNotRunnable(
+ deployment_id=decoded.deployment_id,
+ diagnostics=decoded.diagnostics,
+ outcome=decoded.outcome,
+ error=decoded.error,
+ )
+ return Run(
+ _port=port,
+ run_id=decoded.run_id,
+ deployment_id=decoded.deployment_id,
+ status=decoded.status,
+ outcome=decoded.outcome,
+ output=decoded.output,
+ interrupt=_interrupt(decoded.interrupt, operation=operation),
+ diagnostics=decoded.diagnostics,
+ trace_count=decoded.trace_count,
+ )
+
+
+@dataclass(frozen=True, slots=True, init=False)
+class Run:
+ """Immutable client snapshot of one durable deployment run."""
+
+ _port: WorkflowClientPort = field(repr=False, compare=False)
+ run_id: str
+ deployment_id: str
+ status: str
+ outcome: str | None
+ _output: dict[str, Any] | None = field(repr=False)
+ _interrupt: InterruptRequest | None = field(repr=False)
+ _diagnostics: tuple[DependencyDiagnostic, ...] = field(repr=False)
+ 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:
+ return short_repr(
+ type(self).__name__,
+ run_id=self.run_id,
+ deployment_id=self.deployment_id,
+ status=self.status,
+ outcome=self.outcome,
+ output=self._output,
+ diagnostics=f"{len(self._diagnostics)} diagnostics",
+ trace=f"{self.trace_count} frames",
+ )
+
+ def _repr_html_(self) -> str:
+ return html_repr(
+ type(self).__name__,
+ run_id=self.run_id,
+ deployment_id=self.deployment_id,
+ status=self.status,
+ outcome=self.outcome,
+ output=self._output,
+ diagnostics=f"{len(self._diagnostics)} diagnostics",
+ trace=f"{self.trace_count} frames (use trace() for a bounded page)",
+ )
+
+ @classmethod
+ def from_payload(
+ cls,
+ port: WorkflowClientPort,
+ payload: object,
+ *,
+ expected_run_id: str | None = None,
+ expected_deployment_id: str | None = None,
+ operation: str = "workflow.runs.inspect",
+ ) -> Run:
+ """Validate one run response and reconstruct its immutable snapshot."""
+ return _run_from_decoded(
+ port,
+ decode_run_result(payload, operation=operation),
+ expected_run_id=expected_run_id,
+ expected_deployment_id=expected_deployment_id,
+ operation=operation,
+ )
+
+ async def refresh(self) -> Run:
+ """Read the current server snapshot without mutating this run."""
+ return self.from_payload(
+ self._port,
+ await self._port.inspect_run(run_id=self.run_id),
+ expected_run_id=self.run_id,
+ expected_deployment_id=self.deployment_id,
+ operation="workflow.runs.inspect",
+ )
+
+ async def resume(
+ self,
+ response: Mapping[str, Any],
+ *,
+ outcome: str = "submitted",
+ ) -> Run:
+ """Resume an interrupted run and return the server's new snapshot."""
+ if self.status != "interrupted" or self._interrupt is None:
+ raise ValueError("only interrupted runs can be resumed")
+ if not self._interrupt.resumable:
+ raise ValueError("run interrupt is not resumable")
+ return self.from_payload(
+ self._port,
+ await self._port.resume_run(
+ run_id=self.run_id,
+ resume_payload=dict(response),
+ resume_outcome=outcome,
+ ),
+ expected_run_id=self.run_id,
+ expected_deployment_id=self.deployment_id,
+ operation="workflow.runs.resume",
+ )
+
+ async def trace(self, *, start: int = 0, limit: int = 25) -> TracePage:
+ """Read a bounded trace page, validating bounds before remote I/O."""
+ if start < 0:
+ raise ValueError("start must be >= 0")
+ if limit <= 0 or limit > 100:
+ raise ValueError("limit must be between 1 and 100")
+ decoded = decode_trace_result(
+ await self._port.read_run_trace(
+ run_id=self.run_id,
+ 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 ()))
+ return TracePage(
+ start=start,
+ limit=limit,
+ frames=frames,
+ truncated=bool(decoded.trace_truncated),
+ trace_count=decoded.trace_count,
+ )
diff --git a/src/wf_client/workflows.py b/src/wf_client/workflows.py
new file mode 100644
index 00000000..8b366a76
--- /dev/null
+++ b/src/wf_client/workflows.py
@@ -0,0 +1,290 @@
+"""Immutable workflow artifacts and validation snapshots."""
+
+from __future__ import annotations
+
+from collections.abc import Mapping
+from dataclasses import dataclass, field
+from typing import TYPE_CHECKING, Any, Literal
+
+from wf_artifacts.models import (
+ DriftPolicy,
+ RequiredCapability,
+)
+from wf_artifacts.models import (
+ WorkflowArtifact as ArtifactDomainModel,
+)
+from wf_core import ValidationReport, Workflow
+
+from ._identity import require_response_identity
+from ._repr import html_repr, short_repr
+from .codec import decode_save_deployment
+from .errors import InvalidResponse, ValidationFailed
+
+if TYPE_CHECKING:
+ from .authoring import EditableWorkflow
+ from .deployments import Deployment
+ from .protocols import WorkflowClientPort
+ from .runs import Run
+
+
+@dataclass(frozen=True, slots=True)
+class ArtifactRef:
+ """Stable identity of one immutable workflow artifact version."""
+
+ artifact_id: str
+ version: int
+
+ def __repr__(self) -> str:
+ return short_repr(
+ type(self).__name__, artifact_id=self.artifact_id, version=self.version
+ )
+
+ def _repr_html_(self) -> str:
+ return html_repr(
+ type(self).__name__, artifact_id=self.artifact_id, version=self.version
+ )
+
+
+@dataclass(frozen=True, slots=True)
+class WorkflowDiagnostic:
+ """One server-side workflow-plan diagnostic returned by validation."""
+
+ severity: str
+ code: str
+ path: str
+ message: str
+ repair_hint: str | None = None
+
+ def __repr__(self) -> str:
+ return short_repr(
+ type(self).__name__,
+ severity=self.severity,
+ code=self.code,
+ path=self.path,
+ message=self.message,
+ )
+
+ def _repr_html_(self) -> str:
+ return html_repr(
+ type(self).__name__,
+ severity=self.severity,
+ code=self.code,
+ path=self.path,
+ message=self.message,
+ )
+
+
+# Keep the short name used by the public design available without requiring a
+# second diagnostic implementation.
+Diagnostic = WorkflowDiagnostic
+
+
+@dataclass(frozen=True, slots=True)
+class WorkflowValidation:
+ """Combined deterministic local and server-side plan validation result."""
+
+ local: ValidationReport
+ remote_status: Literal["valid", "invalid", "not_run"]
+ remote_diagnostics: tuple[WorkflowDiagnostic, ...]
+
+ @property
+ def ok(self) -> bool:
+ return self.local.ok and self.remote_status == "valid"
+
+ def __repr__(self) -> str:
+ return short_repr(
+ type(self).__name__,
+ ok=self.ok,
+ remote_status=self.remote_status,
+ diagnostics=f"{len(self.remote_diagnostics)} diagnostics",
+ )
+
+ def _repr_html_(self) -> str:
+ return html_repr(
+ type(self).__name__,
+ ok=self.ok,
+ remote_status=self.remote_status,
+ diagnostics=f"{len(self.remote_diagnostics)} diagnostics",
+ )
+
+ def raise_for_errors(self) -> None:
+ """Raise a useful error for either local or remote validation failures."""
+ self.local.raise_for_errors()
+ if self.remote_status != "invalid":
+ return
+ rendered = "\n".join(
+ f"- [{diagnostic.code}] {diagnostic.path}: {diagnostic.message}"
+ for diagnostic in self.remote_diagnostics
+ )
+ raise ValidationFailed(
+ "Workflow server validation failed"
+ + (f":\n{rendered}" if rendered else ".")
+ )
+
+
+@dataclass(frozen=True, slots=True, init=False)
+class WorkflowArtifact:
+ """Immutable client snapshot retaining the validated artifact and workflow."""
+
+ _port: WorkflowClientPort = field(repr=False, compare=False)
+ _artifact: ArtifactDomainModel = field(repr=False)
+ _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
+ def ref(self) -> ArtifactRef:
+ return ArtifactRef(self._artifact.id, self._artifact.version)
+
+ def __repr__(self) -> str:
+ return short_repr(
+ type(self).__name__,
+ ref=self.ref,
+ title=self.title,
+ required_capabilities=f"{len(self.required_capabilities)} capabilities",
+ )
+
+ def _repr_html_(self) -> str:
+ return html_repr(
+ type(self).__name__,
+ ref=self.ref,
+ title=self.title,
+ description=self.description,
+ required_capabilities=f"{len(self.required_capabilities)} capabilities",
+ )
+
+ @property
+ def title(self) -> str:
+ return self._artifact.title
+
+ @property
+ def description(self) -> str | None:
+ return self._artifact.description
+
+ @property
+ def required_capabilities(self) -> tuple[RequiredCapability, ...]:
+ return tuple(
+ capability.model_copy(deep=True)
+ if isinstance(capability, RequiredCapability)
+ else RequiredCapability.model_validate(capability)
+ for capability in self._artifact.required_capabilities
+ )
+
+ @property
+ def workflow_dependencies(self) -> dict[str, int]:
+ return dict(self._artifact.workflow_dependencies)
+
+ def inspect(self) -> Workflow:
+ """Return a deep copy so inspecting an artifact cannot mutate its snapshot."""
+ return self._workflow.model_copy(deep=True)
+
+ def edit(self) -> EditableWorkflow:
+ """Seed an editable builder from this exact immutable artifact version."""
+ # Import lazily to avoid the authoring/workflows module cycle.
+ from .authoring import EditableWorkflow
+
+ return EditableWorkflow.from_artifact(self)
+
+ async def deploy(
+ self,
+ deployment_id: str,
+ *,
+ bindings: Mapping[str, str] | None = None,
+ drift_policy: DriftPolicy = DriftPolicy.BLOCK,
+ ) -> Deployment:
+ """Save, inspect, and validate a deployment for this artifact version."""
+ from .deployments import Deployment
+
+ saved = decode_save_deployment(
+ await self._port.save_deployment(
+ {
+ "id": deployment_id,
+ "artifact_id": self._artifact.id,
+ "artifact_version": self._artifact.version,
+ "bindings": dict(bindings or {}),
+ "drift_policy": drift_policy,
+ }
+ )
+ )
+ require_response_identity(
+ operation="workflow.deployments.save",
+ actual={
+ "deployment_id": saved["deployment_id"],
+ "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(
+ self._port,
+ await self._port.inspect_deployment(deployment_id=deployment_id),
+ )
+ if deployment.deployment_id != deployment_id:
+ raise InvalidResponse(
+ operation="workflow.deployments.inspect",
+ details=(
+ f"inspected deployment {deployment.deployment_id!r} does not "
+ f"match requested {deployment_id!r}"
+ ),
+ )
+ if (
+ deployment.artifact_id != self._artifact.id
+ or deployment.artifact_version != self._artifact.version
+ ):
+ raise InvalidResponse(
+ operation="workflow.deployments.inspect",
+ details=(
+ f"deployment {deployment_id!r} does not target artifact "
+ f"{self._artifact.id!r} version {self._artifact.version}"
+ ),
+ )
+ validation = await deployment.validate()
+ return deployment.with_validation(
+ diagnostics=validation.diagnostics,
+ runnable=validation.runnable,
+ )
+
+ async def run(
+ self,
+ workflow_input: Mapping[str, Any],
+ *,
+ deployment_id: str | None = None,
+ bindings: Mapping[str, str] | None = None,
+ drift_policy: DriftPolicy = DriftPolicy.BLOCK,
+ ) -> Run:
+ """Run the artifact under the strict deployment selection policy."""
+ from .deployments import run_artifact
+
+ return await run_artifact(
+ self,
+ workflow_input,
+ deployment_id=deployment_id,
+ bindings=bindings,
+ drift_policy=drift_policy,
+ )
diff --git a/src/wf_contract_manifest/__init__.py b/src/wf_contract_manifest/__init__.py
index 9384210b..73b18c73 100644
--- a/src/wf_contract_manifest/__init__.py
+++ b/src/wf_contract_manifest/__init__.py
@@ -22,6 +22,7 @@ def __getattr__(name: str) -> object:
return generate_manifest
raise AttributeError(name)
+
__all__ = [
"ContractManifest",
"JsonSchema",
diff --git a/src/wf_contract_manifest/__main__.py b/src/wf_contract_manifest/__main__.py
index f8892551..a508a87c 100644
--- a/src/wf_contract_manifest/__main__.py
+++ b/src/wf_contract_manifest/__main__.py
@@ -15,7 +15,9 @@ from .model import ManifestError
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"))
return parser
diff --git a/src/wf_contract_manifest/generate.py b/src/wf_contract_manifest/generate.py
index f27c2d28..b23dbf9f 100644
--- a/src/wf_contract_manifest/generate.py
+++ b/src/wf_contract_manifest/generate.py
@@ -14,8 +14,15 @@ from .normalize import manifest_from_openrpc
def generate_manifest() -> ContractManifest:
"""Compose the real server against an isolated store and normalize OpenRPC."""
with TemporaryDirectory(prefix="wf-contract-manifest-") as directory:
- server = build_local_static_workflow_server(Path(directory) / "store")
- document = cast(dict[str, object], create_rpc_app(server).get_openrpc())
+ # The checked contract describes the complete opt-in API. Product
+ # composition remains draft-free by default; contract generation is
+ # the deliberate compatibility seam that asks for the full surface.
+ server = build_local_static_workflow_server(
+ Path(directory) / "store", drafts=True
+ )
+ document = cast(
+ dict[str, object], create_rpc_app(server, drafts=True).get_openrpc()
+ )
# Normalization deliberately drops framework metadata that could carry
# process-local paths or transport details.
return manifest_from_openrpc(document)
diff --git a/src/wf_mcp/broker/config.py b/src/wf_mcp/broker/config.py
index 087b08ed..b97d2664 100644
--- a/src/wf_mcp/broker/config.py
+++ b/src/wf_mcp/broker/config.py
@@ -163,7 +163,9 @@ def build_service_from_config(config: BrokerConfig) -> WfMcpService:
"""Create a broker service with SDK adapters for configured connections."""
runtime_factory = PersistentSessionFactory()
store_roots = config.store_roots
- workflow_stores = file_workflow_stores(store_roots.workflow_root)
+ # The broker's documented workflow/draft tools are a draft-bearing
+ # composition, so opt into the otherwise disabled draft store explicitly.
+ workflow_stores = file_workflow_stores(store_roots.workflow_root, drafts=True)
# Keep FileStore as the compatibility facade on WfMcpService.store while
# focused services receive role-specific stores.
auth_store = FileAuthStore(store_roots.auth_root)
diff --git a/src/wf_mcp/broker/server.py b/src/wf_mcp/broker/server.py
index 0b258631..13c524d0 100644
--- a/src/wf_mcp/broker/server.py
+++ b/src/wf_mcp/broker/server.py
@@ -64,7 +64,8 @@ def workflow_server_from_service(
raise ValueError("MCP-backed WorkflowServer requires workflow stores")
context = context_from_service(service)
- api: WorkflowApi = durable_workflow_api(context)
+ # MCP's documented workflow server includes mutable draft operations.
+ api: WorkflowApi = durable_workflow_api(context, drafts=True)
source_diagnostics = SourceDiagnosticsProvider(
connection_lookup=service.connections.get,
auth_store=service.auth_store or service.store,
diff --git a/src/wf_mcp/workflow_surface/handlers.py b/src/wf_mcp/workflow_surface/handlers.py
index e3a70791..b99bc04d 100644
--- a/src/wf_mcp/workflow_surface/handlers.py
+++ b/src/wf_mcp/workflow_surface/handlers.py
@@ -13,14 +13,16 @@ if TYPE_CHECKING:
class WorkflowSurfaceHandlers(WorkflowApi):
"""Compatibility wrapper for old wf_mcp.workflow_surface imports.
- New code should construct `WorkflowApi(context_from_service(service))`
+ New code should construct `WorkflowApi(context_from_service(service), drafts=True)`
directly. This shim keeps tests and legacy broker artifact tools working
for legacy callers.
"""
def __init__(self, service: WfMcpService) -> None:
self.service = service
- super().__init__(context_from_service(service))
+ # This compatibility surface exposes the draft methods used by the
+ # workflow-surface tests and legacy broker artifact tools.
+ super().__init__(context_from_service(service), drafts=True)
__all__ = ["WorkflowSurfaceHandlers"]
diff --git a/src/wf_mcp/workflow_surface/tools.py b/src/wf_mcp/workflow_surface/tools.py
index f7f46979..7deda098 100644
--- a/src/wf_mcp/workflow_surface/tools.py
+++ b/src/wf_mcp/workflow_surface/tools.py
@@ -71,7 +71,9 @@ from .models import (
def register_workflow_tools(server: FastMCP[Any], service: WfMcpService) -> None:
"""Register stable workflow tools on the public MCP server surface."""
- handlers = WorkflowApi(context_from_service(service))
+ # This MCP surface registers the draft authoring tools below, so its API
+ # composition must explicitly opt into the draft services.
+ handlers = WorkflowApi(context_from_service(service), drafts=True)
@server.tool(
name="wf.workflow.list_artifacts",
diff --git a/src/wf_server/cli.py b/src/wf_server/cli.py
index c682054f..54e344b6 100644
--- a/src/wf_server/cli.py
+++ b/src/wf_server/cli.py
@@ -68,7 +68,11 @@ def serve(
workflow_config = load_workflow_config(config)
store = workflow_config.server.workflow_store
if server is None and store_root is None:
- server = build_workflow_server_from_workflow_config(workflow_config)
+ # The server CLI exposes the full draft RPC surface.
+ server = build_workflow_server_from_workflow_config(
+ workflow_config,
+ drafts=True,
+ )
elif server is None:
has_mcp_sources = any(
getattr(source, "kind", None) == "mcp"
@@ -101,9 +105,11 @@ def serve(
raise typer.BadParameter(
"--store-root is required when --config is not supplied"
)
- server = build_local_static_workflow_server(resolved_store_root)
+ # The standalone server CLI is the documented draft-capable RPC
+ # endpoint; opt into its persistence explicitly at this boundary.
+ server = build_local_static_workflow_server(resolved_store_root, drafts=True)
- rpc_app = create_rpc_app(server, rpc_path=resolved_rpc_path)
+ rpc_app = create_rpc_app(server, rpc_path=resolved_rpc_path, drafts=True)
uvicorn.run(
rpc_app,
host=resolved_host or "127.0.0.1",
diff --git a/src/wf_server/config.py b/src/wf_server/config.py
index 8c3a9667..5bdcc42a 100644
--- a/src/wf_server/config.py
+++ b/src/wf_server/config.py
@@ -53,11 +53,15 @@ def _build_mcp_workflow_server_from_legacy_config(path: Path) -> WorkflowServer:
def build_workflow_server_from_workflow_config(
config: WorkflowConfigFile,
+ *,
+ drafts: bool = False,
) -> WorkflowServer:
"""Build a WorkflowServer from neutral workflow config.
Local/static configs use built-in sources. Configs with ``kind: "mcp"``
- sources delegate to the MCP provider adapter.
+ sources delegate to the MCP provider adapter. Draft persistence is opt-in
+ for static composition; MCP broker composition owns its draft-bearing
+ workflow surface.
"""
if _has_mcp_sources(config):
return _build_mcp_workflow_server_from_workflow_config(config)
@@ -68,6 +72,7 @@ def build_workflow_server_from_workflow_config(
raise ValueError("wf-rpc-server currently requires filesystem store")
return build_local_static_workflow_server(
store.root,
+ drafts=drafts,
extra_sources=collect_static_sources(_static_source_providers(config)),
)
diff --git a/src/wf_server/context.py b/src/wf_server/context.py
index cd869477..0496fa05 100644
--- a/src/wf_server/context.py
+++ b/src/wf_server/context.py
@@ -295,10 +295,11 @@ def build_local_static_workflow_server(
root: str | Path,
*,
extra_sources: Mapping[str, CapabilitySource] | None = None,
+ drafts: bool = False,
) -> WorkflowServer:
- """Build a durable local/static workflow server composition."""
+ """Build a durable local/static server, with drafts as an explicit opt-in."""
config = WorkflowServerConfig(store_root=Path(root))
- stores = file_workflow_stores(config.store_root)
+ stores = file_workflow_stores(config.store_root, drafts=drafts)
events = InMemoryWorkflowEventRecorder()
sources = builtin_sources()
if extra_sources:
@@ -320,7 +321,7 @@ def build_local_static_workflow_server(
runtime=runtime,
live_sources=None,
)
- api = durable_workflow_api(context)
+ api = durable_workflow_api(context, drafts=drafts)
source_admin = WorkflowSourceAdminApi(context)
admin = WorkflowAdminApi(
connections=EmptyWorkflowConnectionProvider(),
diff --git a/src/wf_transport_rpc_http/app.py b/src/wf_transport_rpc_http/app.py
index 47014dd6..804a232e 100644
--- a/src/wf_transport_rpc_http/app.py
+++ b/src/wf_transport_rpc_http/app.py
@@ -24,7 +24,12 @@ from .methods.source_registry import (
from .methods.sources import register_methods as register_source_methods
-def create_rpc_app(server: WorkflowServer, *, rpc_path: str = "/rpc") -> jsonrpc.API:
+def create_rpc_app(
+ server: WorkflowServer,
+ *,
+ rpc_path: str = "/rpc",
+ drafts: bool = False,
+) -> jsonrpc.API:
"""Build a JSON-RPC HTTP app over an existing WorkflowServer.
Transport code owns only JSON-RPC envelope handling. Workflow semantics stay
@@ -49,7 +54,10 @@ def create_rpc_app(server: WorkflowServer, *, rpc_path: str = "/rpc") -> jsonrpc
}
register_capability_methods(entrypoint, server)
- register_draft_methods(entrypoint, server)
+ if drafts:
+ if not server.api.drafts_enabled:
+ raise ValueError("cannot enable draft RPC methods on a draft-disabled API")
+ register_draft_methods(entrypoint, server)
register_artifact_methods(entrypoint, server)
register_deployment_methods(entrypoint, server)
register_run_methods(entrypoint, server)
diff --git a/src/wf_transport_rpc_http/client/artifacts.py b/src/wf_transport_rpc_http/client/artifacts.py
index e87c1cf6..d5e7796a 100644
--- a/src/wf_transport_rpc_http/client/artifacts.py
+++ b/src/wf_transport_rpc_http/client/artifacts.py
@@ -7,6 +7,7 @@ from wf_api.models import (
DeleteArtifactResult,
ListArtifactsResult,
SaveArtifactResult,
+ ValidateArtifactPlanResult,
WorkflowArtifactPayload,
)
@@ -67,6 +68,27 @@ class RpcArtifactClientMixin:
),
)
+ async def validate_artifact_plan(
+ self: RpcCaller,
+ *,
+ 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 cast(
+ ValidateArtifactPlanResult,
+ await self._call(
+ "workflow.artifacts.validate_plan",
+ {
+ "plan": plan,
+ "outcomes": list(outcomes),
+ "required_capabilities": required_capabilities,
+ "source_bindings": source_bindings,
+ },
+ ),
+ )
+
async def create_artifact_from_plan(
self: RpcCaller,
*,
diff --git a/src/wf_transport_rpc_http/client/base.py b/src/wf_transport_rpc_http/client/base.py
index 9d094c0b..29d150e0 100644
--- a/src/wf_transport_rpc_http/client/base.py
+++ b/src/wf_transport_rpc_http/client/base.py
@@ -7,6 +7,31 @@ from uuid import uuid4
import httpx
+@dataclass(slots=True)
+class RpcProtocolError(RuntimeError):
+ """Structured JSON-RPC application error returned by a remote endpoint.
+
+ The exception intentionally remains a ``RuntimeError`` for compatibility
+ with the existing CLI's remote-operation handling, while retaining the
+ server's machine-readable code and data for richer clients.
+ """
+
+ code: int | str | None
+ message: str
+ data: object = None
+
+ def __post_init__(self) -> None:
+ # ``Exception`` stores positional arguments separately from dataclass
+ # fields; populate them so generic exception tooling sees the useful
+ # rendered message as well.
+ object.__setattr__(self, "args", (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
+
+
class RpcCaller(Protocol):
"""Transport primitive required by domain RPC client mixins."""
@@ -26,9 +51,10 @@ class RpcClientTransport:
http_client: httpx.AsyncClient | None = None
async def _call(self, method: str, params: dict[str, Any]) -> dict[str, Any]:
+ request_id = uuid4().hex
request = {
"jsonrpc": "2.0",
- "id": uuid4().hex,
+ "id": request_id,
"method": method,
"params": params,
}
@@ -39,13 +65,23 @@ class RpcClientTransport:
response = await self.http_client.post(self.url, json=request)
response.raise_for_status()
payload = response.json()
+ if not isinstance(payload, dict):
+ raise RuntimeError("JSON-RPC response must be an object")
+ if payload.get("jsonrpc") != "2.0":
+ raise RuntimeError("JSON-RPC response must declare version '2.0'")
+ if payload.get("id") != request_id:
+ raise RuntimeError("JSON-RPC response id does not match the request")
if "error" in payload:
error = payload["error"]
+ if not isinstance(error, dict):
+ raise RpcProtocolError(None, "JSON-RPC error", error)
+ code = error.get("code")
+ if not isinstance(code, (int, str)):
+ code = None
message = error.get("message", "JSON-RPC error")
- data = error.get("data")
- if isinstance(data, dict) and data.get("message"):
- message = f"{message}: {data['message']}"
- raise RuntimeError(message)
+ if not isinstance(message, str):
+ message = "JSON-RPC error"
+ raise RpcProtocolError(code, message, error.get("data"))
result = payload.get("result")
if not isinstance(result, dict):
raise RuntimeError("JSON-RPC response result must be an object")
diff --git a/src/wf_transport_rpc_http/methods/artifacts.py b/src/wf_transport_rpc_http/methods/artifacts.py
index 775e6b34..8d63d641 100644
--- a/src/wf_transport_rpc_http/methods/artifacts.py
+++ b/src/wf_transport_rpc_http/methods/artifacts.py
@@ -10,6 +10,7 @@ from wf_api.models import (
DeleteArtifactResult,
ListArtifactsResult,
SaveArtifactResult,
+ ValidateArtifactPlanResult,
WorkflowArtifactPayload,
)
from wf_server import WorkflowServer
@@ -21,6 +22,7 @@ from ..models import (
InspectArtifactParams,
ListArtifactsParams,
SaveArtifactParams,
+ ValidateArtifactPlanParams,
)
from ..params import RpcParams
@@ -63,6 +65,22 @@ def register_methods(
except (ValueError, KeyError, LookupError, FileNotFoundError) as exc:
raise_workflow_rpc_error(exc)
+ @entrypoint.method(
+ name="workflow.artifacts.validate_plan", errors=[WorkflowRpcError]
+ )
+ async def workflow_artifacts_validate_plan(
+ params: ValidateArtifactPlanParams = RpcParams(),
+ ) -> ValidateArtifactPlanResult:
+ try:
+ return await server.api.validate_artifact_plan(
+ plan=params.plan,
+ outcomes=tuple(params.outcomes),
+ required_capabilities=params.required_capabilities,
+ source_bindings=params.source_bindings,
+ )
+ except (ValueError, KeyError, LookupError, FileNotFoundError) as exc:
+ raise_workflow_rpc_error(exc)
+
@entrypoint.method(name="workflow.artifacts.list", errors=[WorkflowRpcError])
async def workflow_artifacts_list(
params: ListArtifactsParams = RpcParams(),
diff --git a/src/wf_transport_rpc_http/models.py b/src/wf_transport_rpc_http/models.py
index faf189d4..0f9479f9 100644
--- a/src/wf_transport_rpc_http/models.py
+++ b/src/wf_transport_rpc_http/models.py
@@ -120,6 +120,13 @@ class SaveArtifactParams(RpcParamsModel):
artifact: dict[str, Any]
+class ValidateArtifactPlanParams(RpcParamsModel):
+ plan: dict[str, Any]
+ outcomes: list[str]
+ required_capabilities: dict[str, dict[str, Any]] | None = None
+ source_bindings: dict[str, str] | None = None
+
+
class SaveDeploymentParams(RpcParamsModel):
deployment: dict[str, Any]
diff --git a/tests/artifacts/test_factory.py b/tests/artifacts/test_factory.py
index 23055702..8e3fac7e 100644
--- a/tests/artifacts/test_factory.py
+++ b/tests/artifacts/test_factory.py
@@ -111,6 +111,34 @@ def test_create_workflow_artifact_from_plan_rewrites_bound_node_specs() -> None:
assert str(required.observed_concrete_source) == "demo.personal"
+def test_create_workflow_artifact_from_plan_derives_saved_workflow_dependencies() -> (
+ None
+):
+ plan = _plan()
+ plan["nodes"] = [
+ {
+ "id": "child",
+ "type": "subgraph",
+ "workflow": {"artifact_id": "child_workflow", "version": 7},
+ "input_schema": {"type": "object"},
+ "output_schema": {"type": "object"},
+ "outcomes": ["ok"],
+ }
+ ]
+ plan["start"] = "child"
+ plan["edges"] = [{"from": "child", "outcome": "ok", "to": "__end__"}]
+
+ artifact = create_workflow_artifact_from_plan(
+ artifact_id="parent",
+ version=1,
+ title="Parent",
+ plan=plan,
+ outcomes=("done",),
+ )
+
+ assert artifact.workflow_dependencies == {"child_workflow": 7}
+
+
def test_create_workflow_artifact_from_plan_snapshots_observed_node_spec() -> None:
plan = _plan()
_set_first_node_ref(plan, "demo.personal.echo_tool")
diff --git a/tests/authoring/test_builder.py b/tests/authoring/test_builder.py
index a8a7e0cc..fa17164e 100644
--- a/tests/authoring/test_builder.py
+++ b/tests/authoring/test_builder.py
@@ -21,7 +21,15 @@ from wf_authoring import (
state_path,
)
from wf_authoring.builder.mapping import normalize_input_mapping
-from wf_core import END, EndNode, RunStatus, WorkflowExecutionError
+from wf_core import (
+ END,
+ EndNode,
+ NodeDef,
+ RunStatus,
+ ValidationIssueCode,
+ Workflow,
+ WorkflowExecutionError,
+)
from wf_core.models.steps import (
InputExpressionBinding,
InputPathBinding,
@@ -31,6 +39,163 @@ from wf_core.paths import GraphSourcePath, LocalPath, StatePath
from wf_platform import CapabilityRef
+def _editable_three_step_builder() -> WorkflowBuilder:
+ builder = WorkflowBuilder(
+ name="editable_three_step",
+ input_schema={"type": "object", "properties": {}},
+ state_schema={"type": "object", "properties": {}},
+ output_schema={"type": "object", "properties": {}},
+ start="first",
+ )
+ builder.use_ref("demo.first", id="first")
+ builder.use_ref("demo.second", id="second")
+ builder.use_ref("demo.third", id="third")
+ builder.connect("first", "ok", "second")
+ builder.connect("second", "ok", "third")
+ builder.connect("third", "ok", END)
+ return builder
+
+
+def test_builder_round_trip_preserves_complete_workflow() -> None:
+ original = Workflow.model_validate(
+ {
+ "name": "round_trip",
+ "input_schema": {
+ "type": "object",
+ "properties": {"topic": {"type": "string"}},
+ },
+ "state_schema": {
+ "type": "object",
+ "properties": {"result": {"type": "string"}},
+ },
+ "output_schema": {
+ "type": "object",
+ "properties": {"result": {"type": "string"}},
+ },
+ "output": [{"path": "state.result", "target": "result"}],
+ "node_defs": [
+ {
+ "name": "app.default.search",
+ "input_schema": {"type": "object", "properties": {}},
+ "output_schema": {
+ "type": "object",
+ "properties": {"result": {"type": "string"}},
+ },
+ "outcomes": ["ok"],
+ }
+ ],
+ "outcomes": ["ok"],
+ "start": "search",
+ "nodes": [
+ {
+ "id": "search",
+ "type": "node",
+ "node": "app.default.search",
+ "input": [],
+ "output": [{"source": "result", "target": "state.result"}],
+ },
+ {"id": "end_ok", "type": "end", "outcome": "ok"},
+ ],
+ "edges": [{"from": "search", "outcome": "ok", "to": "end_ok"}],
+ }
+ )
+
+ builder = WorkflowBuilder.from_workflow(original)
+ rebuilt = builder.compile()
+
+ assert rebuilt.model_dump(mode="json", by_alias=True) == original.model_dump(
+ mode="json", by_alias=True
+ )
+ builder.set_output([{"value": "changed", "target": "result"}])
+ original_output = original.output[0]
+ assert isinstance(original_output, InputPathBinding)
+ assert str(original_output.path) == "state.result"
+
+
+def test_set_route_replaces_unique_source_outcome_edge() -> None:
+ builder = _editable_three_step_builder()
+
+ builder.set_route("first", "ok", "third")
+
+ matching = [
+ edge for edge in builder.edges if edge.from_ == "first" and edge.outcome == "ok"
+ ]
+ assert [(edge.from_, edge.outcome, edge.to) for edge in matching] == [
+ ("first", "ok", "third")
+ ]
+
+
+def test_remove_route_removes_only_requested_source_outcome_pair() -> None:
+ builder = _editable_three_step_builder()
+ builder.connect("first", "error", "third")
+
+ builder.remove_route("first", "ok")
+
+ assert [(edge.from_, edge.outcome) for edge in builder.edges] == [
+ ("second", "ok"),
+ ("third", "ok"),
+ ("first", "error"),
+ ]
+
+
+def test_remove_step_rejects_referenced_step() -> None:
+ builder = _editable_three_step_builder()
+
+ with pytest.raises(ValueError, match="still referenced by route"):
+ builder.remove_step("second")
+
+
+def test_use_contract_registers_schema_only_external_node() -> None:
+ builder = WorkflowBuilder(
+ name="contract_builder",
+ input_schema={"type": "object", "properties": {"topic": {"type": "string"}}},
+ state_schema={
+ "type": "object",
+ "properties": {"result": {"type": "string"}},
+ },
+ output_schema={"type": "object", "properties": {"result": {"type": "string"}}},
+ )
+ contract = NodeDef.model_validate(
+ {
+ "name": "app.remote.search",
+ "input_schema": {
+ "type": "object",
+ "properties": {"topic": {"type": "string"}},
+ },
+ "output_schema": {
+ "type": "object",
+ "properties": {"result": {"type": "string"}},
+ },
+ "outcomes": ["ok"],
+ }
+ )
+
+ step = builder.use_contract(contract, id="search")
+ builder.set_entry_point(step)
+ builder.connect(step, "ok", END)
+
+ assert step.node == contract.name
+ assert contract.name in {node_def.name for node_def in builder.compile().node_defs}
+ assert builder.node_specs == {}
+ assert builder.registry() == {}
+ assert isinstance(step.input[0], InputPathBinding)
+ assert step.input[0].path == GraphSourcePath.input("topic")
+ assert step.output[0].target == StatePath.of("result")
+
+
+def test_validate_structure_reports_unknown_start_when_unset() -> None:
+ builder = WorkflowBuilder(
+ name="missing_start",
+ input_schema={},
+ state_schema={"type": "object"},
+ output_schema={},
+ )
+
+ report = builder.validate_structure()
+
+ assert report.errors[0].code == ValidationIssueCode.UNKNOWN_START
+
+
def test_builder_auto_binds_matching_node_inputs_and_outputs_to_state() -> None:
builder = WorkflowBuilder(
name="auto_bind_demo",
diff --git a/tests/examples/test_lda_report_workflow_example.py b/tests/examples/test_lda_report_workflow_example.py
index a097677c..89591f76 100644
--- a/tests/examples/test_lda_report_workflow_example.py
+++ b/tests/examples/test_lda_report_workflow_example.py
@@ -256,9 +256,7 @@ async def test_lda_report_workflow_artifact_interrupt_resume_path(
"approved",
"selected_issue_ids",
}
- proposed_ids = [
- issue["id"] for issue in interrupt["payload"]["proposed_issues"]
- ]
+ proposed_ids = [issue["id"] for issue in interrupt["payload"]["proposed_issues"]]
assert proposed_ids
started_run_id = started["run_id"]
assert isinstance(started_run_id, str)
diff --git a/tests/wf_api/test_artifact_api.py b/tests/wf_api/test_artifact_api.py
index d081d89d..ddd721ee 100644
--- a/tests/wf_api/test_artifact_api.py
+++ b/tests/wf_api/test_artifact_api.py
@@ -131,7 +131,7 @@ def _artifact_api(
)
service.register_specs("demo.personal", echo_tool)
context = context_from_service(service)
- return WorkflowArtifactApi(context), service
+ return WorkflowArtifactApi(context, drafts=True), service
@pytest.mark.asyncio
@@ -185,6 +185,169 @@ async def test_create_artifact_from_plan_saves_with_observed_node_specs(
assert saved.id == "echo"
+@pytest.mark.asyncio
+async def test_validate_artifact_plan_does_not_persist(tmp_path: Path) -> None:
+ artifact_store = FileWorkflowArtifactStore(tmp_path / "artifacts_validate")
+ api, _service = _artifact_api(artifact_store)
+
+ result = await api.validate_artifact_plan(
+ plan=_echo_artifact().plan,
+ outcomes=("completed",),
+ source_bindings={},
+ )
+
+ assert result["status"] == "valid"
+ assert result["diagnostics"] == []
+ assert await api.list_artifacts(query="echo") == {
+ "nodes": [],
+ "next_cursor": None,
+ "total": 0,
+ }
+
+
+@pytest.mark.asyncio
+async def test_validate_artifact_plan_projects_invalid_plan_diagnostic(
+ tmp_path: Path,
+) -> None:
+ artifact_store = FileWorkflowArtifactStore(tmp_path / "artifacts_invalid")
+ api, _service = _artifact_api(artifact_store)
+ invalid_plan = {**_echo_artifact().plan, "start": "missing"}
+
+ result = await api.validate_artifact_plan(
+ plan=invalid_plan,
+ outcomes=("completed",),
+ source_bindings={},
+ )
+
+ assert result["status"] == "invalid"
+ assert result["diagnostics"] == [
+ {
+ "severity": "error",
+ "code": "artifact_plan_invalid",
+ "path": "plan",
+ "message": "invalid workflow plan: start node 'missing' does not exist",
+ "repair_hint": None,
+ }
+ ]
+ assert await api.list_artifacts(query="echo") == {
+ "nodes": [],
+ "next_cursor": None,
+ "total": 0,
+ }
+
+
+@pytest.mark.asyncio
+async def test_validate_artifact_plan_roots_capability_diagnostic_at_request_field(
+ tmp_path: Path,
+) -> None:
+ artifact_store = FileWorkflowArtifactStore(tmp_path / "artifacts_requirement")
+ api, _service = _artifact_api(artifact_store)
+
+ result = await api.validate_artifact_plan(
+ plan=_echo_artifact().plan,
+ outcomes=("completed",),
+ required_capabilities={
+ "broken": {
+ "ref": {"source": "demo", "capability_key": "echo"},
+ "kind": "not-a-capability-kind",
+ }
+ },
+ source_bindings={},
+ )
+
+ assert result["status"] == "invalid"
+ assert result["diagnostics"][0]["path"] == "required_capabilities.broken.kind"
+
+
+@pytest.mark.asyncio
+async def test_validate_artifact_plan_propagates_unexpected_value_error(
+ tmp_path: Path,
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ artifact_store = FileWorkflowArtifactStore(tmp_path / "artifacts_unexpected")
+ api, _service = _artifact_api(artifact_store)
+
+ def raise_programming_error(_context: object) -> dict[str, object]:
+ raise ValueError("unexpected preparation defect")
+
+ monkeypatch.setattr(
+ "wf_api.artifacts.observed_node_specs",
+ raise_programming_error,
+ )
+
+ with pytest.raises(ValueError, match="unexpected preparation defect"):
+ await api.validate_artifact_plan(
+ plan=_echo_artifact().plan,
+ outcomes=("completed",),
+ source_bindings={},
+ )
+
+
+@pytest.mark.asyncio
+async def test_validate_artifact_plan_derives_saved_workflow_dependencies(
+ tmp_path: Path,
+) -> None:
+ artifact_store = FileWorkflowArtifactStore(tmp_path / "artifacts_dependency")
+ api, _service = _artifact_api(artifact_store)
+ plan = _echo_artifact().plan
+ plan["start"] = "child"
+ plan["nodes"] = [
+ {
+ "id": "child",
+ "type": "subgraph",
+ "workflow": {"artifact_id": "child_workflow", "version": 7},
+ "input_schema": {"type": "object"},
+ "output_schema": {"type": "object"},
+ "outcomes": ["completed"],
+ }
+ ]
+ plan["edges"] = [{"from": "child", "outcome": "completed", "to": "__end__"}]
+
+ result = await api.validate_artifact_plan(
+ plan=plan,
+ outcomes=("completed",),
+ source_bindings={},
+ )
+
+ assert result["status"] == "valid"
+ assert result["workflow_dependencies"] == {"child_workflow": 7}
+
+
+@pytest.mark.asyncio
+async def test_validate_artifact_plan_rejects_conflicting_child_version_pins(
+ tmp_path: Path,
+) -> None:
+ artifact_store = FileWorkflowArtifactStore(tmp_path / "artifacts_pin_conflict")
+ api, _service = _artifact_api(artifact_store)
+ plan = _echo_artifact().plan
+ plan["start"] = "child_v1"
+ plan["nodes"] = [
+ {
+ "id": node_id,
+ "type": "subgraph",
+ "workflow": {"artifact_id": "child_workflow", "version": version},
+ "input_schema": {"type": "object"},
+ "output_schema": {"type": "object"},
+ "outcomes": ["completed"],
+ }
+ for node_id, version in (("child_v1", 1), ("child_v2", 2))
+ ]
+ plan["edges"] = [
+ {"from": "child_v1", "outcome": "completed", "to": "child_v2"},
+ {"from": "child_v2", "outcome": "completed", "to": "__end__"},
+ ]
+
+ result = await api.validate_artifact_plan(
+ plan=plan,
+ outcomes=("completed",),
+ source_bindings={},
+ )
+
+ assert result["status"] == "invalid"
+ assert result["diagnostics"][0]["path"] == "plan"
+ assert "conflicting versions 1 and 2" in result["diagnostics"][0]["message"]
+
+
@pytest.mark.asyncio
async def test_create_artifact_from_workspace_suggests_exact_available_source_binding(
tmp_path: Path,
@@ -349,7 +512,7 @@ def _api(root: Path) -> WorkflowApi:
artifact_store=FileWorkflowArtifactStore(root),
draft_workspace_store=FileDraftWorkspaceStore(mcp_root),
)
- return WorkflowApi(context_from_service(service))
+ return WorkflowApi(context_from_service(service), drafts=True)
@pytest.mark.asyncio
diff --git a/tests/wf_api/test_capability_api.py b/tests/wf_api/test_capability_api.py
index 383e30bd..0f2ad7d0 100644
--- a/tests/wf_api/test_capability_api.py
+++ b/tests/wf_api/test_capability_api.py
@@ -39,7 +39,7 @@ def _capability_api(
)
service.register_specs("demo.personal", failing_tool)
context = context_from_service(service)
- return WorkflowCapabilityApi(context), service
+ return WorkflowCapabilityApi(context, drafts=True), service
@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 result["wrapper_hints"]["capability_name"] == "demo.personal.echo_tool"
+ assert api.drafts is not None
fetched = await api.drafts.get_draft_workspace(
workspace_id="echo_ws", include_draft=True
)
diff --git a/tests/wf_api/test_composite_input_workflow.py b/tests/wf_api/test_composite_input_workflow.py
index d7583a1f..f976e484 100644
--- a/tests/wf_api/test_composite_input_workflow.py
+++ b/tests/wf_api/test_composite_input_workflow.py
@@ -42,12 +42,13 @@ def _composite_concat_draft() -> dict[str, object]:
async def test_composite_concat_runs_through_the_platform_registry(
tmp_path: Path,
) -> 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(
workspace_id="composite_concat",
draft=_composite_concat_draft(),
)
+ assert server.api.draft_authoring is not None
authored = await server.api.draft_authoring.set_step_input_bindings(
workspace_id="composite_concat",
revision=1,
diff --git a/tests/wf_api/test_direct_service.py b/tests/wf_api/test_direct_service.py
index f6d3b457..4ec18643 100644
--- a/tests/wf_api/test_direct_service.py
+++ b/tests/wf_api/test_direct_service.py
@@ -27,7 +27,7 @@ def _api(root: Path) -> WorkflowApi:
ConnectionConfig(id="demo.personal", server="demo", account="personal")
)
service.register_specs("demo.personal", echo_tool)
- return WorkflowApi(context_from_service(service))
+ return WorkflowApi(context_from_service(service), drafts=True)
def test_workflow_api_composes_domain_services(tmp_path: Path) -> None:
diff --git a/tests/wf_api/test_drafts_service.py b/tests/wf_api/test_drafts_service.py
index 2336d827..5d6a0391 100644
--- a/tests/wf_api/test_drafts_service.py
+++ b/tests/wf_api/test_drafts_service.py
@@ -171,7 +171,7 @@ async def test_inspect_draft_authoring_contract_projects_selected_capability(
"properties": {"echoed": {"type": "string"}},
}
await draft_api.create_draft_workspace(workspace_id="authoring", draft=draft)
- api = WorkflowApi(authoring.context)
+ api = WorkflowApi(authoring.context, drafts=True)
inventory = _authoring_inventory(
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"}},
}
await draft_api.create_draft_workspace(workspace_id="authoring", draft=draft)
- api = WorkflowApi(authoring.context)
+ api = WorkflowApi(authoring.context, drafts=True)
inventory = _authoring_inventory(
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"}},
}
await draft_api.create_draft_workspace(workspace_id="authoring", draft=draft)
- api = WorkflowApi(authoring.context)
+ api = WorkflowApi(authoring.context, drafts=True)
inventory = _authoring_inventory(
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"}},
}
await draft_api.create_draft_workspace(workspace_id="authoring", draft=draft)
- api = WorkflowApi(authoring.context)
+ api = WorkflowApi(authoring.context, drafts=True)
inventory = _authoring_inventory(
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",
draft=_echo_draft(),
)
- api = WorkflowApi(authoring.context)
+ api = WorkflowApi(authoring.context, drafts=True)
inventory = _authoring_inventory(
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(
workspace_id="authoring", draft=_echo_draft()
)
- api = WorkflowApi(authoring.context)
+ api = WorkflowApi(authoring.context, drafts=True)
with pytest.raises(KeyError, match="unknown draft step"):
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["start"] = "broken"
await draft_api.create_draft_workspace(workspace_id="authoring", draft=draft)
- api = WorkflowApi(authoring.context)
+ api = WorkflowApi(authoring.context, drafts=True)
inventory = _authoring_inventory(
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(
workspace_id="authoring", draft=_echo_draft()
)
- api = WorkflowApi(authoring.context)
+ api = WorkflowApi(authoring.context, drafts=True)
changed = await api.set_draft_name(
workspace_id="authoring",
revision=1,
@@ -1023,7 +1023,7 @@ async def _create_structured_binding_api(
workspace_id=workspace_id,
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(
@@ -1058,7 +1058,7 @@ async def _create_nested_output_binding_api(
workspace_id=workspace_id,
draft=_nested_report_draft(),
)
- return draft_api, service, WorkflowApi(authoring.context)
+ return draft_api, service, WorkflowApi(authoring.context, drafts=True)
@pytest.mark.asyncio
@@ -1163,7 +1163,7 @@ async def test_create_empty_draft_workspace_persists_invalid_skeleton(
) -> None:
artifact_store = FileWorkflowArtifactStore(tmp_path / "drafts_create_empty")
_drafts, _service, authoring = _draft_api(artifact_store)
- facade = WorkflowApi(authoring.context)
+ facade = WorkflowApi(authoring.context, drafts=True)
created = await facade.create_empty_draft_workspace(
workspace_id="control_first",
@@ -1198,7 +1198,7 @@ async def test_create_empty_draft_workspace_preserves_custom_contract(
) -> None:
artifact_store = FileWorkflowArtifactStore(tmp_path / "drafts_create_contract")
_drafts, _service, authoring = _draft_api(artifact_store)
- facade = WorkflowApi(authoring.context)
+ facade = WorkflowApi(authoring.context, drafts=True)
input_schema = {
"type": "object",
"properties": {"topic": {"type": "string"}},
@@ -1249,7 +1249,7 @@ async def test_create_empty_draft_workspace_isolates_default_schemas(
) -> None:
artifact_store = FileWorkflowArtifactStore(tmp_path / "drafts_schema_isolation")
_drafts, _service, authoring = _draft_api(artifact_store)
- facade = WorkflowApi(authoring.context)
+ facade = WorkflowApi(authoring.context, drafts=True)
input_schema = {
"type": "object",
"properties": {"topic": {"type": "string"}},
@@ -1283,7 +1283,7 @@ async def test_create_empty_draft_workspace_reports_duplicate_conflict(
) -> None:
artifact_store = FileWorkflowArtifactStore(tmp_path / "drafts_create_conflict")
_drafts, _service, authoring = _draft_api(artifact_store)
- facade = WorkflowApi(authoring.context)
+ facade = WorkflowApi(authoring.context, drafts=True)
await facade.create_empty_draft_workspace(
workspace_id="control_first",
name="control_first",
@@ -1317,7 +1317,7 @@ async def test_create_empty_draft_workspace_rejects_invalid_contract_before_muta
) -> None:
artifact_store = FileWorkflowArtifactStore(tmp_path / "drafts_create_rejected")
_drafts, _service, authoring = _draft_api(artifact_store)
- facade = WorkflowApi(authoring.context)
+ facade = WorkflowApi(authoring.context, drafts=True)
with pytest.raises(ValueError):
await facade.create_empty_draft_workspace(
@@ -1335,7 +1335,7 @@ async def test_set_draft_start_and_contract_replace_top_level_fields_atomically(
) -> None:
artifact_store = FileWorkflowArtifactStore(tmp_path / "drafts_set_lifecycle")
_drafts, _service, authoring = _draft_api(artifact_store)
- facade = WorkflowApi(authoring.context)
+ facade = WorkflowApi(authoring.context, drafts=True)
await facade.create_empty_draft_workspace(
workspace_id="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}"
)
_drafts, _service, authoring = _draft_api(artifact_store)
- facade = WorkflowApi(authoring.context)
+ facade = WorkflowApi(authoring.context, drafts=True)
await facade.create_empty_draft_workspace(
workspace_id="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}"
)
_drafts, _service, authoring = _draft_api(artifact_store)
- facade = WorkflowApi(authoring.context)
+ facade = WorkflowApi(authoring.context, drafts=True)
await facade.create_empty_draft_workspace(
workspace_id="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)
context = context_from_service(service)
- facade = WorkflowApi(context)
+ facade = WorkflowApi(context, drafts=True)
assert facade.draft_authoring is not None
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",
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(
workspace_id="remote_target",
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["steps"]["report"] = {"join": {}}
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(
workspace_id="non_capability",
include_draft=True,
@@ -4007,7 +4007,7 @@ async def test_add_step_accepts_every_typed_draft_step(
) -> None:
artifact_store = FileWorkflowArtifactStore(tmp_path / f"draft_add_{step_name}")
draft_api, _service, authoring = _draft_api(artifact_store, register_echo=True)
- api = WorkflowApi(authoring.context)
+ api = WorkflowApi(authoring.context, drafts=True)
await draft_api.create_draft_workspace(workspace_id="draft_ws", draft=_echo_draft())
step = TypeAdapter(DraftStep).validate_python(step_payload)
@@ -4033,7 +4033,7 @@ async def test_add_step_routes_incoming_and_outgoing_edges_atomically(
) -> None:
artifact_store = FileWorkflowArtifactStore(tmp_path / "draft_add_routes")
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())
step = TypeAdapter(DraftStep).validate_python(
@@ -4062,7 +4062,7 @@ async def test_add_step_stale_revision_wins_over_content_preflight(
) -> None:
artifact_store = FileWorkflowArtifactStore(tmp_path / "draft_add_stale")
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())
before = await draft_api.get_draft_workspace(
workspace_id="draft_ws", include_draft=True
@@ -4135,7 +4135,7 @@ async def test_add_step_adds_missing_incoming_route_parent_atomically(
) -> None:
artifact_store = FileWorkflowArtifactStore(tmp_path / "draft_add_missing_parent")
draft_api, _service, authoring = _draft_api(artifact_store, register_echo=True)
- api = WorkflowApi(authoring.context)
+ api = WorkflowApi(authoring.context, drafts=True)
draft = _echo_draft()
draft["routes"] = {}
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:
artifact_store = FileWorkflowArtifactStore(tmp_path / "draft_add_empty_routes")
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())
step_adapter = TypeAdapter(DraftStep)
@@ -4207,7 +4207,7 @@ async def test_add_step_rejects_unknown_incoming_outcome_without_mutation(
) -> None:
artifact_store = FileWorkflowArtifactStore(tmp_path / "draft_add_bad_incoming")
draft_api, _service, authoring = _draft_api(artifact_store, register_echo=True)
- api = WorkflowApi(authoring.context)
+ api = WorkflowApi(authoring.context, drafts=True)
await draft_api.create_draft_workspace(workspace_id="draft_ws", draft=_echo_draft())
draft_store = authoring.drafts._draft_store()
@@ -4277,7 +4277,7 @@ async def test_add_step_rejects_invalid_routing_inputs_atomically(
) -> None:
artifact_store = FileWorkflowArtifactStore(tmp_path / "draft_add_errors")
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 _assert_add_step_rejected_without_mutation(
@@ -4338,7 +4338,7 @@ async def test_add_step_rejects_routes_for_non_routable_steps_atomically(
) -> None:
artifact_store = FileWorkflowArtifactStore(tmp_path / "draft_add_forbidden_routes")
draft_api, _service, authoring = _draft_api(artifact_store, register_echo=True)
- api = WorkflowApi(authoring.context)
+ api = WorkflowApi(authoring.context, drafts=True)
await draft_api.create_draft_workspace(workspace_id="draft_ws", draft=_echo_draft())
before = await draft_api.get_draft_workspace(
@@ -4367,7 +4367,7 @@ async def test_add_step_accepts_incomplete_declared_route_subset(
artifact_store = FileWorkflowArtifactStore(tmp_path / "draft_add_partial_routes")
draft_api, service, authoring = _draft_api(artifact_store, register_echo=True)
service.register_specs("demo.personal", echo_tool, _snapshot_tool)
- api = WorkflowApi(authoring.context)
+ api = WorkflowApi(authoring.context, drafts=True)
await draft_api.create_draft_workspace(workspace_id="draft_ws", draft=_echo_draft())
step = TypeAdapter(DraftStep).validate_python(
@@ -6300,7 +6300,7 @@ def _browser_click_api(
_collect_snapshots,
)
context = context_from_service(service)
- return WorkflowApi(context), service
+ return WorkflowApi(context, drafts=True), service
@pytest.mark.asyncio
diff --git a/tests/wf_api/test_durable_context.py b/tests/wf_api/test_durable_context.py
index 82f76b4a..1893e2de 100644
--- a/tests/wf_api/test_durable_context.py
+++ b/tests/wf_api/test_durable_context.py
@@ -54,3 +54,23 @@ def test_durable_workflow_api_returns_workflow_api_with_same_context(tmp_path) -
assert isinstance(api, WorkflowApi)
assert api.context is context
+
+
+def test_durable_workflow_api_can_opt_out_of_draft_store(tmp_path) -> None:
+ stores = file_workflow_stores(tmp_path / "workflow_stores")
+ service = WfMcpService(
+ store=FileStore(tmp_path / "mcp"),
+ artifact_store=stores.artifact_store,
+ draft_workspace_store=None,
+ run_store=stores.run_store,
+ )
+ context = context_from_service(service)
+
+ api = durable_workflow_api(context, drafts=False)
+
+ assert api.drafts_enabled is False
+ assert api.drafts is None
+ assert api.draft_authoring is None
+ assert api.capabilities.drafts is None
+ assert api.capabilities.draft_authoring is None
+ assert api.artifacts.drafts is None
diff --git a/tests/wf_api/test_stores.py b/tests/wf_api/test_stores.py
index 5b3d8f5b..edbe94d1 100644
--- a/tests/wf_api/test_stores.py
+++ b/tests/wf_api/test_stores.py
@@ -10,18 +10,30 @@ from wf_artifacts import (
)
-def test_file_workflow_stores_constructs_all_three_file_stores(tmp_path: Path) -> None:
+def test_file_workflow_stores_skips_draft_store_by_default(tmp_path: Path) -> None:
root = tmp_path / "wf_api_file_workflow_stores"
stores = file_workflow_stores(root)
assert isinstance(stores, WorkflowStores)
assert isinstance(stores.artifact_store, FileWorkflowArtifactStore)
- assert isinstance(stores.draft_workspace_store, FileDraftWorkspaceStore)
+ assert stores.draft_workspace_store is None
assert isinstance(stores.run_store, FileRunStore)
assert stores.artifact_store.root == root
- assert stores.draft_workspace_store.root == root
assert stores.run_store.root == root
+ assert not (root / "draft_workspaces").exists()
+
+
+def test_file_workflow_stores_constructs_draft_store_when_explicitly_enabled(
+ tmp_path: Path,
+) -> None:
+ root = tmp_path / "wf_api_file_workflow_stores_drafts"
+
+ stores = file_workflow_stores(root, drafts=True)
+
+ assert isinstance(stores.draft_workspace_store, FileDraftWorkspaceStore)
+ assert stores.draft_workspace_store.root == root
+ assert (root / "draft_workspaces").is_dir()
def test_wf_api_exports_workflow_stores() -> None:
diff --git a/tests/wf_cli/test_context.py b/tests/wf_cli/test_context.py
index 0a86f617..d7a0dc02 100644
--- a/tests/wf_cli/test_context.py
+++ b/tests/wf_cli/test_context.py
@@ -8,6 +8,7 @@ import typer
from typer.core import TyperCommand
from wf_api import WorkflowApi
+from wf_artifacts import FileWorkflowArtifactStore
from wf_cli.context import (
CliTyperState,
config_path_from_context,
@@ -16,7 +17,6 @@ from wf_cli.context import (
rpc_timeout_from_context,
rpc_url_from_context,
)
-from wf_server.config import build_workflow_server_from_workflow_config
from .conftest import write_python_source_config
@@ -104,7 +104,6 @@ def test_load_cli_context_builds_service_and_handlers(tmp_path: Path) -> None:
def test_load_cli_context_local_uses_workflow_store_override(
tmp_path: Path,
- monkeypatch,
) -> None:
config_path = tmp_path / "wf.json"
config_path.write_text(
@@ -125,21 +124,14 @@ def test_load_cli_context_local_uses_workflow_store_override(
),
encoding="utf-8",
)
- captured: dict[str, object] = {}
-
- def fake_build_workflow_server_from_workflow_config(config):
- captured["store_root"] = config.server.workflow_store.root
- return build_workflow_server_from_workflow_config(config)
-
- monkeypatch.setattr(
- "wf_cli.context.build_workflow_server_from_workflow_config",
- fake_build_workflow_server_from_workflow_config,
- )
-
context = load_cli_context(config_path)
assert context.service is None
- assert captured["store_root"] == (tmp_path / ".workflow").resolve()
+ assert isinstance(context.handlers, WorkflowApi)
+ assert context.handlers.drafts_enabled is True
+ artifact_store = context.handlers.context.artifact_store
+ assert isinstance(artifact_store, FileWorkflowArtifactStore)
+ assert artifact_store.root == (tmp_path / ".workflow").resolve()
@pytest.mark.asyncio
diff --git a/tests/wf_cli/test_remote_target.py b/tests/wf_cli/test_remote_target.py
index 2eb1c858..4233b54a 100644
--- a/tests/wf_cli/test_remote_target.py
+++ b/tests/wf_cli/test_remote_target.py
@@ -329,7 +329,7 @@ def _patch_rpc_client_to_server(monkeypatch, server) -> None:
url=url,
timeout_seconds=timeout_seconds,
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",
),
)
@@ -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:
- 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)
config_path = tmp_path / "wf.json"
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:
- 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)
config_path = tmp_path / "wf.json"
config_path.write_text('{"version": 1}', encoding="utf-8")
@@ -511,7 +511,7 @@ def test_wf_remote_source_inspect_formats_expected_rpc_error(
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)
config_path = tmp_path / "wf.json"
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:
- 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(
"workflow_test_event",
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:
- 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)
config_path = tmp_path / "wf.json"
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:
- 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)
config_path = tmp_path / "wf.json"
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(
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)
rpc_calls: list[tuple[str, dict[str, Any]]] = []
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(
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(
server.api.create_empty_draft_workspace(
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(
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(
server.api.create_empty_draft_workspace(
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:
- server = build_local_static_workflow_server(tmp_path / "store")
+ server = build_local_static_workflow_server(tmp_path / "store", drafts=True)
asyncio.run(
server.api.create_artifact_from_plan(
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:
- server = build_local_static_workflow_server(tmp_path / "store")
+ server = build_local_static_workflow_server(tmp_path / "store", drafts=True)
asyncio.run(
server.api.create_artifact_from_plan(
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:
- 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)
config_path = tmp_path / "wf.json"
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:
- 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)
config_path = tmp_path / "wf.json"
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:
- 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)
config_path = tmp_path / "wf.json"
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:
- 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)
config_path = tmp_path / "wf.json"
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(
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)
rpc_calls: list[tuple[str, dict[str, Any]]] = []
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:
- 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)
config_path = tmp_path / "wf.json"
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(
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)
rpc_calls: list[tuple[str, dict[str, Any]]] = []
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(
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)
rpc_methods: list[str] = []
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(
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)
rpc_methods: list[str] = []
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:
- 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)
config_path = tmp_path / "wf.json"
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:
- 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)
config_path = tmp_path / "wf.json"
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(
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)
config_path = tmp_path / "wf.json"
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(
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)
rpc_methods: list[str] = []
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(
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)
rpc_calls: list[tuple[str, dict[str, Any]]] = []
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:
- 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)
rpc_methods: list[str] = []
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(
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)
rpc_calls: list[tuple[str, dict[str, Any]]] = []
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(
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)
rpc_methods: list[str] = []
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(
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)
config_path = tmp_path / "wf.json"
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:
- 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)
config_path = tmp_path / "wf.json"
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(
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(
server.api.create_draft_workspace(
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:
- server = build_local_static_workflow_server(tmp_path / "store")
+ server = build_local_static_workflow_server(tmp_path / "store", drafts=True)
asyncio.run(
server.api.create_artifact_from_plan(
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:
- 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)
config_path = tmp_path / "wf.json"
config_path.write_text('{"version": 1}', encoding="utf-8")
diff --git a/tests/wf_client/__init__.py b/tests/wf_client/__init__.py
new file mode 100644
index 00000000..f2442955
--- /dev/null
+++ b/tests/wf_client/__init__.py
@@ -0,0 +1 @@
+"""Tests for the transport-independent workflow client boundary."""
diff --git a/tests/wf_client/conftest.py b/tests/wf_client/conftest.py
new file mode 100644
index 00000000..26492796
--- /dev/null
+++ b/tests/wf_client/conftest.py
@@ -0,0 +1,73 @@
+from __future__ import annotations
+
+from typing import Any
+
+import pytest
+
+
+class FakeWorkflowClient:
+ """Small configurable adapter used to test rich clients without HTTP."""
+
+ def __init__(self, **responses: object) -> None:
+ self.responses = responses
+ self.calls: list[tuple[str, dict[str, Any]]] = []
+
+ def response(self, method: str) -> object:
+ return self.responses[method]
+
+ def _response(self, method: str, params: dict[str, Any]) -> object:
+ self.calls.append((method, params))
+ return self.response(method)
+
+ async def list_capabilities(self, **params: Any) -> object:
+ return self._response("workflow.capabilities.list", params)
+
+ async def inspect_capability(self, **params: Any) -> object:
+ return self._response("workflow.capabilities.inspect", params)
+
+ async def call_capability(self, **params: Any) -> object:
+ return self._response("workflow.capabilities.call", params)
+
+ async def inspect_artifact(self, **params: Any) -> object:
+ return self._response("workflow.artifacts.inspect", params)
+
+ async def save_artifact(self, artifact: dict[str, Any]) -> object:
+ return self._response("workflow.artifacts.save", {"artifact": artifact})
+
+ async def validate_artifact_plan(self, **params: Any) -> object:
+ return self._response("workflow.artifacts.validate_plan", params)
+
+ async def create_artifact_from_plan(self, **params: Any) -> object:
+ return self._response("workflow.artifacts.create_from_plan", params)
+
+ async def list_deployments(self) -> object:
+ return self._response("workflow.deployments.list", {})
+
+ async def inspect_deployment(self, **params: Any) -> object:
+ return self._response("workflow.deployments.inspect", params)
+
+ async def save_deployment(self, deployment: dict[str, Any]) -> object:
+ return self._response("workflow.deployments.save", {"deployment": deployment})
+
+ async def validate_deployment(self, **params: Any) -> object:
+ return self._response("workflow.deployments.validate", params)
+
+ async def run_deployment(self, **params: Any) -> object:
+ return self._response("workflow.runs.start", params)
+
+ async def inspect_run(self, **params: Any) -> object:
+ return self._response("workflow.runs.inspect", params)
+
+ async def resume_run(self, **params: Any) -> object:
+ return self._response("workflow.runs.resume", params)
+
+ async def read_run_trace(self, **params: Any) -> object:
+ return self._response("workflow.runs.trace", params)
+
+ async def _call(self, method: str, params: dict[str, Any]) -> object:
+ return self._response(method, params)
+
+
+@pytest.fixture
+def fake_client() -> FakeWorkflowClient:
+ return FakeWorkflowClient()
diff --git a/tests/wf_client/test_app.py b/tests/wf_client/test_app.py
new file mode 100644
index 00000000..9ed4aea8
--- /dev/null
+++ b/tests/wf_client/test_app.py
@@ -0,0 +1,299 @@
+from __future__ import annotations
+
+from typing import Any, cast
+
+import httpx
+import pytest
+
+import wf_client
+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_platform import CapabilityRef
+
+
+def _inspect_payload() -> dict[str, Any]:
+ return {
+ "name": "app.default.search",
+ "source_id": "app.default",
+ "kind": "node_spec",
+ "description": "Search things",
+ "outcomes": ["ok"],
+ "is_async": False,
+ "input_schema": {"type": "object", "properties": {}},
+ "output_schema": {"type": "object", "properties": {}},
+ "wrapper_hints": {
+ "capability_name": "app.default.search",
+ "confidence": "high",
+ "declared_outcomes": ["ok"],
+ "suggested_wrapper_outcomes": ["ok"],
+ "outcome_policy": "preserve_declared",
+ "input_schema": {"type": "object", "properties": {}},
+ "state_schema": {"type": "object", "properties": {}},
+ "output_schema": {"type": "object", "properties": {}},
+ "input_map": {},
+ "output_map": {},
+ "outcome_candidates": [],
+ "missing_decisions": [],
+ "notes": [],
+ },
+ "accepts_context": False,
+ }
+
+
+class _Port:
+ def __init__(self, *, capability_name: str = "app.default.search") -> None:
+ self.calls: list[tuple[str, dict[str, Any]]] = []
+ self.capability_name = capability_name
+
+ async def inspect_capability(self, **params: Any) -> object:
+ self.calls.append(("inspect", params))
+ payload = _inspect_payload()
+ payload["name"] = self.capability_name
+ payload["wrapper_hints"]["capability_name"] = self.capability_name
+ return payload
+
+ async def list_capabilities(self, **params: Any) -> object:
+ self.calls.append(("list", params))
+ return {
+ "next_cursor": None,
+ "total": 1,
+ "capabilities": [
+ {
+ "name": "app.default.search",
+ "source_id": "app.default",
+ "kind": "node_spec",
+ "description": "Search things",
+ "outcomes": ["ok"],
+ "is_async": False,
+ "input_fields": [],
+ "output_fields": [],
+ }
+ ],
+ }
+
+
+def _app(*, capability_name: str = "app.default.search") -> App:
+ return App._from_port(
+ cast(WorkflowClientPort, _Port(capability_name=capability_name))
+ )
+
+
+def test_from_http_jsonrpc_is_lazy(monkeypatch: pytest.MonkeyPatch) -> None:
+ calls: list[str] = []
+ monkeypatch.setattr(
+ httpx.AsyncClient,
+ "post",
+ lambda *args, **kwargs: calls.append("post"),
+ )
+
+ app = App.from_http_jsonrpc("http://localhost:8765/rpc")
+
+ assert app.endpoint == "http://localhost:8765/rpc"
+ 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(
+ _client: object,
+ _url: object,
+ *,
+ json: dict[str, object],
+ **_kwargs: object,
+ ) -> httpx.Response:
+ return httpx.Response(
+ 200,
+ request=httpx.Request("POST", "http://test/rpc"),
+ json={
+ "jsonrpc": "2.0",
+ "id": json["id"],
+ "error": {
+ "code": 5000,
+ "message": "Workflow operation failed",
+ "data": {
+ "code": "KeyError",
+ "message": "unknown workflow 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 workflow capability" in str(raised.value)
+ assert raised.value.code == 5000
+ assert raised.value.data == {
+ "code": "KeyError",
+ "message": "unknown workflow capability 'app.default.search'",
+ }
+
+
+@pytest.mark.asyncio
+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(
+ _client: object,
+ _url: object,
+ *,
+ json: dict[str, object],
+ **_kwargs: object,
+ ) -> httpx.Response:
+ return httpx.Response(
+ 200,
+ request=httpx.Request("POST", "http://test/rpc"),
+ json={
+ "jsonrpc": "2.0",
+ "id": json["id"],
+ "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
+async def test_capability_discovery_returns_rich_page() -> None:
+ page = await _app().capabilities(query="search", limit=10)
+
+ assert isinstance(page, Page)
+ assert page.total == 1
+ assert page.next_cursor is None
+ assert isinstance(page.items[0], CapabilitySummary)
+ assert page.items[0].qualified_name == "app.default.search"
+ assert page.items[0].outcomes == ("ok",)
+
+
+@pytest.mark.asyncio
+async def test_capability_reconstructs_structural_reference() -> None:
+ capability = await _app().capability("app.default.search")
+
+ assert capability.ref == CapabilityRef.parse("app.default.search")
+ assert capability.ref.source.parts == ("app", "default")
+
+
+@pytest.mark.asyncio
+async def test_capability_reference_keeps_dotted_local_key() -> None:
+ capability = await _app(capability_name="app.default.search.v2").capability(
+ "app.default.search.v2"
+ )
+
+ assert capability.ref.source.parts == ("app", "default")
+ assert capability.ref.name == "search.v2"
+
+
+@pytest.mark.asyncio
+async def test_capability_rejects_mismatched_inspection_name() -> None:
+ app = _app(capability_name="app.default.other")
+
+ with pytest.raises(InvalidResponse, match="workflow.capabilities.inspect"):
+ await app.capability("app.default.search")
diff --git a/tests/wf_client/test_authoring.py b/tests/wf_client/test_authoring.py
new file mode 100644
index 00000000..19980231
--- /dev/null
+++ b/tests/wf_client/test_authoring.py
@@ -0,0 +1,317 @@
+from __future__ import annotations
+
+from typing import Any, cast
+
+import pytest
+
+from wf_authoring import WorkflowBuilder
+from wf_client import App, ArtifactRef, EditableWorkflow, RemoteCapability
+from wf_client.errors import InvalidResponse
+from wf_client.protocols import WorkflowClientPort
+from wf_platform import CapabilityRef
+
+
+class FakePort:
+ def __init__(self) -> None:
+ self.calls: list[tuple[str, dict[str, Any]]] = []
+ self.validate_artifact_plan_result: dict[str, Any] = {
+ "status": "valid",
+ "diagnostics": [],
+ "required_capabilities": [],
+ "workflow_dependencies": {},
+ }
+ 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:
+ self.calls.append(("validate_artifact_plan", params))
+ return self.validate_artifact_plan_result
+
+ async def create_artifact_from_plan(self, **params: Any) -> object:
+ self.calls.append(("create_artifact_from_plan", params))
+ return self.create_artifact_result or {
+ "artifact_id": params["artifact_id"],
+ "version": params["version"],
+ "saved": True,
+ }
+
+ async def inspect_artifact(self, **params: Any) -> object:
+ self.calls.append(("inspect_artifact", params))
+ assert self.inspect_artifact_result is not None
+ return self.inspect_artifact_result
+
+
+def valid_plan(version: int = 1) -> dict[str, Any]:
+ return {
+ "id": "report",
+ "version": version,
+ "title": "Report",
+ "kind": "workflow",
+ "description": None,
+ "input_schema": {"type": "object", "properties": {}},
+ "output_schema": {
+ "type": "object",
+ "properties": {"value": {"type": "string"}},
+ },
+ "outcomes": ["ok"],
+ "plan": {
+ "name": "report",
+ "input_schema": {"type": "object", "properties": {}},
+ "state_schema": {
+ "type": "object",
+ "properties": {"value": {"type": "string"}},
+ },
+ "output_schema": {
+ "type": "object",
+ "properties": {"value": {"type": "string"}},
+ },
+ "outcomes": ["ok"],
+ "output": [{"path": "state.value", "target": "value"}],
+ "start": "done",
+ "nodes": [{"id": "done", "type": "end", "outcome": "ok"}],
+ "edges": [],
+ },
+ "required_capabilities": [],
+ "workflow_dependencies": {},
+ "created_from_catalog_version": None,
+ }
+
+
+def remote_plan_without_schema_snapshots(version: int = 1) -> dict[str, Any]:
+ payload = valid_plan(version)
+ payload["plan"]["nodes"] = [
+ {
+ "id": "remote",
+ "type": "node",
+ "node": "app.default.remote",
+ "input": [],
+ "output": [],
+ },
+ {"id": "done", "type": "end", "outcome": "ok"},
+ ]
+ payload["plan"]["start"] = "remote"
+ payload["plan"]["edges"] = [{"from": "remote", "outcome": "ok", "to": "done"}]
+ payload["required_capabilities"] = [
+ {
+ "ref": {"source": "app.default", "capability_key": "remote"},
+ "kind": "node_spec",
+ "input_schema_hash": None,
+ "input_schema_snapshot": None,
+ "output_schema_hash": None,
+ "output_schema_snapshot": None,
+ "observed_concrete_source": None,
+ "observed_at_epoch_ms": None,
+ }
+ ]
+ return payload
+
+
+def remote_plan_with_real_node_def(version: int = 1) -> dict[str, Any]:
+ payload = remote_plan_without_schema_snapshots(version)
+ payload["plan"]["node_defs"] = [
+ {
+ "name": "app.default.remote",
+ "input_schema": {
+ "type": "object",
+ "properties": {"real": {"type": "string"}},
+ },
+ "output_schema": {"type": "object", "properties": {}},
+ "outcomes": ["ok"],
+ }
+ ]
+ return payload
+
+
+@pytest.mark.asyncio
+async def test_validate_stops_before_remote_call_when_local_graph_is_invalid() -> None:
+ port = FakePort()
+ graph = App._from_port(cast(WorkflowClientPort, port)).new_workflow(
+ "invalid",
+ input_schema={"type": "object", "properties": {}},
+ state_schema={"type": "object", "properties": {}},
+ output_schema={"type": "object", "properties": {}},
+ )
+
+ result = await graph.validate()
+
+ assert result.local.ok is False
+ assert result.remote_status == "not_run"
+ assert port.calls == []
+
+
+@pytest.mark.asyncio
+async def test_validate_runs_local_and_server_validation() -> None:
+ port = FakePort()
+ graph = App._from_port(cast(WorkflowClientPort, port)).new_workflow(
+ "report",
+ input_schema={"type": "object", "properties": {}},
+ state_schema={"type": "object", "properties": {"value": {"type": "string"}}},
+ output_schema={"type": "object", "properties": {"value": {"type": "string"}}},
+ )
+ done = graph.end("ok", id="done")
+ graph.set_entry_point(done)
+
+ result = await graph.validate()
+
+ assert result.ok is True
+ assert result.local.ok is True
+ assert result.remote_status == "valid"
+ assert port.calls[-1][0] == "validate_artifact_plan"
+
+
+@pytest.mark.asyncio
+async def test_edit_and_save_inspects_exact_saved_version() -> None:
+ port = FakePort()
+ port.inspect_artifact_result = valid_plan(version=1)
+ app = App._from_port(cast(WorkflowClientPort, port))
+
+ graph = await app.edit_workflow("report", version=1)
+ assert isinstance(graph, WorkflowBuilder)
+ assert isinstance(graph, EditableWorkflow)
+ 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)
+ saved = await graph.save(version=2)
+
+ create = next(
+ params
+ for operation, params in port.calls
+ if operation == "create_artifact_from_plan"
+ )
+ assert create["plan"] == valid_plan(version=1)["plan"]
+ inspect = [
+ params for operation, params in port.calls if operation == "inspect_artifact"
+ ][-1]
+ assert inspect == {"artifact_id": "report", "version": 2}
+ assert saved.ref == ArtifactRef("report", 2)
+ 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
+async def test_editable_artifact_without_schema_snapshots_remains_saveable() -> None:
+ port = FakePort()
+ port.inspect_artifact_result = remote_plan_without_schema_snapshots(version=1)
+ graph = await App._from_port(cast(WorkflowClientPort, port)).edit_workflow(
+ "report", version=1
+ )
+
+ result = graph.validate_local()
+
+ assert result.ok is True
+ port.inspect_artifact_result = remote_plan_without_schema_snapshots(version=2)
+ saved = await graph.save(version=2)
+ assert saved.ref == ArtifactRef("report", 2)
+
+
+@pytest.mark.asyncio
+async def test_snapshotless_remote_node_upgrades_to_real_capability_contract() -> None:
+ port = FakePort()
+ port.inspect_artifact_result = remote_plan_without_schema_snapshots(version=1)
+ graph = await App._from_port(cast(WorkflowClientPort, port)).edit_workflow(
+ "report", version=1
+ )
+ capability = RemoteCapability(
+ _port=cast(WorkflowClientPort, port),
+ ref=CapabilityRef.parse("app.default.remote"),
+ qualified_name="app.default.remote",
+ description=None,
+ input_schema={"type": "object", "properties": {"query": {"type": "string"}}},
+ output_schema={"type": "object", "properties": {}},
+ outcomes=("ok",),
+ is_async=False,
+ )
+
+ replacement = graph.use(capability, id="replacement", input=[], output=[])
+ graph.connect(replacement, "ok", "done")
+
+ assert graph.validate_local().ok is True
+ assert graph.seeded_node_defs["app.default.remote"].input_schema.properties == {
+ "query": {"type": "string"}
+ }
+
+ incompatible = RemoteCapability(
+ _port=cast(WorkflowClientPort, port),
+ ref=CapabilityRef.parse("app.default.remote"),
+ qualified_name="app.default.remote",
+ description=None,
+ input_schema={"type": "object", "properties": {"other": {"type": "string"}}},
+ output_schema={"type": "object", "properties": {}},
+ outcomes=("ok",),
+ is_async=False,
+ )
+ with pytest.raises(ValueError, match="incompatible duplicate"):
+ graph.use(incompatible, id="incompatible", input=[], output=[])
+
+
+@pytest.mark.asyncio
+async def test_real_plan_node_def_remains_authoritative_without_snapshots() -> None:
+ port = FakePort()
+ port.inspect_artifact_result = remote_plan_with_real_node_def(version=1)
+ graph = await App._from_port(cast(WorkflowClientPort, port)).edit_workflow(
+ "report", version=1
+ )
+ incompatible = RemoteCapability(
+ _port=cast(WorkflowClientPort, port),
+ ref=CapabilityRef.parse("app.default.remote"),
+ qualified_name="app.default.remote",
+ description=None,
+ input_schema={"type": "object", "properties": {"other": {"type": "string"}}},
+ output_schema={"type": "object", "properties": {}},
+ outcomes=("ok",),
+ is_async=False,
+ )
+
+ with pytest.raises(ValueError, match="incompatible duplicate"):
+ graph.use(incompatible, id="incompatible", input=[], output=[])
+
+ assert graph.seeded_node_defs["app.default.remote"].input_schema.properties == {
+ "real": {"type": "string"}
+ }
diff --git a/tests/wf_client/test_capabilities.py b/tests/wf_client/test_capabilities.py
new file mode 100644
index 00000000..4a178e90
--- /dev/null
+++ b/tests/wf_client/test_capabilities.py
@@ -0,0 +1,197 @@
+from __future__ import annotations
+
+from typing import Any, cast
+
+import pytest
+
+from wf_client import CapabilityResult, RemoteCapability
+from wf_client.errors import InvalidResponse
+from wf_client.protocols import WorkflowClientPort
+from wf_platform import CapabilityRef, SourceRef
+
+
+def _inspect_payload() -> dict[str, Any]:
+ return {
+ "name": "app.default.search",
+ "source_id": "app.default",
+ "kind": "node_spec",
+ "description": "Search things",
+ "outcomes": ["ok", "error"],
+ "is_async": True,
+ "input_schema": {
+ "type": "object",
+ "properties": {"query": {"type": "string"}},
+ "required": ["query"],
+ },
+ "output_schema": {
+ "type": "object",
+ "properties": {"results": {"type": "array"}},
+ "required": ["results"],
+ },
+ "wrapper_hints": {},
+ "accepts_context": False,
+ }
+
+
+class _Port:
+ def __init__(self) -> None:
+ 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:
+ self.calls.append(params)
+ return {
+ "qualified_name": self.result_qualified_name,
+ "source_id": self.result_source_id,
+ "kind": self.result_kind,
+ "deployment_id": self.result_deployment_id,
+ "outcome": "ok",
+ "output": {"results": ["one"]},
+ "diagnostics": [],
+ }
+
+
+def _port() -> WorkflowClientPort:
+ return cast(WorkflowClientPort, _Port())
+
+
+def test_remote_capability_preserves_dotted_local_key() -> None:
+ capability = RemoteCapability(
+ _port=_port(),
+ ref=CapabilityRef(source=SourceRef.parse("app.default"), name="search.v2"),
+ qualified_name="app.default.search.v2",
+ description=None,
+ input_schema={"type": "object"},
+ output_schema={"type": "object"},
+ outcomes=("ok",),
+ is_async=False,
+ )
+
+ assert capability.ref.name == "search.v2"
+ assert capability.node_def().name == "app.default.search.v2"
+
+
+@pytest.mark.asyncio
+async def test_remote_capability_is_callable_and_validates_result() -> None:
+ port = _Port()
+ capability = RemoteCapability(
+ _port=cast(WorkflowClientPort, port),
+ ref=CapabilityRef.parse("app.default.search"),
+ qualified_name="app.default.search",
+ description=None,
+ input_schema={
+ "type": "object",
+ "properties": {"query": {"type": "string"}},
+ "required": ["query"],
+ },
+ output_schema={"type": "object", "required": ["results"]},
+ outcomes=("ok",),
+ is_async=False,
+ )
+
+ result = await capability(query="workflow")
+
+ assert isinstance(result, CapabilityResult)
+ assert result.output == {"results": ["one"]}
+ assert port.calls == [
+ {
+ "qualified_name": "app.default.search",
+ "payload": {"query": "workflow"},
+ "deployment_id": None,
+ }
+ ]
+
+
+@pytest.mark.asyncio
+async def test_remote_capability_rejects_mixed_payload_forms() -> None:
+ capability = RemoteCapability(
+ _port=_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(TypeError, match="not both"):
+ 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:
+ with pytest.raises(InvalidResponse, match="invalid JSON Schema"):
+ RemoteCapability(
+ _port=_port(),
+ ref=CapabilityRef.parse("app.default.search"),
+ qualified_name="app.default.search",
+ description=None,
+ input_schema={"type": "not-a-json-schema-type"},
+ output_schema={"type": "object"},
+ outcomes=("ok",),
+ is_async=False,
+ )
diff --git a/tests/wf_client/test_codec.py b/tests/wf_client/test_codec.py
new file mode 100644
index 00000000..3c4e8c05
--- /dev/null
+++ b/tests/wf_client/test_codec.py
@@ -0,0 +1,175 @@
+from __future__ import annotations
+
+from typing import Any
+
+import pytest
+
+from wf_client.codec import (
+ decode_dependency_diagnostics,
+ decode_deployment,
+ decode_run_result,
+ decode_trace_result,
+ decode_workflow_artifact,
+)
+from wf_client.errors import InvalidResponse
+
+
+def _constant_plan_payload() -> dict[str, Any]:
+ return {
+ "name": "report",
+ "input_schema": {"type": "object", "properties": {}},
+ "state_schema": {
+ "type": "object",
+ "properties": {"result": {"type": "string", "reducer": "wf.std.replace"}},
+ },
+ "output_schema": {
+ "type": "object",
+ "properties": {"result": {"type": "string"}},
+ "required": ["result"],
+ },
+ "outcomes": ["ok"],
+ "start": "constant",
+ "nodes": [
+ {
+ "id": "constant",
+ "type": "node",
+ "node": "wf.std.constant",
+ "input": [{"value": "hello", "target": "local.value"}],
+ "output": [{"source": "local.value", "target": "state.result"}],
+ }
+ ],
+ "edges": [{"from": "constant", "outcome": "ok", "to": "__end__"}],
+ "output": [{"path": "state.result", "target": "result"}],
+ }
+
+
+def _workflow_artifact_payload(*, plan: dict[str, Any]) -> dict[str, Any]:
+ return {
+ "id": "report",
+ "version": 1,
+ "title": "Report",
+ "kind": "workflow",
+ "description": None,
+ "input_schema": {"type": "object", "properties": {}},
+ "output_schema": {
+ "type": "object",
+ "properties": {"result": {"type": "string"}},
+ },
+ "outcomes": ["ok"],
+ "plan": plan,
+ "required_capabilities": [],
+ "workflow_dependencies": {},
+ "created_from_catalog_version": None,
+ }
+
+
+def test_decode_workflow_artifact_validates_plan() -> None:
+ artifact, workflow = decode_workflow_artifact(
+ _workflow_artifact_payload(plan=_constant_plan_payload())
+ )
+
+ assert artifact.id == "report"
+ assert workflow.name == "report"
+ assert workflow.start == "constant"
+
+
+def test_decode_workflow_artifact_rejects_invalid_nested_plan() -> None:
+ with pytest.raises(InvalidResponse, match="workflow.artifacts.inspect"):
+ decode_workflow_artifact(_workflow_artifact_payload(plan={"name": "broken"}))
+
+
+def test_decode_deployment_returns_domain_model() -> None:
+ deployment = decode_deployment(
+ {
+ "id": "production",
+ "artifact_id": "report",
+ "artifact_version": 1,
+ "bindings": [],
+ "drift_policy": "block",
+ }
+ )
+
+ assert deployment.id == "production"
+ assert deployment.artifact_id == "report"
+
+
+def test_decode_dependency_diagnostics_returns_domain_models() -> None:
+ diagnostics = decode_dependency_diagnostics(
+ [
+ {
+ "severity": "error",
+ "code": "missing_source",
+ "logical_ref": "demo",
+ "bound_source": None,
+ "message": "missing",
+ "repair_hint": "bind it",
+ }
+ ]
+ )
+
+ assert diagnostics[0].code == "missing_source"
+ assert diagnostics[0].severity.value == "error"
+
+
+def _run_payload(*, trace: list[dict[str, Any]] | None = None) -> dict[str, Any]:
+ payload: dict[str, Any] = {
+ "artifact_id": "report",
+ "artifact_version": 1,
+ "deployment_id": "production",
+ "status": "completed",
+ "run_id": "run-1",
+ "resume_readiness": None,
+ "interrupt": None,
+ "outcome": "ok",
+ "error": None,
+ "output": {"result": "hello"},
+ "trace_count": 0 if trace is None else len(trace),
+ "diagnostics": [],
+ "next_actions": {
+ "can_continue": False,
+ "can_save_now": None,
+ "recommended_next_tool": None,
+ "reason": "done",
+ "patch_examples": [],
+ "warnings": [],
+ },
+ }
+ if trace is not None:
+ payload.update(
+ trace=trace,
+ trace_start=0,
+ trace_limit=25,
+ trace_truncated=False,
+ )
+ return payload
+
+
+def test_decode_run_result_returns_typed_domain_boundary() -> None:
+ result = decode_run_result(_run_payload())
+
+ assert result.run_id == "run-1"
+ assert result.output == {"result": "hello"}
+ assert result.diagnostics == ()
+
+
+def test_decode_trace_result_decodes_bounded_trace() -> None:
+ result = decode_trace_result(
+ _run_payload(
+ trace=[
+ {
+ "frame_id": "root",
+ "node_id": "constant",
+ "step_type": "node",
+ "resolved_input": {},
+ "outcome": "ok",
+ "next_node_id": "__end__",
+ "output": {},
+ "state_changes": {},
+ }
+ ]
+ )
+ )
+
+ assert result.trace_start == 0
+ assert result.trace is not None
+ assert result.trace[0]["node_id"] == "constant"
diff --git a/tests/wf_client/test_deployments.py b/tests/wf_client/test_deployments.py
new file mode 100644
index 00000000..163fb268
--- /dev/null
+++ b/tests/wf_client/test_deployments.py
@@ -0,0 +1,367 @@
+from __future__ import annotations
+
+from typing import Any, cast
+
+import pytest
+
+from wf_artifacts import WorkflowArtifact as ArtifactModel
+from wf_client import DeploymentRequired
+from wf_client.errors import DeploymentNotRunnable, InvalidResponse
+from wf_client.protocols import WorkflowClientPort
+from wf_client.workflows import WorkflowArtifact
+from wf_core import Workflow
+
+
+def _artifact() -> WorkflowArtifact:
+ plan: dict[str, Any] = {
+ "name": "report",
+ "input_schema": {"type": "object", "properties": {}},
+ "state_schema": {"type": "object", "properties": {}},
+ "output_schema": {"type": "object", "properties": {}},
+ "outcomes": ["ok"],
+ "start": "end",
+ "nodes": [{"id": "end", "type": "end", "outcome": "ok"}],
+ "edges": [],
+ }
+ artifact = ArtifactModel(
+ id="report",
+ version=1,
+ title="Report",
+ input_schema=plan["input_schema"],
+ output_schema=plan["output_schema"],
+ outcomes=("ok",),
+ plan=plan,
+ )
+ return WorkflowArtifact(
+ cast(WorkflowClientPort, _FakePort()), artifact, Workflow.model_validate(plan)
+ )
+
+
+class _FakePort:
+ def __init__(self) -> None:
+ self.calls: list[tuple[str, dict[str, Any]]] = []
+ self.list_result: dict[str, Any] = {"deployments": []}
+ self.inspect_artifact_id = "report"
+ self.inspect_artifact_version = 1
+ self.inspect_deployment_id: str | None = None
+ self.save_result: dict[str, Any] = {
+ "deployment_id": "report.production",
+ "artifact_id": "report",
+ "artifact_version": 1,
+ "saved": True,
+ }
+ self.validation_result: dict[str, Any] = {
+ "deployment_id": "report.production",
+ "artifact_id": "report",
+ "artifact_version": 1,
+ "status": "runnable",
+ "diagnostics": [],
+ "next_actions": {
+ "can_continue": True,
+ "can_save_now": None,
+ "recommended_next_tool": None,
+ "reason": "ready",
+ "patch_examples": [],
+ "warnings": [],
+ },
+ }
+ self.run_result: dict[str, Any] = {
+ "artifact_id": "report",
+ "artifact_version": 1,
+ "deployment_id": "report.production",
+ "status": "completed",
+ "run_id": "run-1",
+ "resume_readiness": "not_applicable",
+ "interrupt": None,
+ "outcome": "ok",
+ "error": None,
+ "output": {"result": "done"},
+ "trace_count": 0,
+ "diagnostics": [],
+ "next_actions": {
+ "can_continue": False,
+ "can_save_now": None,
+ "recommended_next_tool": None,
+ "reason": "done",
+ "patch_examples": [],
+ "warnings": [],
+ },
+ }
+
+ async def save_deployment(self, deployment: dict[str, Any]) -> object:
+ self.calls.append(("save_deployment", {"deployment": deployment}))
+ return self.save_result
+
+ async def inspect_deployment(self, *, deployment_id: str) -> object:
+ self.calls.append(("inspect_deployment", {"deployment_id": deployment_id}))
+ return {
+ "id": self.inspect_deployment_id or deployment_id,
+ "artifact_id": self.inspect_artifact_id,
+ "artifact_version": self.inspect_artifact_version,
+ "bindings": [
+ {
+ "logical_source": "app.default",
+ "concrete_source": "company.production",
+ }
+ ],
+ "drift_policy": "block",
+ }
+
+ async def validate_deployment(self, **params: Any) -> object:
+ self.calls.append(("validate_deployment", params))
+ return self.validation_result
+
+ async def list_deployments(self) -> object:
+ self.calls.append(("list_deployments", {}))
+ return self.list_result
+
+ async def run_deployment(self, **params: Any) -> object:
+ self.calls.append(("run_deployment", params))
+ return self.run_result
+
+
+@pytest.mark.asyncio
+async def test_artifact_deploys_with_explicit_bindings() -> None:
+ artifact = _artifact()
+ port = cast(_FakePort, artifact._port)
+ deployment = await artifact.deploy(
+ "report.production", bindings={"app.default": "company.production"}
+ )
+ assert deployment.deployment_id == "report.production"
+ assert deployment.bindings == {"app.default": "company.production"}
+ assert deployment.runnable is True
+ assert [call[0] for call in port.calls[-3:]] == [
+ "save_deployment",
+ "inspect_deployment",
+ "validate_deployment",
+ ]
+
+
+@pytest.mark.asyncio
+async def test_artifact_run_rejects_ambiguous_deployments() -> None:
+ artifact = _artifact()
+ port = cast(_FakePort, artifact._port)
+ port.list_result = {
+ "deployments": [
+ {
+ "id": "report.prod",
+ "artifact_id": "report",
+ "artifact_version": 1,
+ "binding_count": 0,
+ "drift_policy": "block",
+ },
+ {
+ "id": "report.dev",
+ "artifact_id": "report",
+ "artifact_version": 1,
+ "binding_count": 0,
+ "drift_policy": "block",
+ },
+ ]
+ }
+ with pytest.raises(DeploymentRequired) as captured:
+ await artifact.run({"topic": "workflow"})
+ assert captured.value.candidate_deployment_ids == ("report.dev", "report.prod")
+ assert not any(call[0] == "run_deployment" for call in port.calls)
+
+
+@pytest.mark.asyncio
+async def test_deployment_run_rejects_missing_run_id() -> None:
+ artifact = _artifact()
+ deployment = await artifact.deploy("report.production")
+ cast(_FakePort, deployment._port).run_result["run_id"] = None
+ with pytest.raises(DeploymentNotRunnable) as captured:
+ await deployment.run({})
+ assert captured.value.error is None
+
+
+@pytest.mark.asyncio
+async def test_explicit_artifact_run_rejects_deployment_for_another_artifact() -> None:
+ artifact = _artifact()
+ port = cast(_FakePort, artifact._port)
+ port.inspect_artifact_id = "other"
+ with pytest.raises(InvalidResponse, match="does not target artifact"):
+ await artifact.run({}, deployment_id="report.production")
+ assert not any(call[0] == "run_deployment" for call in port.calls)
+
+
+@pytest.mark.asyncio
+async def test_artifact_deploy_rejects_wrong_created_deployment_id() -> None:
+ artifact = _artifact()
+ port = cast(_FakePort, artifact._port)
+ port.save_result["deployment_id"] = "other.deployment"
+ with pytest.raises(InvalidResponse, match="save"):
+ 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
+async def test_artifact_deploy_rejects_wrong_inspected_deployment_id() -> None:
+ artifact = _artifact()
+ port = cast(_FakePort, artifact._port)
+ port.inspect_deployment_id = "other.deployment"
+ with pytest.raises(InvalidResponse, match="inspect"):
+ await artifact.deploy("report.production")
+
+
+@pytest.mark.asyncio
+async def test_discovered_deployment_rechecks_inspected_artifact_identity() -> None:
+ artifact = _artifact()
+ port = cast(_FakePort, artifact._port)
+ port.list_result = {
+ "deployments": [
+ {
+ "id": "report.production",
+ "artifact_id": "report",
+ "artifact_version": 1,
+ "binding_count": 0,
+ "drift_policy": "block",
+ }
+ ]
+ }
+ port.inspect_artifact_id = "other"
+ with pytest.raises(InvalidResponse, match="does not target artifact"):
+ await artifact.run({})
+
+
+@pytest.mark.asyncio
+async def test_deployment_validation_rejects_identity_mismatch() -> None:
+ artifact = _artifact()
+ deployment = await artifact.deploy("report.production")
+ port = cast(_FakePort, deployment._port)
+ port.validation_result["artifact_version"] = 2
+ with pytest.raises(InvalidResponse, match="workflow.deployments.validate"):
+ await deployment.validate()
+
+
+@pytest.mark.asyncio
+async def test_deployment_run_rejects_mismatched_start_deployment() -> None:
+ artifact = _artifact()
+ deployment = await artifact.deploy("report.production")
+ port = cast(_FakePort, deployment._port)
+ port.run_result["deployment_id"] = "other.deployment"
+ with pytest.raises(InvalidResponse, match="workflow.runs.start"):
+ await deployment.run({})
+
+
+@pytest.mark.asyncio
+async def test_deployment_run_rejects_mismatched_start_artifact() -> None:
+ artifact = _artifact()
+ deployment = await artifact.deploy("report.production")
+ port = cast(_FakePort, deployment._port)
+ port.run_result["artifact_id"] = "other"
+ with pytest.raises(InvalidResponse, match="workflow.runs.start"):
+ await deployment.run({})
+
+
+@pytest.mark.asyncio
+async def test_start_malformed_interrupt_reports_start_operation() -> None:
+ artifact = _artifact()
+ deployment = await artifact.deploy("report.production")
+ port = cast(_FakePort, deployment._port)
+ port.run_result["interrupt"] = {
+ "id": "interrupt-1",
+ "frame_id": "root",
+ "node_id": "approve",
+ "kind": "approval",
+ "payload": {},
+ "resumable": True,
+ "route": {
+ "frame_id": "child",
+ "node_id": "approve",
+ "scope_id": "scope",
+ "lineage_id": "lineage",
+ "parent_frame_id": "root",
+ "workflow_ref": {
+ "name": "local",
+ "artifact_id": "invalid",
+ "version": 1,
+ },
+ },
+ "outcomes": ["submitted"],
+ "request_schema": {"type": "object"},
+ "resume_schema": {"type": "object"},
+ "typed": False,
+ }
+ with pytest.raises(InvalidResponse, match="workflow.runs.start"):
+ await deployment.run({})
+
+
+@pytest.mark.asyncio
+async def test_deployment_run_preserves_server_error_and_diagnostics() -> None:
+ artifact = _artifact()
+ deployment = await artifact.deploy("report.production")
+ port = cast(_FakePort, deployment._port)
+ port.run_result.update(
+ run_id=None,
+ outcome="rejected",
+ error="dependency check failed",
+ diagnostics=[
+ {
+ "severity": "error",
+ "code": "missing_source",
+ "logical_ref": "app.default",
+ "bound_source": None,
+ "message": "missing source",
+ "repair_hint": "bind a source",
+ }
+ ],
+ )
+ with pytest.raises(DeploymentNotRunnable) as captured:
+ await deployment.run({})
+ assert captured.value.error == "dependency check failed"
+ assert captured.value.outcome == "rejected"
+ 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"
diff --git a/tests/wf_client/test_http_integration.py b/tests/wf_client/test_http_integration.py
new file mode 100644
index 00000000..207f6626
--- /dev/null
+++ b/tests/wf_client/test_http_integration.py
@@ -0,0 +1,61 @@
+from __future__ import annotations
+
+import httpx
+import pytest
+
+from wf_authoring import input_from, input_value, output_to, state_path
+from wf_client import App, ArtifactRef
+from wf_server import build_local_static_workflow_server
+from wf_transport_rpc_http import RpcWorkflowApiClient, create_rpc_app
+
+
+@pytest.mark.asyncio
+async def test_http_app_calls_authors_saves_deploys_and_runs(tmp_path) -> None:
+ """Prove the public client lifecycle against the real JSON-RPC ASGI app."""
+ server = build_local_static_workflow_server(tmp_path / "store")
+ rpc_app = create_rpc_app(server)
+ transport = httpx.ASGITransport(app=rpc_app)
+
+ async with httpx.AsyncClient(
+ transport=transport,
+ base_url="http://test",
+ ) as http_client:
+ app = App._from_port(
+ RpcWorkflowApiClient(
+ url="http://test/rpc",
+ http_client=http_client,
+ )
+ )
+ constant = await app.capability("wf.std.constant")
+ graph = app.new_workflow(
+ "http_client_proof",
+ input_schema={"type": "object", "properties": {}},
+ state_schema={
+ "type": "object",
+ "properties": {"value": {"type": "string"}},
+ },
+ output_schema={
+ "type": "object",
+ "properties": {"value": {"type": "string"}},
+ "required": ["value"],
+ },
+ )
+ step = graph.use(
+ constant,
+ id="constant",
+ input=[input_value("value", "hello")],
+ output=[output_to("value", state_path("value"))],
+ )
+ end = graph.end("ok", id="end_ok")
+ graph.set_entry_point(step)
+ graph.connect(step, "ok", end)
+ graph.set_output([input_from(state_path("value"), "value")])
+
+ validation = await graph.validate()
+ artifact = await graph.save(version=1, title="HTTP client proof")
+ run = await artifact.run({})
+
+ assert validation.ok is True
+ assert artifact.ref == ArtifactRef("http_client_proof", 1)
+ assert run.status == "completed"
+ assert run.output == {"value": "hello"}
diff --git a/tests/wf_client/test_repr.py b/tests/wf_client/test_repr.py
new file mode 100644
index 00000000..cb7bbe24
--- /dev/null
+++ b/tests/wf_client/test_repr.py
@@ -0,0 +1,157 @@
+from __future__ import annotations
+
+from typing import Any, cast
+
+from wf_client.capabilities import CapabilityResult, RemoteCapability
+from wf_client.deployments import Deployment, DeploymentValidation
+from wf_client.protocols import WorkflowClientPort
+from wf_client.runs import Run, TracePage
+from wf_client.workflows import (
+ ArtifactRef,
+ WorkflowDiagnostic,
+ WorkflowValidation,
+)
+from wf_core import ValidationReport
+from wf_platform import CapabilityRef
+
+
+class _Port:
+ """A port that records accidental representation-time remote operations."""
+
+ def __init__(self) -> None:
+ self.calls: list[tuple[str, dict[str, Any]]] = []
+
+ def __getattr__(self, name: str) -> Any:
+ async def operation(**params: Any) -> object:
+ self.calls.append((name, params))
+ return {}
+
+ return operation
+
+
+def _port() -> _Port:
+ return _Port()
+
+
+def test_capability_html_repr_is_bounded_and_does_not_call_port() -> None:
+ port = _port()
+ remote = RemoteCapability(
+ _port=cast(WorkflowClientPort, port),
+ ref=CapabilityRef.parse("app.default.search"),
+ qualified_name="app.default.search",
+ description="Search things ",
+ input_schema={"type": "object", "properties": {"query": {"type": "string"}}},
+ output_schema={"type": "object", "properties": {"items": {"type": "array"}}},
+ outcomes=("ok",),
+ is_async=False,
+ )
+
+ rendered = remote._repr_html_()
+
+ assert "app.default.search" in rendered
+ assert "input schema" in rendered.lower()
+ assert "<carefully>" in rendered
+ assert port.calls == []
+
+
+def test_rich_representations_bound_large_values_and_redact_secret_like_fields() -> (
+ None
+):
+ port = _port()
+ result = CapabilityResult(
+ outcome="ok",
+ output={"token": "do-not-show", "items": ["x" * 400] * 20},
+ diagnostics=(),
+ )
+ run = Run(
+ _port=cast(WorkflowClientPort, port),
+ run_id="run-1",
+ deployment_id="deployment-1",
+ status="completed",
+ outcome="ok",
+ output=result.output,
+ interrupt=None,
+ diagnostics=(),
+ trace_count=1000,
+ )
+
+ rendered = repr(run)
+ html = run._repr_html_()
+
+ assert len(rendered) <= 1_201
+ assert len(html) <= 2_500
+ assert "do-not-show" not in rendered
+ assert "do-not-show" not in html
+ assert "1000 frames" in rendered
+ assert port.calls == []
+
+
+def test_repr_redacts_only_exact_sensitive_keys_in_snake_and_camel_case() -> None:
+ result = CapabilityResult(
+ outcome="ok",
+ output={
+ "apiKey": "hide-me",
+ "accessToken": "hide-me-too",
+ "setCookie": "hide-me-three",
+ "tokenCount": 3,
+ "authorizationStatus": "ok",
+ "secretary": "safe",
+ },
+ diagnostics=(),
+ )
+
+ rendered = repr(result)
+
+ assert "hide-me" not in rendered
+ assert "hide-me-too" not in rendered
+ assert "hide-me-three" not in rendered
+ assert '"tokenCount": 3' in rendered
+ assert '"authorizationStatus": "ok"' in rendered
+ assert '"secretary": "safe"' in rendered
+
+
+def test_repr_does_not_materialize_an_unbounded_iterable() -> None:
+ class ExplodingIterable:
+ def __iter__(self):
+ for index in range(10_000):
+ if index > 8:
+ raise AssertionError("repr consumed too many values")
+ yield index
+
+ result = CapabilityResult("ok", {"values": ExplodingIterable()}, ())
+
+ rendered = repr(result)
+
+ assert "more items" in rendered
+
+
+def test_all_rich_objects_render_without_port_access() -> None:
+ raw_port = _port()
+ port = cast(WorkflowClientPort, raw_port)
+ diagnostic = WorkflowDiagnostic("error", "bad", "state.x", "broken")
+ local = ValidationReport()
+ objects = [
+ ArtifactRef("artifact", 1),
+ diagnostic,
+ WorkflowValidation(local, "valid", (diagnostic,)),
+ CapabilityResult("ok", {"value": 1}, ()),
+ DeploymentValidation("deployment", "artifact", 1, "runnable", ()),
+ TracePage(0, 25, (), False, 0),
+ ]
+
+ for value in objects:
+ assert repr(value)
+ assert value._repr_html_()
+ assert repr(
+ Deployment.from_payload(
+ port,
+ {
+ "id": "deployment",
+ "artifact_id": "artifact",
+ "artifact_version": 1,
+ "bindings": [],
+ "drift_policy": "block",
+ },
+ )
+ )
+ assert raw_port.calls == []
diff --git a/tests/wf_client/test_runs.py b/tests/wf_client/test_runs.py
new file mode 100644
index 00000000..cbee10e5
--- /dev/null
+++ b/tests/wf_client/test_runs.py
@@ -0,0 +1,255 @@
+from __future__ import annotations
+
+from typing import Any, cast
+
+import pytest
+
+from wf_client import App, Run
+from wf_client.errors import DeploymentNotRunnable, InvalidResponse
+from wf_client.protocols import WorkflowClientPort
+
+
+def _payload(
+ *, run_id: str | None = "run-1", status: str = "interrupted"
+) -> dict[str, Any]:
+ return {
+ "artifact_id": "report",
+ "artifact_version": 1,
+ "deployment_id": "report.production",
+ "status": status,
+ "run_id": run_id,
+ "resume_readiness": "ready" if status == "interrupted" else "not_applicable",
+ "interrupt": {
+ "id": "interrupt-1",
+ "frame_id": "root",
+ "node_id": "approve",
+ "kind": "approval",
+ "payload": {"question": "approve?"},
+ "resumable": True,
+ "route": None,
+ "outcomes": ["submitted"],
+ "request_schema": {"type": "object"},
+ "resume_schema": {"type": "object"},
+ "typed": False,
+ }
+ if status == "interrupted"
+ else None,
+ "outcome": None if status == "interrupted" else "ok",
+ "error": None,
+ "output": None if status == "interrupted" else {"result": "done"},
+ "trace_count": 1,
+ "diagnostics": [],
+ "next_actions": {
+ "can_continue": status == "interrupted",
+ "can_save_now": None,
+ "recommended_next_tool": None,
+ "reason": "ready",
+ "patch_examples": [],
+ "warnings": [],
+ },
+ }
+
+
+class _Port:
+ def __init__(self) -> None:
+ self.calls: list[tuple[str, dict[str, Any]]] = []
+ self.resume_payload = _payload(status="completed")
+ self.trace_payload = {
+ **_payload(status="completed"),
+ "trace": [
+ {
+ "frame_id": "root",
+ "node_id": "approve",
+ "step_type": "node",
+ "resolved_input": {},
+ "outcome": "ok",
+ "next_node_id": "__end__",
+ "output": {},
+ "state_changes": {},
+ }
+ ],
+ "trace_start": 0,
+ "trace_limit": 25,
+ "trace_truncated": False,
+ }
+
+ async def resume_run(self, **params: Any) -> object:
+ self.calls.append(("resume_run", params))
+ return self.resume_payload
+
+ async def inspect_run(self, **params: Any) -> object:
+ self.calls.append(("inspect_run", params))
+ return self.resume_payload
+
+ async def read_run_trace(self, **params: Any) -> object:
+ self.calls.append(("read_run_trace", params))
+ return self.trace_payload
+
+
+@pytest.mark.asyncio
+async def test_interrupted_run_resumes_and_reads_bounded_trace() -> None:
+ port = _Port()
+ run = Run.from_payload(cast(WorkflowClientPort, port), _payload())
+ completed = await run.resume({"approved": True})
+ trace = await completed.trace(limit=25)
+ assert completed.status == "completed"
+ assert completed.output == {"result": "done"}
+ assert trace.start == 0
+ assert trace.limit == 25
+ assert len(trace.frames) == 1
+
+
+@pytest.mark.asyncio
+async def test_refresh_returns_a_new_snapshot() -> None:
+ port = _Port()
+ original = Run.from_payload(cast(WorkflowClientPort, port), _payload())
+ refreshed = await original.refresh()
+ assert refreshed is not original
+ assert refreshed.status == "completed"
+ assert original.status == "interrupted"
+
+
+@pytest.mark.asyncio
+async def test_app_run_rejects_mismatched_inspection_id() -> None:
+ port = _Port()
+ port.resume_payload["run_id"] = "different-run"
+ app = App._from_port(cast(WorkflowClientPort, port))
+ with pytest.raises(InvalidResponse, match="workflow.runs.inspect"):
+ await app.run("run-1")
+
+
+@pytest.mark.asyncio
+async def test_refresh_rejects_mismatched_inspection_id() -> None:
+ port = _Port()
+ port.resume_payload["run_id"] = "different-run"
+ run = Run.from_payload(cast(WorkflowClientPort, port), _payload())
+ with pytest.raises(InvalidResponse, match="workflow.runs.inspect"):
+ await run.refresh()
+
+
+@pytest.mark.asyncio
+async def test_resume_rejects_mismatched_result_id() -> None:
+ port = _Port()
+ port.resume_payload["run_id"] = "different-run"
+ run = Run.from_payload(cast(WorkflowClientPort, port), _payload())
+ with pytest.raises(InvalidResponse, match="workflow.runs.resume"):
+ 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
+async def test_malformed_interrupt_route_is_invalid_response() -> None:
+ payload = _payload()
+ payload["interrupt"]["route"] = {
+ "frame_id": "child",
+ "node_id": "approve",
+ "scope_id": "scope",
+ "lineage_id": "lineage",
+ "parent_frame_id": "root",
+ "workflow_ref": {"name": "local", "artifact_id": "also-invalid", "version": 1},
+ }
+ with pytest.raises(InvalidResponse, match="workflow.runs.inspect"):
+ Run.from_payload(cast(WorkflowClientPort, _Port()), payload)
+
+
+@pytest.mark.asyncio
+async def test_missing_run_id_preserves_server_error() -> None:
+ payload = _payload(run_id=None, status="failed")
+ payload["error"] = "server refused to start"
+ with pytest.raises(DeploymentNotRunnable) as captured:
+ Run.from_payload(cast(WorkflowClientPort, _Port()), payload)
+ assert captured.value.error == "server refused to start"
+
+
+@pytest.mark.asyncio
+async def test_non_resumable_run_is_rejected_before_io() -> None:
+ port = _Port()
+ payload = _payload()
+ assert payload["interrupt"] is not None
+ payload["interrupt"]["resumable"] = False
+ run = Run.from_payload(cast(WorkflowClientPort, port), payload)
+ with pytest.raises(ValueError, match="not resumable"):
+ await run.resume({"approved": True})
+ assert port.calls == []
+
+
+@pytest.mark.asyncio
+async def test_trace_rejects_invalid_bounds_before_io() -> None:
+ port = _Port()
+ run = Run.from_payload(cast(WorkflowClientPort, port), _payload())
+ with pytest.raises(ValueError):
+ await run.trace(start=-1)
+ with pytest.raises(ValueError):
+ await run.trace(limit=101)
+ 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"
diff --git a/tests/wf_contract_manifest/test_cli.py b/tests/wf_contract_manifest/test_cli.py
index 4f23cf72..1a2de778 100644
--- a/tests/wf_contract_manifest/test_cli.py
+++ b/tests/wf_contract_manifest/test_cli.py
@@ -18,7 +18,9 @@ def _manifest() -> ContractManifest:
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()
calls: list[tuple[object, Path]] = []
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",
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 calls == [(manifest, tmp_path / "manifest.json")]
diff --git a/tests/wf_contract_manifest/test_generate.py b/tests/wf_contract_manifest/test_generate.py
index 5e0378f3..d8177cfd 100644
--- a/tests/wf_contract_manifest/test_generate.py
+++ b/tests/wf_contract_manifest/test_generate.py
@@ -90,9 +90,9 @@ def test_generates_the_complete_real_workflow_contract() -> None:
manifest = generate_manifest()
schemas = manifest["components"]["schemas"]
- assert len(manifest["operations"]) == 71
- assert len({operation["method"] for operation in manifest["operations"]}) == 71
- assert len(schemas) == 140
+ assert len(manifest["operations"]) == 72
+ assert len({operation["method"] for operation in manifest["operations"]}) == 72
+ assert len(schemas) == 142
assert len(manifest["components"]["errors"]) == 1
assert all(
set(operation["result"]["schema"]) == {"$ref"}
@@ -151,9 +151,7 @@ def test_manifest_separates_recursive_step_inputs_from_workflow_outputs() -> Non
input_binding_schema = schemas["InputExpressionBinding"]
properties = input_binding_schema.get("properties")
assert isinstance(properties, dict)
- assert properties["expression"] == {
- "$ref": "#/components/schemas/InputExpression"
- }
+ assert properties["expression"] == {"$ref": "#/components/schemas/InputExpression"}
expression_schema = schemas["InputExpression"]
assert expression_schema["discriminator"] == {
"mapping": {
diff --git a/tests/wf_mcp/server/test_tools.py b/tests/wf_mcp/server/test_tools.py
index d81e700b..0096c418 100644
--- a/tests/wf_mcp/server/test_tools.py
+++ b/tests/wf_mcp/server/test_tools.py
@@ -101,7 +101,7 @@ async def test_registered_output_bindings_tool_delegates_typed_bindings_once(
recorder = RecordingWorkflowHandler()
monkeypatch.setattr(
"wf_mcp.workflow_surface.tools.WorkflowApi",
- lambda _context: recorder,
+ lambda _context, **_kwargs: recorder,
)
service = WfMcpService(
store=FileStore(tmp_path / "tool_invocation_store"),
@@ -167,7 +167,7 @@ async def test_registered_workflow_output_bindings_tool_preserves_union_order(
recorder = RecordingWorkflowHandler()
monkeypatch.setattr(
"wf_mcp.workflow_surface.tools.WorkflowApi",
- lambda _context: recorder,
+ lambda _context, **_kwargs: recorder,
)
service = WfMcpService(
store=FileStore(tmp_path / "workflow_output_tool_store"),
@@ -242,7 +242,7 @@ async def test_registered_capability_tools_delegate_presence_aware_requests(
recorder = RecordingWorkflowHandler()
monkeypatch.setattr(
"wf_mcp.workflow_surface.tools.WorkflowApi",
- lambda _context: recorder,
+ lambda _context, **_kwargs: recorder,
)
service = WfMcpService(
store=FileStore(tmp_path / "capability_tool_store"),
diff --git a/tests/wf_mcp/test_mcp_workflow_server.py b/tests/wf_mcp/test_mcp_workflow_server.py
index 2a72d61a..7aa89387 100644
--- a/tests/wf_mcp/test_mcp_workflow_server.py
+++ b/tests/wf_mcp/test_mcp_workflow_server.py
@@ -69,6 +69,8 @@ def test_workflow_server_from_service_wires_neutral_surfaces(tmp_path) -> None:
assert isinstance(server, WorkflowServer)
assert server.config.store_root == config.store_root
assert server.api.context is server.context
+ assert server.api.drafts_enabled is True
+ assert server.api.drafts is not None
assert server.source_registry_admin is not None
assert server.admin.connections is service.connection_service
assert server.admin.events is service.events
diff --git a/tests/wf_mcp/test_saved_subgraphs.py b/tests/wf_mcp/test_saved_subgraphs.py
index 72312971..f204a97a 100644
--- a/tests/wf_mcp/test_saved_subgraphs.py
+++ b/tests/wf_mcp/test_saved_subgraphs.py
@@ -177,9 +177,7 @@ def test_interrupted_saved_child_blocks_resume_until_pinned_source_returns(
assert blocked["status"] == "interrupted"
assert blocked["resume_readiness"] == "blocked"
assert blocked["diagnostics"][0]["code"] == "source_disabled"
- assert (
- run_store.get_run(paused_run_id).resume_readiness is ResumeReadiness.BLOCKED
- )
+ assert run_store.get_run(paused_run_id).resume_readiness is ResumeReadiness.BLOCKED
assert run_store.get_latest_checkpoint(paused_run_id).sequence == 1
handlers.service.capability_sources["demo.personal"].enabled = True
diff --git a/tests/wf_server/test_cli.py b/tests/wf_server/test_cli.py
index 527a8bbd..93a35869 100644
--- a/tests/wf_server/test_cli.py
+++ b/tests/wf_server/test_cli.py
@@ -83,13 +83,15 @@ def test_rpc_server_cli_uses_configured_store_and_transport(
)
captured: dict[str, object] = {}
- def fake_build_server(config):
+ def fake_build_server(config, *, drafts=False):
captured["store_root"] = config.server.store.root
+ captured["drafts"] = drafts
return object()
- def fake_create_rpc_app(server, *, rpc_path="/rpc"):
+ def fake_create_rpc_app(server, *, rpc_path="/rpc", drafts=False):
captured["server"] = server
captured["rpc_path"] = rpc_path
+ captured["drafts"] = drafts
return object()
def fake_uvicorn_run(app_obj, *, host, port, access_log):
@@ -110,6 +112,7 @@ def test_rpc_server_cli_uses_configured_store_and_transport(
assert result.exit_code == 0, result.output
assert captured["store_root"] == (tmp_path / ".wf_store").resolve()
assert captured["rpc_path"] == "/workflow-rpc"
+ assert captured["drafts"] is True
assert captured["host"] == "127.0.0.2"
assert captured["port"] == 9999
assert captured["access_log"] is False
@@ -132,9 +135,10 @@ def test_rpc_server_cli_uses_mcp_config_server(monkeypatch, tmp_path) -> None:
captured["mcp_config_path"] = path
return object()
- def fake_create_rpc_app(server, *, rpc_path="/rpc"):
+ def fake_create_rpc_app(server, *, rpc_path="/rpc", drafts=False):
captured["server"] = server
captured["rpc_path"] = rpc_path
+ captured["drafts"] = drafts
return object()
def fake_uvicorn_run(app_obj, *, host, port, access_log):
@@ -207,9 +211,10 @@ def test_rpc_server_cli_mcp_config_builds_registry_capable_server(
)
captured: dict[str, object] = {}
- def fake_create_rpc_app(server, *, rpc_path="/rpc"):
+ def fake_create_rpc_app(server, *, rpc_path="/rpc", drafts=False):
captured["source_registry_admin"] = server.source_registry_admin
captured["rpc_path"] = rpc_path
+ captured["drafts"] = drafts
return object()
def fake_uvicorn_run(app_obj, *, host, port, access_log):
@@ -263,9 +268,10 @@ def test_rpc_server_cli_mcp_config_with_config_uses_transport_settings(
)
captured: dict[str, object] = {}
- def fake_create_rpc_app(server, *, rpc_path="/rpc"):
+ def fake_create_rpc_app(server, *, rpc_path="/rpc", drafts=False):
captured["server"] = server
captured["rpc_path"] = rpc_path
+ captured["drafts"] = drafts
return object()
def fake_uvicorn_run(app_obj, *, host, port, access_log):
@@ -299,13 +305,15 @@ def test_rpc_server_cli_config_with_mcp_source_uses_mcp_builder(
) -> None:
captured = {}
- def fake_build_from_workflow_config(config):
+ def fake_build_from_workflow_config(config, *, drafts=False):
captured["source_kinds"] = [source.kind for source in config.server.sources]
+ captured["build_drafts"] = drafts
return object()
- def fake_create_rpc_app(server, *, rpc_path="/rpc"):
+ def fake_create_rpc_app(server, *, rpc_path="/rpc", drafts=False):
captured["server"] = server
captured["rpc_path"] = rpc_path
+ captured["drafts"] = drafts
return "app"
def fake_run(app, *, host, port, access_log):
@@ -409,13 +417,15 @@ def test_rpc_server_cli_config_uses_workflow_store_override(
)
captured: dict[str, object] = {}
- def fake_build_server(config):
+ def fake_build_server(config, *, drafts=False):
captured["workflow_store_root"] = config.server.workflow_store.root
+ captured["build_drafts"] = drafts
return object()
- def fake_create_rpc_app(server, *, rpc_path="/rpc"):
+ def fake_create_rpc_app(server, *, rpc_path="/rpc", drafts=False):
captured["server"] = server
captured["rpc_path"] = rpc_path
+ captured["drafts"] = drafts
return object()
def fake_uvicorn_run(app_obj, *, host, port, access_log):
diff --git a/tests/wf_server/test_local_static_server.py b/tests/wf_server/test_local_static_server.py
index b3235ea6..559521ac 100644
--- a/tests/wf_server/test_local_static_server.py
+++ b/tests/wf_server/test_local_static_server.py
@@ -107,9 +107,7 @@ async def test_local_static_server_runs_deployment_and_persists_run(tmp_path) ->
assert output["result"] == "hello from server"
run_id = run_result["run_id"]
assert isinstance(run_id, str)
- assert (
- server.stores.run_store.get_run(run_id).id == run_id
- )
+ assert server.stores.run_store.get_run(run_id).id == run_id
async def test_local_static_server_inspects_and_reads_bounded_trace(tmp_path) -> None:
@@ -193,6 +191,28 @@ def test_local_static_server_has_no_source_registry_admin(tmp_path) -> None:
assert server.source_registry_admin is None
+def test_local_static_server_default_composition_has_no_draft_store(tmp_path) -> None:
+ root = tmp_path / "store"
+ server = build_local_static_workflow_server(root)
+
+ assert server.stores.draft_workspace_store is None
+ assert server.context.draft_workspace_store is None
+ assert server.api.drafts_enabled is False
+ assert not (root / "draft_workspaces").exists()
+
+
+def test_local_static_server_explicit_draft_composition_has_draft_store(
+ tmp_path,
+) -> None:
+ root = tmp_path / "store"
+ server = build_local_static_workflow_server(root, drafts=True)
+
+ assert server.stores.draft_workspace_store is not None
+ assert server.context.draft_workspace_store is server.stores.draft_workspace_store
+ assert server.api.drafts_enabled is True
+ assert (root / "draft_workspaces").is_dir()
+
+
def test_local_static_builtins_are_platform_sources(tmp_path) -> None:
server = build_local_static_workflow_server(tmp_path)
diff --git a/tests/wf_transport_rpc_http/test_app.py b/tests/wf_transport_rpc_http/test_app.py
index 9bc9a15d..9b4c9901 100644
--- a/tests/wf_transport_rpc_http/test_app.py
+++ b/tests/wf_transport_rpc_http/test_app.py
@@ -1,5 +1,6 @@
from __future__ import annotations
+import sys
from typing import Any
import httpx
@@ -21,6 +22,33 @@ from wf_transport_rpc_http.models import (
)
+@pytest.fixture(autouse=True)
+def _draft_enabled_composition(monkeypatch: pytest.MonkeyPatch) -> None:
+ """Opt draft-focused RPC tests into the otherwise disabled composition."""
+ build_local = build_local_static_workflow_server
+ build_config = build_workflow_server_from_workflow_config
+ create_app = create_rpc_app
+
+ def draft_local(root, *args, **kwargs):
+ kwargs.setdefault("drafts", True)
+ return build_local(root, *args, **kwargs)
+
+ def draft_config(config, *args, **kwargs):
+ kwargs.setdefault("drafts", True)
+ return build_config(config, *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, "build_workflow_server_from_workflow_config", draft_config
+ )
+ monkeypatch.setattr(module, "create_rpc_app", draft_app)
+
+
async def _rpc(
client: httpx.AsyncClient, method: str, params: dict[str, Any]
) -> dict[str, Any]:
@@ -32,6 +60,24 @@ async def _rpc(
return response.json()
+def test_rpc_app_can_omit_draft_methods(tmp_path) -> None:
+ server = build_local_static_workflow_server(tmp_path / "store")
+
+ app = create_rpc_app(server, drafts=False)
+ methods = {method["name"] for method in app.get_openrpc()["methods"]}
+
+ assert "workflow.capabilities.list" in methods
+ assert "workflow.draft_workspaces.list" not in methods
+
+
+def test_rpc_app_draft_methods_require_explicit_server_opt_in(tmp_path) -> None:
+ server = build_local_static_workflow_server(tmp_path / "store", drafts=True)
+ app = create_rpc_app(server, drafts=True)
+ methods = {method["name"] for method in app.get_openrpc()["methods"]}
+
+ assert "workflow.draft_workspaces.list" in methods
+
+
def _rpc_constant_draft() -> dict[str, Any]:
"""Return the canonical keyed draft shared by stateless RPC tests."""
return {
@@ -1333,6 +1379,33 @@ async def test_rpc_create_artifact_from_plan(tmp_path) -> None:
assert inspected["result"]["plan"]["name"] == "rpc_constant"
+async def test_rpc_validate_artifact_plan_does_not_persist(tmp_path) -> None:
+ server = build_local_static_workflow_server(tmp_path / "store")
+ app = create_rpc_app(server)
+ transport = httpx.ASGITransport(app=app)
+ async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
+ validated = await _rpc(
+ client,
+ "workflow.artifacts.validate_plan",
+ {
+ "plan": _constant_plan().model_dump(mode="json", by_alias=True),
+ "outcomes": ["ok"],
+ "source_bindings": {},
+ },
+ )
+ listed = await _rpc(
+ client, "workflow.artifacts.list", {"query": "rpc_constant"}
+ )
+
+ assert validated["result"]["status"] == "valid"
+ assert validated["result"]["diagnostics"] == []
+ assert listed["result"] == {
+ "nodes": [],
+ "next_cursor": None,
+ "total": 0,
+ }
+
+
async def test_rpc_draft_workspace_focused_edit_methods(tmp_path) -> None:
server = build_local_static_workflow_server(tmp_path / "store")
app = create_rpc_app(server)
diff --git a/tests/wf_transport_rpc_http/test_client.py b/tests/wf_transport_rpc_http/test_client.py
index 65b8fb31..44d34f62 100644
--- a/tests/wf_transport_rpc_http/test_client.py
+++ b/tests/wf_transport_rpc_http/test_client.py
@@ -1,5 +1,6 @@
from __future__ import annotations
+import json
from typing import Any
import httpx
@@ -26,10 +27,67 @@ from wf_core.models.steps import (
from wf_core.paths import GraphSourcePath, LocalPath, StatePath
from wf_server import build_local_static_workflow_server
from wf_transport_rpc_http import RpcWorkflowApiClient, create_rpc_app
+from wf_transport_rpc_http.client.base import RpcProtocolError
from wf_transport_rpc_http.client.drafts import RpcDraftClientMixin
from wf_transport_rpc_http.client.sources import RpcSourceAdminClientMixin
+async def test_rpc_client_preserves_structured_jsonrpc_error() -> None:
+ def handler(request: httpx.Request) -> httpx.Response:
+ request_id = json.loads(request.content)["id"]
+ return httpx.Response(
+ 200,
+ json={
+ "jsonrpc": "2.0",
+ "id": request_id,
+ "error": {
+ "code": "missing_source",
+ "message": "workflow operation failed",
+ "data": {"message": "source is not configured", "hint": "bind it"},
+ },
+ },
+ )
+
+ http_client = httpx.AsyncClient(transport=httpx.MockTransport(handler))
+ async with http_client:
+ client = RpcWorkflowApiClient(url="http://test/rpc", http_client=http_client)
+ with pytest.raises(RpcProtocolError) as raised:
+ await client.list_capabilities()
+
+ assert raised.value.code == "missing_source"
+ assert raised.value.message == "workflow operation failed"
+ assert raised.value.data == {
+ "message": "source is not configured",
+ "hint": "bind it",
+ }
+ assert str(raised.value) == ("workflow operation failed: source is not configured")
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize(
+ ("jsonrpc", "response_id"),
+ [(None, "echo"), ("1.0", "echo"), ("2.0", "wrong")],
+)
+async def test_rpc_client_rejects_malformed_response_envelope(
+ jsonrpc: str | None,
+ response_id: str,
+) -> None:
+ def handler(request: httpx.Request) -> httpx.Response:
+ request_id = json.loads(request.content)["id"]
+ payload: dict[str, object] = {
+ "id": request_id if response_id == "echo" else response_id,
+ "result": {},
+ }
+ if jsonrpc is not None:
+ payload["jsonrpc"] = jsonrpc
+ return httpx.Response(200, json=payload)
+
+ async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as http_client:
+ client = RpcWorkflowApiClient(url="http://test/rpc", http_client=http_client)
+ with pytest.raises(RuntimeError, match="JSON-RPC response"):
+ await client.list_capabilities()
+
+
def _constant_plan() -> RawWorkflowPlan:
return RawWorkflowPlan.model_validate(
{
@@ -303,8 +361,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:
- server = build_local_static_workflow_server(tmp_path / "store")
- app = create_rpc_app(server)
+ server = build_local_static_workflow_server(tmp_path / "store", drafts=True)
+ app = create_rpc_app(server, drafts=True)
transport = httpx.ASGITransport(app=app)
async with httpx.AsyncClient(
transport=transport, base_url="http://test"
@@ -499,8 +557,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:
- server = build_local_static_workflow_server(tmp_path / "store")
- app = create_rpc_app(server)
+ server = build_local_static_workflow_server(tmp_path / "store", drafts=True)
+ app = create_rpc_app(server, drafts=True)
transport = httpx.ASGITransport(app=app)
async with httpx.AsyncClient(
transport=transport,
@@ -574,8 +632,8 @@ def test_rpc_client_satisfies_draft_surface_static_shape() -> None:
async def test_rpc_workflow_client_deletes_draft_workspace(tmp_path) -> None:
- server = build_local_static_workflow_server(tmp_path / "store")
- app = create_rpc_app(server)
+ server = build_local_static_workflow_server(tmp_path / "store", drafts=True)
+ app = create_rpc_app(server, drafts=True)
transport = httpx.ASGITransport(app=app)
async with httpx.AsyncClient(
transport=transport, base_url="http://test"
@@ -695,10 +753,35 @@ async def test_rpc_client_creates_artifact_from_plan(tmp_path) -> None:
assert inspected["id"] == "client_plan"
-async def test_rpc_client_set_workflow_output_map(tmp_path) -> None:
+async def test_rpc_client_validates_artifact_plan_without_persisting(tmp_path) -> None:
server = build_local_static_workflow_server(tmp_path / "store")
app = create_rpc_app(server)
transport = httpx.ASGITransport(app=app)
+ async with httpx.AsyncClient(
+ transport=transport, base_url="http://test"
+ ) as http_client:
+ client = RpcWorkflowApiClient(
+ url="http://test/rpc",
+ timeout_seconds=5,
+ http_client=http_client,
+ )
+ validated = await client.validate_artifact_plan(
+ plan=_constant_plan().model_dump(mode="json", by_alias=True),
+ outcomes=("ok",),
+ source_bindings={},
+ )
+ listed = await client.list_artifacts(query="client_constant")
+
+ assert validated["status"] == "valid"
+ assert validated["diagnostics"] == []
+ assert listed["nodes"] == []
+ assert listed["total"] == 0
+
+
+async def test_rpc_client_set_workflow_output_map(tmp_path) -> None:
+ server = build_local_static_workflow_server(tmp_path / "store", drafts=True)
+ app = create_rpc_app(server, drafts=True)
+ transport = httpx.ASGITransport(app=app)
async with httpx.AsyncClient(
transport=transport, base_url="http://test"
) as http_client:
@@ -731,8 +814,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:
- server = build_local_static_workflow_server(tmp_path / "store")
- app = create_rpc_app(server)
+ server = build_local_static_workflow_server(tmp_path / "store", drafts=True)
+ app = create_rpc_app(server, drafts=True)
transport = httpx.ASGITransport(app=app)
async with httpx.AsyncClient(
transport=transport, base_url="http://test"
@@ -993,8 +1076,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:
- server = build_local_static_workflow_server(tmp_path / "store")
- app = create_rpc_app(server)
+ server = build_local_static_workflow_server(tmp_path / "store", drafts=True)
+ app = create_rpc_app(server, drafts=True)
transport = httpx.ASGITransport(app=app)
async with httpx.AsyncClient(
transport=transport, base_url="http://test"
diff --git a/tests/wf_transport_rpc_http/test_mcp_backed_server_rpc.py b/tests/wf_transport_rpc_http/test_mcp_backed_server_rpc.py
index dab971fc..5248b6c7 100644
--- a/tests/wf_transport_rpc_http/test_mcp_backed_server_rpc.py
+++ b/tests/wf_transport_rpc_http/test_mcp_backed_server_rpc.py
@@ -200,7 +200,7 @@ def _runtime_reuse_server(
],
)
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)
catalog_store = FileCatalogStore(store_roots.catalog_cache_root)
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")])
)
server = build_workflow_server_from_config(config)
- app = create_rpc_app(server)
+ app = create_rpc_app(server, drafts=True)
transport = httpx.ASGITransport(app=app)
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:
config = BrokerConfig(store_root=tmp_path / "store", connections=[])
server = build_workflow_server_from_config(config)
- app = create_rpc_app(server)
+ app = create_rpc_app(server, drafts=True)
transport = httpx.ASGITransport(app=app)
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)
- app = create_rpc_app(server)
+ app = create_rpc_app(server, drafts=True)
transport = httpx.ASGITransport(app=app)
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:
config = BrokerConfig(store_root=tmp_path / "store", connections=[])
server = build_workflow_server_from_config(config)
- app = create_rpc_app(server)
+ app = create_rpc_app(server, drafts=True)
async with httpx.AsyncClient(
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)
- app = create_rpc_app(server)
+ app = create_rpc_app(server, drafts=True)
transport = httpx.ASGITransport(app=app)
async with httpx.AsyncClient(
@@ -408,7 +408,7 @@ async def test_mcp_backed_rpc_resumes_interrupted_run_after_server_rebuild(
}
)
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",
) as http_client:
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)
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",
) as http_client:
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"
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",
) as 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(
- transport=httpx.ASGITransport(app=create_rpc_app(server)),
+ transport=httpx.ASGITransport(app=create_rpc_app(server, drafts=True)),
base_url="http://test",
) as 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(
- transport=httpx.ASGITransport(app=create_rpc_app(server)),
+ transport=httpx.ASGITransport(app=create_rpc_app(server, drafts=True)),
base_url="http://test",
) as 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(
- transport=httpx.ASGITransport(app=create_rpc_app(server)),
+ transport=httpx.ASGITransport(app=create_rpc_app(server, drafts=True)),
base_url="http://test",
) as http_client:
client = RpcWorkflowApiClient(url="http://test/rpc", http_client=http_client)
diff --git a/tests/wf_transport_rpc_http/test_openrpc_contract.py b/tests/wf_transport_rpc_http/test_openrpc_contract.py
index 30680651..7124b910 100644
--- a/tests/wf_transport_rpc_http/test_openrpc_contract.py
+++ b/tests/wf_transport_rpc_http/test_openrpc_contract.py
@@ -31,7 +31,12 @@ def _assert_result_component(
@pytest.fixture
def openrpc_document(tmp_path: Path) -> dict[str, Any]:
- app = create_rpc_app(build_local_static_workflow_server(tmp_path / "store"))
+ # This fixture inventories the complete RPC contract, including the
+ # explicitly opt-in draft methods.
+ app = create_rpc_app(
+ build_local_static_workflow_server(tmp_path / "store", drafts=True),
+ drafts=True,
+ )
return app.get_openrpc()
@@ -399,6 +404,11 @@ def test_openrpc_exposes_typed_auth_delete_result(
"SaveArtifactResult",
{"artifact_id", "version", "saved"},
),
+ (
+ "workflow.artifacts.validate_plan",
+ "ValidateArtifactPlanResult",
+ {"status", "diagnostics", "required_capabilities", "workflow_dependencies"},
+ ),
(
"workflow.artifacts.save",
"SaveArtifactResult",
diff --git a/web/packages/rpc/scripts/workflow-contract-generator.test.ts b/web/packages/rpc/scripts/workflow-contract-generator.test.ts
index 408ed199..3b115b86 100644
--- a/web/packages/rpc/scripts/workflow-contract-generator.test.ts
+++ b/web/packages/rpc/scripts/workflow-contract-generator.test.ts
@@ -436,6 +436,6 @@ describe("workflow contract generator", () => {
const generatedSource = await generateWorkflowContractSource(manifestText);
expect(generatedSource).toBe(checkedSource);
- expect(generatedSource.match(/^ \| "workflow\./gm)).toHaveLength(71);
+ expect(generatedSource.match(/^ \| "workflow\./gm)).toHaveLength(72);
});
});
diff --git a/web/packages/rpc/src/generated/workflow-contract.test.ts b/web/packages/rpc/src/generated/workflow-contract.test.ts
index a8bf2298..9e54b809 100644
--- a/web/packages/rpc/src/generated/workflow-contract.test.ts
+++ b/web/packages/rpc/src/generated/workflow-contract.test.ts
@@ -15,8 +15,8 @@ import {
describe("generated workflow contract", () => {
it("contains every operation exactly once", () => {
- expect(workflowOperationNames).toHaveLength(71);
- expect(new Set(workflowOperationNames)).toHaveLength(71);
+ expect(workflowOperationNames).toHaveLength(72);
+ expect(new Set(workflowOperationNames)).toHaveLength(72);
});
it("contains every authored Effect operation without broadening its boundary", () => {
diff --git a/web/packages/rpc/src/generated/workflow-contract.ts b/web/packages/rpc/src/generated/workflow-contract.ts
index 744748e3..058be46d 100644
--- a/web/packages/rpc/src/generated/workflow-contract.ts
+++ b/web/packages/rpc/src/generated/workflow-contract.ts
@@ -22,6 +22,7 @@ export type WorkflowOperationName =
| "workflow.artifacts.inspect"
| "workflow.artifacts.list"
| "workflow.artifacts.save"
+ | "workflow.artifacts.validate_plan"
| "workflow.capabilities.call"
| "workflow.capabilities.inspect"
| "workflow.capabilities.list"
@@ -95,6 +96,7 @@ export const workflowOperationNames: readonly WorkflowOperationName[] = [
"workflow.artifacts.inspect",
"workflow.artifacts.list",
"workflow.artifacts.save",
+ "workflow.artifacts.validate_plan",
"workflow.capabilities.call",
"workflow.capabilities.inspect",
"workflow.capabilities.list",
@@ -380,6 +382,23 @@ export interface WorkflowContractMap {
};
result: SaveArtifactResult;
};
+ "workflow.artifacts.validate_plan": {
+ params: {
+ plan: {
+ [k: string]: unknown;
+ };
+ outcomes: string[];
+ required_capabilities?: {
+ [k: string]: {
+ [k: string]: unknown;
+ };
+ } | null;
+ source_bindings?: {
+ [k: string]: string;
+ } | null;
+ };
+ result: ValidateArtifactPlanResult;
+ };
"workflow.capabilities.call": {
params: {
qualified_name: string;
@@ -1248,6 +1267,35 @@ export interface ArtifactCatalogEntryPayload {
version: number;
[k: string]: unknown;
}
+/**
+ * Non-persisting artifact-plan validation and dependency inventory.
+ *
+ * This interface was referenced by `WorkflowContractMap`'s JSON-Schema
+ * via the `definition` "ValidateArtifactPlanResult".
+ */
+export interface ValidateArtifactPlanResult {
+ diagnostics: ArtifactPlanDiagnosticPayload[];
+ required_capabilities: RequiredCapabilityPayload[];
+ status: "valid" | "invalid";
+ workflow_dependencies: {
+ [k: string]: number;
+ };
+ [k: string]: unknown;
+}
+/**
+ * Stable diagnostic projected when an artifact plan is invalid.
+ *
+ * This interface was referenced by `WorkflowContractMap`'s JSON-Schema
+ * via the `definition` "ArtifactPlanDiagnosticPayload".
+ */
+export interface ArtifactPlanDiagnosticPayload {
+ code: string;
+ message: string;
+ path: string;
+ repair_hint: string | null;
+ severity: "error" | "warning";
+ [k: string]: unknown;
+}
/**
* Outcome returned by a direct node-spec or wrapper capability call.
*