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