merge: Python workflow client

This commit is contained in:
lda
2026-08-31 23:38:23 +07:00 Verified
87 changed files with 6474 additions and 289 deletions
+151
View File
@@ -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": [
+12
View File
@@ -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
+54
View File
@@ -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.
+27
View File
@@ -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.
@@ -1301,7 +1301,13 @@ artifact = await graph.save(version=1)
run = await artifact.run({})
```
State explicitly that drafts are not part of `wf_client` but remain registered in the server until the separate draft opt-out plan is executed. Update `docs/current_roadmap.md` to mark the Python-client slice complete only after every verification step below passes.
Drafts are not part of `wf_client`. The shipped server composition uses
`drafts=False` by default, which excludes draft storage, domain modules, and RPC
registration; real legacy draft consumers opt in explicitly with `drafts=True`.
The earlier plan assumption that draft methods would remain registered pending a
separate opt-out slice is historical. Update `docs/current_roadmap.md` to mark
the Python-client slice complete only after every verification step below
passes.
- [ ] **Step 6: Run focused and cross-layer verification**
@@ -633,12 +633,11 @@ capability, expose secrets, or fetch an unbounded trace.
authoring compiles a complete workflow and saves it through the existing
artifact-from-plan operation.
Making draft support uninitialized by default is a separate server-composition
slice. Today `WorkflowApi` constructs draft modules unconditionally, durable
context validation requires a draft store, and the JSON-RPC app always
registers draft methods. That follow-up must make draft storage, domain modules,
and RPC registration opt-in without weakening artifact, deployment, or run
durability.
Draft support is uninitialized by default across storage, domain modules, and
JSON-RPC registration. Normal composition uses `drafts=False`; callers that
still operate the legacy draft workspace surface must opt in explicitly with
`drafts=True`. This keeps artifact, deployment, and run durability independent
from draft storage.
## Testing Strategy
+53 -2
View File
@@ -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
+3 -13
View File
@@ -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]:
+169 -16
View File
@@ -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(
{
+18 -4
View File
@@ -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,
+2 -6
View File
@@ -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(
+16 -10
View File
@@ -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"]
+4
View File
@@ -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",
+19
View File
@@ -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
+84 -38
View File
@@ -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,
+13 -4
View File
@@ -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),
)
+10
View File
@@ -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."""
+2 -1
View File
@@ -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",
+39 -6
View File
@@ -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")
+3
View File
@@ -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",
]
+176 -12
View File
@@ -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)
+28 -2
View File
@@ -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
}
+5 -2
View File
@@ -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,
+59
View File
@@ -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",
]
+284
View File
@@ -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,
)
+31
View File
@@ -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}"
),
)
+124
View File
@@ -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(
'<tr><th scope="row">'
+ html.escape(name)
+ "</th><td><code>"
+ html.escape(html_preview(value))
+ "</code></td></tr>"
for name, value in fields.items()
)
return (
'<div class="wf-client-repr"><strong>'
+ html.escape(type_name)
+ "</strong><table><tbody>"
+ rows
+ "</tbody></table></div>"
)
+221
View File
@@ -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",
)
+323
View File
@@ -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)},
}
+252
View File
@@ -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),
)
+283
View File
@@ -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))
)
+333
View File
@@ -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)
+137
View File
@@ -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."""
+131
View File
@@ -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: ...
+291
View File
@@ -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,
)
+290
View File
@@ -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,
)
+1
View File
@@ -22,6 +22,7 @@ def __getattr__(name: str) -> object:
return generate_manifest
raise AttributeError(name)
__all__ = [
"ContractManifest",
"JsonSchema",
+3 -1
View File
@@ -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
+9 -2
View File
@@ -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)
+3 -1
View File
@@ -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)
+2 -1
View File
@@ -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,
+4 -2
View File
@@ -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"]
+3 -1
View File
@@ -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",
+9 -3
View File
@@ -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",
+6 -1
View File
@@ -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)),
)
+4 -3
View File
@@ -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(),
+10 -2
View File
@@ -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)
@@ -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,
*,
+41 -5
View File
@@ -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")
@@ -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(),
+7
View File
@@ -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]
+28
View File
@@ -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")
+166 -1
View File
@@ -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",
@@ -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)
+165 -2
View File
@@ -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
+2 -1
View File
@@ -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
)
@@ -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,
+1 -1
View File
@@ -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:
+31 -31
View File
@@ -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
+20
View File
@@ -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
+15 -3
View File
@@ -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:
+6 -14
View File
@@ -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
+34 -34
View File
@@ -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")
+1
View File
@@ -0,0 +1 @@
"""Tests for the transport-independent workflow client boundary."""
+73
View File
@@ -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()
+299
View File
@@ -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")
+317
View File
@@ -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"}
}
+197
View File
@@ -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,
)
+175
View File
@@ -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"
+367
View File
@@ -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"
+61
View File
@@ -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"}
+157
View File
@@ -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 <carefully>",
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 "&lt;carefully&gt;" 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 == []
+255
View File
@@ -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"
+7 -2
View File
@@ -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")]
+4 -6
View File
@@ -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": {
+3 -3
View File
@@ -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"),
+2
View File
@@ -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
+1 -3
View File
@@ -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
+19 -9
View File
@@ -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):
+23 -3
View File
@@ -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)
+73
View File
@@ -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)
+94 -11
View File
@@ -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"
@@ -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)
@@ -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",
@@ -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);
});
});
@@ -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", () => {
@@ -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.
*