merge: Python workflow client
This commit is contained in:
@@ -218,6 +218,45 @@
|
|||||||
],
|
],
|
||||||
"type": "string"
|
"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": {
|
"AuthRecordSummaryPayload": {
|
||||||
"additionalProperties": false,
|
"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.",
|
"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"
|
"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": {
|
"ValidateDeploymentResult": {
|
||||||
"properties": {
|
"properties": {
|
||||||
"artifact_id": {
|
"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",
|
"action": "call",
|
||||||
"errors": [
|
"errors": [
|
||||||
|
|||||||
@@ -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
|
`wf_server` composition. The old `wf-mcp` script remains a legacy/special-purpose
|
||||||
MCP entrypoint and compatibility surface.
|
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
|
## Active Initiative: Workflow Console And Defense Demo
|
||||||
|
|
||||||
The next product-facing push is a local-first web console and defense demo that
|
The next product-facing push is a local-first web console and defense demo that
|
||||||
|
|||||||
@@ -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_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_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_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_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_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. |
|
| `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_server.WorkflowServer`: durable workflow server composition object.
|
||||||
- `wf_transport_rpc_http.RpcWorkflowApiClient`: JSON-RPC client implementing
|
- `wf_transport_rpc_http.RpcWorkflowApiClient`: JSON-RPC client implementing
|
||||||
the workflow/admin surfaces over HTTP.
|
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
|
- `wf_transport_rpc_http.create_rpc_app`: JSON-RPC HTTP adapter over an existing
|
||||||
`WorkflowServer`.
|
`WorkflowServer`.
|
||||||
- `wf_sources_mcp.McpRuntimePool`: persistent MCP source runtime for stateful
|
- `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.
|
`--keep-temp` to preserve the generated config/store on failure.
|
||||||
- `examples/browser_click_workflow/` is a serial browser-click workflow
|
- `examples/browser_click_workflow/` is a serial browser-click workflow
|
||||||
example with bounded before/after snapshots and full lifecycle tests.
|
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
|
- `examples/agent_challenges/` contains reusable opencode challenge harnesses
|
||||||
for evaluating whether agents can use the public workflow CLI/server path.
|
for evaluating whether agents can use the public workflow CLI/server path.
|
||||||
|
|
||||||
|
|||||||
@@ -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_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_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_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. |
|
| `wf_mcp` | Legacy/special-purpose MCP frontend, broker glue, proxy, compatibility shims. | New durable product behavior unless explicitly retiring old callers. |
|
||||||
|
|
||||||
## Data Flow
|
## Data Flow
|
||||||
@@ -65,6 +66,17 @@ wf_config.server.sources[]
|
|||||||
-> transport or CLI
|
-> 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:
|
The first shared provider seam is intentionally static:
|
||||||
|
|
||||||
```python
|
```python
|
||||||
@@ -212,3 +224,18 @@ Then add:
|
|||||||
|
|
||||||
Do not add source-family branches inside `wf_api` run execution. Source-specific
|
Do not add source-family branches inside `wf_api` run execution. Source-specific
|
||||||
logic belongs in the provider package or server composition layer.
|
logic belongs in the provider package or server composition layer.
|
||||||
|
|
||||||
|
## Python client boundary
|
||||||
|
|
||||||
|
`wf_client` is a consumer-facing layer, not another server API.
|
||||||
|
`App.from_http_jsonrpc()` creates a lazy HTTP transport; the first
|
||||||
|
capability/artifact/deployment/run operation performs I/O. `EditableWorkflow`
|
||||||
|
keeps graph construction local and uses the port only for remote validation and
|
||||||
|
artifact persistence. The server remains responsible for durable artifact,
|
||||||
|
deployment, and run stores and for resolving concrete source bindings.
|
||||||
|
|
||||||
|
The client intentionally omits draft workspace operations. Drafts are a
|
||||||
|
server-side authoring/admin surface, and a server composition must opt in to
|
||||||
|
registering their JSON-RPC methods. This keeps a normal Python application
|
||||||
|
focused on the stable artifact -> deployment -> run lifecycle while retaining
|
||||||
|
the underlying draft implementation for explicit server users.
|
||||||
|
|||||||
@@ -1301,7 +1301,13 @@ artifact = await graph.save(version=1)
|
|||||||
run = await artifact.run({})
|
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**
|
- [ ] **Step 6: Run focused and cross-layer verification**
|
||||||
|
|
||||||
|
|||||||
@@ -633,12 +633,11 @@ capability, expose secrets, or fetch an unbounded trace.
|
|||||||
authoring compiles a complete workflow and saves it through the existing
|
authoring compiles a complete workflow and saves it through the existing
|
||||||
artifact-from-plan operation.
|
artifact-from-plan operation.
|
||||||
|
|
||||||
Making draft support uninitialized by default is a separate server-composition
|
Draft support is uninitialized by default across storage, domain modules, and
|
||||||
slice. Today `WorkflowApi` constructs draft modules unconditionally, durable
|
JSON-RPC registration. Normal composition uses `drafts=False`; callers that
|
||||||
context validation requires a draft store, and the JSON-RPC app always
|
still operate the legacy draft workspace surface must opt in explicitly with
|
||||||
registers draft methods. That follow-up must make draft storage, domain modules,
|
`drafts=True`. This keeps artifact, deployment, and run durability independent
|
||||||
and RPC registration opt-in without weakening artifact, deployment, or run
|
from draft storage.
|
||||||
durability.
|
|
||||||
|
|
||||||
## Testing Strategy
|
## Testing Strategy
|
||||||
|
|
||||||
|
|||||||
@@ -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_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_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_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. |
|
| 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. |
|
| `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
|
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.
|
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 And Domain Services
|
||||||
|
|
||||||
`WorkflowApiSurface` is the public application contract shared by local and
|
`WorkflowApiSurface` is the public application contract shared by local and
|
||||||
@@ -154,8 +205,8 @@ contract itself.
|
|||||||
```text
|
```text
|
||||||
WorkflowApi
|
WorkflowApi
|
||||||
capabilities: WorkflowCapabilityApi
|
capabilities: WorkflowCapabilityApi
|
||||||
drafts: WorkflowDraftApi
|
drafts: WorkflowDraftApi | None # only when drafts=True
|
||||||
draft_authoring: WorkflowDraftAuthoringApi
|
draft_authoring: WorkflowDraftAuthoringApi | None # only when drafts=True
|
||||||
artifacts: WorkflowArtifactApi
|
artifacts: WorkflowArtifactApi
|
||||||
deployments: WorkflowDeploymentApi
|
deployments: WorkflowDeploymentApi
|
||||||
runs: WorkflowRunApi
|
runs: WorkflowRunApi
|
||||||
|
|||||||
@@ -19,12 +19,7 @@ WORKFLOW_OUTPUT = [
|
|||||||
|
|
||||||
|
|
||||||
def build_workflow() -> Workflow:
|
def build_workflow() -> Workflow:
|
||||||
"""Build the demo workflow with the public authoring API.
|
"""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.
|
|
||||||
"""
|
|
||||||
builder = WorkflowBuilder(
|
builder = WorkflowBuilder(
|
||||||
name="lda_report_case_study",
|
name="lda_report_case_study",
|
||||||
input_schema={
|
input_schema={
|
||||||
@@ -185,13 +180,8 @@ def build_workflow() -> Workflow:
|
|||||||
builder.connect(create_issues, "ok", finalise)
|
builder.connect(create_issues, "ok", finalise)
|
||||||
builder.connect(finalise, "ok", end_completed)
|
builder.connect(finalise, "ok", end_completed)
|
||||||
builder.connect(revision_requested, "ok", end_cancelled)
|
builder.connect(revision_requested, "ok", end_cancelled)
|
||||||
return _with_workflow_output(builder.compile())
|
builder.set_output(WORKFLOW_OUTPUT)
|
||||||
|
return 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)
|
|
||||||
|
|
||||||
|
|
||||||
def workflow_plan_payload() -> dict[str, Any]:
|
def workflow_plan_payload() -> dict[str, Any]:
|
||||||
|
|||||||
+169
-16
@@ -9,10 +9,13 @@ from __future__ import annotations
|
|||||||
from collections.abc import Mapping, Sequence
|
from collections.abc import Mapping, Sequence
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
|
from pydantic import ValidationError
|
||||||
|
|
||||||
from wf_artifacts import (
|
from wf_artifacts import (
|
||||||
ArtifactKind,
|
ArtifactKind,
|
||||||
RequiredCapability,
|
RequiredCapability,
|
||||||
WorkflowArtifact,
|
WorkflowArtifact,
|
||||||
|
WorkflowPlanValidationError,
|
||||||
artifact_catalog_entry,
|
artifact_catalog_entry,
|
||||||
)
|
)
|
||||||
from wf_artifacts import (
|
from wf_artifacts import (
|
||||||
@@ -25,6 +28,7 @@ from .capability_requirements import observed_node_specs
|
|||||||
from .drafts import WorkflowDraftApi
|
from .drafts import WorkflowDraftApi
|
||||||
from .listing import matches_query, paged_list_payload
|
from .listing import matches_query, paged_list_payload
|
||||||
from .models import (
|
from .models import (
|
||||||
|
ArtifactPlanDiagnosticPayload,
|
||||||
CreateArtifactFromWorkspaceResult,
|
CreateArtifactFromWorkspaceResult,
|
||||||
DeleteArtifactResult,
|
DeleteArtifactResult,
|
||||||
JsonProjector,
|
JsonProjector,
|
||||||
@@ -33,6 +37,7 @@ from .models import (
|
|||||||
SaveArtifactResult,
|
SaveArtifactResult,
|
||||||
SavedDraftArtifactResult,
|
SavedDraftArtifactResult,
|
||||||
UnsavedDraftArtifactResult,
|
UnsavedDraftArtifactResult,
|
||||||
|
ValidateArtifactPlanResult,
|
||||||
WorkflowArtifactPayload,
|
WorkflowArtifactPayload,
|
||||||
)
|
)
|
||||||
from .operation_context import WorkflowOperationContext
|
from .operation_context import WorkflowOperationContext
|
||||||
@@ -41,10 +46,82 @@ _PROJECT_ARTIFACT = JsonProjector(WorkflowArtifactPayload)
|
|||||||
_PROJECT_ARTIFACT_LIST = JsonProjector(ListArtifactsResult)
|
_PROJECT_ARTIFACT_LIST = JsonProjector(ListArtifactsResult)
|
||||||
_PROJECT_ARTIFACT_SAVE = JsonProjector(SaveArtifactResult)
|
_PROJECT_ARTIFACT_SAVE = JsonProjector(SaveArtifactResult)
|
||||||
_PROJECT_ARTIFACT_DELETE = JsonProjector(DeleteArtifactResult)
|
_PROJECT_ARTIFACT_DELETE = JsonProjector(DeleteArtifactResult)
|
||||||
|
_PROJECT_VALIDATE_ARTIFACT = JsonProjector(ValidateArtifactPlanResult)
|
||||||
_PROJECT_UNSAVED_DRAFT_ARTIFACT = JsonProjector(UnsavedDraftArtifactResult)
|
_PROJECT_UNSAVED_DRAFT_ARTIFACT = JsonProjector(UnsavedDraftArtifactResult)
|
||||||
_PROJECT_SAVED_DRAFT_ARTIFACT = JsonProjector(SavedDraftArtifactResult)
|
_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:
|
class WorkflowArtifactApi:
|
||||||
"""Saved workflow artifact operations.
|
"""Saved workflow artifact operations.
|
||||||
|
|
||||||
@@ -52,9 +129,19 @@ class WorkflowArtifactApi:
|
|||||||
WorkflowOperationContext so this module stays protocol-neutral.
|
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.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):
|
def _artifact_store(self):
|
||||||
if self.context.artifact_store is None:
|
if self.context.artifact_store is None:
|
||||||
@@ -133,25 +220,17 @@ class WorkflowArtifactApi:
|
|||||||
source_bindings: dict[str, str] | None = None,
|
source_bindings: dict[str, str] | None = None,
|
||||||
created_from_catalog_version: str | None = None,
|
created_from_catalog_version: str | None = None,
|
||||||
) -> SaveArtifactResult:
|
) -> SaveArtifactResult:
|
||||||
typed_plan = (
|
workflow_artifact = _prepare_artifact_from_plan(
|
||||||
plan
|
self.context,
|
||||||
if isinstance(plan, RawWorkflowPlan)
|
|
||||||
else RawWorkflowPlan.model_validate(plan)
|
|
||||||
)
|
|
||||||
workflow_artifact = build_workflow_artifact_from_plan(
|
|
||||||
artifact_id=artifact_id,
|
artifact_id=artifact_id,
|
||||||
version=version,
|
version=version,
|
||||||
title=title,
|
title=title,
|
||||||
kind=kind,
|
kind=kind,
|
||||||
description=description,
|
description=description,
|
||||||
plan=typed_plan.model_dump(mode="json", by_alias=True),
|
plan=plan,
|
||||||
outcomes=tuple(outcomes),
|
outcomes=outcomes,
|
||||||
required_capabilities={
|
required_capabilities=required_capabilities,
|
||||||
name: RequiredCapability.model_validate(capability)
|
|
||||||
for name, capability in (required_capabilities or {}).items()
|
|
||||||
},
|
|
||||||
source_bindings=source_bindings,
|
source_bindings=source_bindings,
|
||||||
observed_node_specs=observed_node_specs(self.context),
|
|
||||||
created_from_catalog_version=created_from_catalog_version,
|
created_from_catalog_version=created_from_catalog_version,
|
||||||
)
|
)
|
||||||
self._artifact_store().save_artifact(workflow_artifact)
|
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(
|
async def create_artifact_from_draft(
|
||||||
self,
|
self,
|
||||||
*,
|
*,
|
||||||
@@ -253,7 +406,7 @@ class WorkflowArtifactApi:
|
|||||||
if store is None:
|
if store is None:
|
||||||
raise KeyError("draft workspace store is not configured")
|
raise KeyError("draft workspace store is not configured")
|
||||||
workspace = store.get_workspace(workspace_id)
|
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":
|
if validation["status"] != "valid":
|
||||||
return _PROJECT_UNSAVED_DRAFT_ARTIFACT(
|
return _PROJECT_UNSAVED_DRAFT_ARTIFACT(
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -98,10 +98,24 @@ class WorkflowCapabilityApi:
|
|||||||
tool schemas stay outside wf_api.
|
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.context = context
|
||||||
self.drafts = WorkflowDraftApi(context)
|
self.drafts: WorkflowDraftApi | None = (
|
||||||
self.draft_authoring = WorkflowDraftAuthoringApi(context, self.drafts)
|
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(
|
async def list_capabilities(
|
||||||
self,
|
self,
|
||||||
@@ -441,7 +455,7 @@ class WorkflowCapabilityApi:
|
|||||||
# Validate capability-derived guidance before workspace creation. The
|
# Validate capability-derived guidance before workspace creation. The
|
||||||
# workspace result is already projected by the draft-workspace API.
|
# workspace result is already projected by the draft-workspace API.
|
||||||
hints = _PROJECT_WRAPPER_HINTS(capability["wrapper_hints"])
|
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,
|
workspace_id=workspace_id,
|
||||||
name=name or _draft_name_from_capability(capability_name),
|
name=name or _draft_name_from_capability(capability_name),
|
||||||
capability_name=capability_name,
|
capability_name=capability_name,
|
||||||
|
|||||||
@@ -62,9 +62,7 @@ class WorkflowDeploymentApi:
|
|||||||
.model_dump(mode="json"),
|
.model_dump(mode="json"),
|
||||||
)
|
)
|
||||||
|
|
||||||
async def save_deployment(
|
async def save_deployment(self, deployment: dict[str, Any]) -> SaveDeploymentResult:
|
||||||
self, deployment: dict[str, Any]
|
|
||||||
) -> SaveDeploymentResult:
|
|
||||||
workflow_deployment = WorkflowDeployment.model_validate(deployment)
|
workflow_deployment = WorkflowDeployment.model_validate(deployment)
|
||||||
self._artifact_store().save_deployment(workflow_deployment)
|
self._artifact_store().save_deployment(workflow_deployment)
|
||||||
self.context.events.record_workflow_event(
|
self.context.events.record_workflow_event(
|
||||||
@@ -83,9 +81,7 @@ class WorkflowDeploymentApi:
|
|||||||
"saved": True,
|
"saved": True,
|
||||||
}
|
}
|
||||||
|
|
||||||
async def delete_deployment(
|
async def delete_deployment(self, *, deployment_id: str) -> DeleteDeploymentResult:
|
||||||
self, *, deployment_id: str
|
|
||||||
) -> DeleteDeploymentResult:
|
|
||||||
"""Delete one mutable deployment environment binding."""
|
"""Delete one mutable deployment environment binding."""
|
||||||
self._artifact_store().delete_deployment(deployment_id)
|
self._artifact_store().delete_deployment(deployment_id)
|
||||||
self.context.events.record_workflow_event(
|
self.context.events.record_workflow_event(
|
||||||
|
|||||||
@@ -5,25 +5,27 @@ from .service import WorkflowApi
|
|||||||
from .stores import WorkflowStores
|
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.
|
"""Return required stores or fail before constructing durable frontends.
|
||||||
|
|
||||||
`WorkflowOperationContext` keeps stores optional for compatibility tests and
|
`WorkflowOperationContext` keeps stores optional for compatibility tests and
|
||||||
lightweight MCP surfaces. Durable API surfaces need all stores up front so a
|
lightweight MCP surfaces. Durable API surfaces need artifact/run stores up
|
||||||
run cannot start without somewhere to persist artifacts, drafts, and stopped
|
front; a draft store is only required when draft APIs are enabled.
|
||||||
execution state.
|
|
||||||
"""
|
"""
|
||||||
missing = []
|
missing = []
|
||||||
if context.artifact_store is None:
|
if context.artifact_store is None:
|
||||||
missing.append("artifact_store")
|
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")
|
missing.append("draft_workspace_store")
|
||||||
if context.run_store is None:
|
if context.run_store is None:
|
||||||
missing.append("run_store")
|
missing.append("run_store")
|
||||||
if missing:
|
if missing:
|
||||||
raise ValueError("durable workflow API requires stores: " + ", ".join(missing))
|
raise ValueError("durable workflow API requires stores: " + ", ".join(missing))
|
||||||
assert context.artifact_store is not None
|
assert context.artifact_store is not None
|
||||||
assert context.draft_workspace_store is not None
|
|
||||||
assert context.run_store is not None
|
assert context.run_store is not None
|
||||||
return WorkflowStores(
|
return WorkflowStores(
|
||||||
artifact_store=context.artifact_store,
|
artifact_store=context.artifact_store,
|
||||||
@@ -32,10 +34,14 @@ def require_workflow_stores(context: WorkflowOperationContext) -> WorkflowStores
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def durable_workflow_api(context: WorkflowOperationContext) -> WorkflowApi:
|
def durable_workflow_api(
|
||||||
"""Construct a WorkflowApi only after durable store dependencies exist."""
|
context: WorkflowOperationContext,
|
||||||
require_workflow_stores(context)
|
*,
|
||||||
return WorkflowApi(context)
|
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"]
|
__all__ = ["durable_workflow_api", "require_workflow_stores"]
|
||||||
|
|||||||
@@ -14,12 +14,14 @@ from .admin import (
|
|||||||
from .artifacts import (
|
from .artifacts import (
|
||||||
ArtifactCatalogEntryPayload,
|
ArtifactCatalogEntryPayload,
|
||||||
ArtifactKindPayload,
|
ArtifactKindPayload,
|
||||||
|
ArtifactPlanDiagnosticPayload,
|
||||||
CapabilityKindPayload,
|
CapabilityKindPayload,
|
||||||
CapabilityRefPayload,
|
CapabilityRefPayload,
|
||||||
DeleteArtifactResult,
|
DeleteArtifactResult,
|
||||||
ListArtifactsResult,
|
ListArtifactsResult,
|
||||||
RequiredCapabilityPayload,
|
RequiredCapabilityPayload,
|
||||||
SaveArtifactResult,
|
SaveArtifactResult,
|
||||||
|
ValidateArtifactPlanResult,
|
||||||
WorkflowArtifactPayload,
|
WorkflowArtifactPayload,
|
||||||
)
|
)
|
||||||
from .authoring_contracts import (
|
from .authoring_contracts import (
|
||||||
@@ -139,6 +141,7 @@ __all__ = [
|
|||||||
"AuthoringStepContractPayload",
|
"AuthoringStepContractPayload",
|
||||||
"AuthRecordSummaryPayload",
|
"AuthRecordSummaryPayload",
|
||||||
"ArtifactCatalogEntryPayload",
|
"ArtifactCatalogEntryPayload",
|
||||||
|
"ArtifactPlanDiagnosticPayload",
|
||||||
"ArtifactKindPayload",
|
"ArtifactKindPayload",
|
||||||
"CapabilityKindPayload",
|
"CapabilityKindPayload",
|
||||||
"CapabilityCallResult",
|
"CapabilityCallResult",
|
||||||
@@ -205,6 +208,7 @@ __all__ = [
|
|||||||
"RequiredCapabilityPayload",
|
"RequiredCapabilityPayload",
|
||||||
"RemoveRegistryEntryResult",
|
"RemoveRegistryEntryResult",
|
||||||
"SaveArtifactResult",
|
"SaveArtifactResult",
|
||||||
|
"ValidateArtifactPlanResult",
|
||||||
"SavedDraftArtifactResult",
|
"SavedDraftArtifactResult",
|
||||||
"SaveDeploymentResult",
|
"SaveDeploymentResult",
|
||||||
"SourceBindingPayload",
|
"SourceBindingPayload",
|
||||||
|
|||||||
@@ -80,6 +80,25 @@ class SaveArtifactResult(TypedDict):
|
|||||||
saved: bool
|
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):
|
class DeleteArtifactResult(TypedDict):
|
||||||
artifact_id: str
|
artifact_id: str
|
||||||
version: int
|
version: int
|
||||||
|
|||||||
+84
-38
@@ -46,6 +46,7 @@ from .models import (
|
|||||||
SaveArtifactResult,
|
SaveArtifactResult,
|
||||||
SavedDraftArtifactResult,
|
SavedDraftArtifactResult,
|
||||||
SaveDeploymentResult,
|
SaveDeploymentResult,
|
||||||
|
ValidateArtifactPlanResult,
|
||||||
ValidateDeploymentResult,
|
ValidateDeploymentResult,
|
||||||
ValidateDraftResult,
|
ValidateDraftResult,
|
||||||
WorkflowArtifactPayload,
|
WorkflowArtifactPayload,
|
||||||
@@ -99,15 +100,39 @@ class WorkflowApi:
|
|||||||
callers share one operation surface without importing wf_mcp.
|
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.context = context
|
||||||
self.capabilities = WorkflowCapabilityApi(context)
|
self.capabilities = WorkflowCapabilityApi(context, drafts=drafts)
|
||||||
self.drafts = WorkflowDraftApi(context)
|
# ``drafts`` keeps server composition explicit. Disabled APIs are None,
|
||||||
self.draft_authoring = WorkflowDraftAuthoringApi(context, self.drafts)
|
# so artifact/deployment/run initialization has no draft dependency.
|
||||||
self.artifacts = WorkflowArtifactApi(context)
|
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.deployments = WorkflowDeploymentApi(context)
|
||||||
self.runs = WorkflowRunApi(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 --
|
# -- capabilities --
|
||||||
|
|
||||||
async def list_capabilities(
|
async def list_capabilities(
|
||||||
@@ -217,6 +242,21 @@ class WorkflowApi:
|
|||||||
created_from_catalog_version=created_from_catalog_version,
|
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(
|
async def create_artifact_from_draft(
|
||||||
self,
|
self,
|
||||||
*,
|
*,
|
||||||
@@ -303,14 +343,14 @@ class WorkflowApi:
|
|||||||
*,
|
*,
|
||||||
draft: dict[str, Any],
|
draft: dict[str, Any],
|
||||||
) -> ValidateDraftResult:
|
) -> ValidateDraftResult:
|
||||||
return await self.drafts.validate_draft(draft=draft)
|
return await self._require_drafts().validate_draft(draft=draft)
|
||||||
|
|
||||||
async def compile_draft(
|
async def compile_draft(
|
||||||
self,
|
self,
|
||||||
*,
|
*,
|
||||||
draft: dict[str, Any],
|
draft: dict[str, Any],
|
||||||
) -> CompileDraftWorkspaceSuccess:
|
) -> CompileDraftWorkspaceSuccess:
|
||||||
return await self.drafts.compile_draft(draft=draft)
|
return await self._require_drafts().compile_draft(draft=draft)
|
||||||
|
|
||||||
async def patch_draft(
|
async def patch_draft(
|
||||||
self,
|
self,
|
||||||
@@ -318,12 +358,12 @@ class WorkflowApi:
|
|||||||
draft: dict[str, Any],
|
draft: dict[str, Any],
|
||||||
patch: list[dict[str, Any]],
|
patch: list[dict[str, Any]],
|
||||||
) -> PatchDraftResult:
|
) -> PatchDraftResult:
|
||||||
return await self.drafts.patch_draft(draft=draft, patch=patch)
|
return await self._require_drafts().patch_draft(draft=draft, patch=patch)
|
||||||
|
|
||||||
# -- draft workspaces --
|
# -- draft workspaces --
|
||||||
|
|
||||||
async def list_draft_workspaces(self) -> ListDraftWorkspacesResult:
|
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(
|
async def create_draft_workspace(
|
||||||
self,
|
self,
|
||||||
@@ -332,7 +372,7 @@ class WorkflowApi:
|
|||||||
draft: dict[str, Any],
|
draft: dict[str, Any],
|
||||||
title: str | None = None,
|
title: str | None = None,
|
||||||
) -> DraftWorkspaceResult:
|
) -> DraftWorkspaceResult:
|
||||||
return await self.drafts.create_draft_workspace(
|
return await self._require_drafts().create_draft_workspace(
|
||||||
workspace_id=workspace_id,
|
workspace_id=workspace_id,
|
||||||
draft=draft,
|
draft=draft,
|
||||||
title=title,
|
title=title,
|
||||||
@@ -349,7 +389,7 @@ class WorkflowApi:
|
|||||||
output_schema: dict[str, Any] | None = None,
|
output_schema: dict[str, Any] | None = None,
|
||||||
outcomes: Sequence[str] = ("ok",),
|
outcomes: Sequence[str] = ("ok",),
|
||||||
) -> DraftWorkspaceResult:
|
) -> DraftWorkspaceResult:
|
||||||
return await self.drafts.create_empty_draft_workspace(
|
return await self._require_drafts().create_empty_draft_workspace(
|
||||||
workspace_id=workspace_id,
|
workspace_id=workspace_id,
|
||||||
name=name,
|
name=name,
|
||||||
title=title,
|
title=title,
|
||||||
@@ -381,7 +421,7 @@ class WorkflowApi:
|
|||||||
workspace_id: str,
|
workspace_id: str,
|
||||||
include_draft: bool = False,
|
include_draft: bool = False,
|
||||||
) -> DraftWorkspaceResult | DraftWorkspaceWithDocument:
|
) -> DraftWorkspaceResult | DraftWorkspaceWithDocument:
|
||||||
return await self.drafts.get_draft_workspace(
|
return await self._require_drafts().get_draft_workspace(
|
||||||
workspace_id=workspace_id,
|
workspace_id=workspace_id,
|
||||||
include_draft=include_draft,
|
include_draft=include_draft,
|
||||||
)
|
)
|
||||||
@@ -399,7 +439,7 @@ class WorkflowApi:
|
|||||||
selected step. This preserves the draft APIs' canonical conflict
|
selected step. This preserves the draft APIs' canonical conflict
|
||||||
precedence when an authoring client is holding an old revision.
|
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,
|
workspace_id=workspace_id,
|
||||||
revision=revision,
|
revision=revision,
|
||||||
)
|
)
|
||||||
@@ -523,21 +563,27 @@ class WorkflowApi:
|
|||||||
*,
|
*,
|
||||||
workspace_id: str,
|
workspace_id: str,
|
||||||
) -> DeleteDraftWorkspaceResult:
|
) -> 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(
|
async def validate_draft_workspace(
|
||||||
self,
|
self,
|
||||||
*,
|
*,
|
||||||
workspace_id: str,
|
workspace_id: str,
|
||||||
) -> DraftWorkspaceResult:
|
) -> 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(
|
async def compile_draft_workspace(
|
||||||
self,
|
self,
|
||||||
*,
|
*,
|
||||||
workspace_id: str,
|
workspace_id: str,
|
||||||
) -> CompileDraftWorkspaceResult:
|
) -> 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(
|
async def patch_draft_workspace(
|
||||||
self,
|
self,
|
||||||
@@ -546,7 +592,7 @@ class WorkflowApi:
|
|||||||
revision: int,
|
revision: int,
|
||||||
patch: list[dict[str, Any]],
|
patch: list[dict[str, Any]],
|
||||||
) -> DraftWorkspaceResult:
|
) -> DraftWorkspaceResult:
|
||||||
return await self.drafts.patch_draft_workspace(
|
return await self._require_drafts().patch_draft_workspace(
|
||||||
workspace_id=workspace_id,
|
workspace_id=workspace_id,
|
||||||
revision=revision,
|
revision=revision,
|
||||||
patch=patch,
|
patch=patch,
|
||||||
@@ -560,7 +606,7 @@ class WorkflowApi:
|
|||||||
draft: dict[str, Any],
|
draft: dict[str, Any],
|
||||||
) -> DraftWorkspaceResult:
|
) -> DraftWorkspaceResult:
|
||||||
"""Replace and semantically revalidate one complete workspace draft."""
|
"""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,
|
workspace_id=workspace_id,
|
||||||
revision=revision,
|
revision=revision,
|
||||||
draft=draft,
|
draft=draft,
|
||||||
@@ -573,7 +619,7 @@ class WorkflowApi:
|
|||||||
revision: int,
|
revision: int,
|
||||||
name: str,
|
name: str,
|
||||||
) -> DraftWorkspaceResult:
|
) -> DraftWorkspaceResult:
|
||||||
return await self.drafts.set_draft_name(
|
return await self._require_drafts().set_draft_name(
|
||||||
workspace_id=workspace_id,
|
workspace_id=workspace_id,
|
||||||
revision=revision,
|
revision=revision,
|
||||||
name=name,
|
name=name,
|
||||||
@@ -586,7 +632,7 @@ class WorkflowApi:
|
|||||||
revision: int,
|
revision: int,
|
||||||
step_id: str,
|
step_id: str,
|
||||||
) -> DraftWorkspaceResult:
|
) -> DraftWorkspaceResult:
|
||||||
return await self.drafts.set_draft_start(
|
return await self._require_drafts().set_draft_start(
|
||||||
workspace_id=workspace_id,
|
workspace_id=workspace_id,
|
||||||
revision=revision,
|
revision=revision,
|
||||||
step_id=step_id,
|
step_id=step_id,
|
||||||
@@ -602,7 +648,7 @@ class WorkflowApi:
|
|||||||
output_schema: dict[str, Any] | None = None,
|
output_schema: dict[str, Any] | None = None,
|
||||||
outcomes: Sequence[str] | None = None,
|
outcomes: Sequence[str] | None = None,
|
||||||
) -> DraftWorkspaceResult:
|
) -> DraftWorkspaceResult:
|
||||||
return await self.drafts.set_draft_contract(
|
return await self._require_drafts().set_draft_contract(
|
||||||
workspace_id=workspace_id,
|
workspace_id=workspace_id,
|
||||||
revision=revision,
|
revision=revision,
|
||||||
input_schema=input_schema,
|
input_schema=input_schema,
|
||||||
@@ -620,7 +666,7 @@ class WorkflowApi:
|
|||||||
outcome: str,
|
outcome: str,
|
||||||
target: str,
|
target: str,
|
||||||
) -> DraftWorkspaceResult:
|
) -> DraftWorkspaceResult:
|
||||||
return await self.drafts.set_draft_route(
|
return await self._require_drafts().set_draft_route(
|
||||||
workspace_id=workspace_id,
|
workspace_id=workspace_id,
|
||||||
revision=revision,
|
revision=revision,
|
||||||
step_id=step_id,
|
step_id=step_id,
|
||||||
@@ -637,7 +683,7 @@ class WorkflowApi:
|
|||||||
input_map: dict[str, str],
|
input_map: dict[str, str],
|
||||||
merge: bool = False,
|
merge: bool = False,
|
||||||
) -> DraftWorkspaceResult:
|
) -> DraftWorkspaceResult:
|
||||||
return await self.drafts.set_step_input_map(
|
return await self._require_drafts().set_step_input_map(
|
||||||
workspace_id=workspace_id,
|
workspace_id=workspace_id,
|
||||||
revision=revision,
|
revision=revision,
|
||||||
step_id=step_id,
|
step_id=step_id,
|
||||||
@@ -653,7 +699,7 @@ class WorkflowApi:
|
|||||||
step_id: str,
|
step_id: str,
|
||||||
bindings: Sequence[StepInputBinding],
|
bindings: Sequence[StepInputBinding],
|
||||||
) -> DraftWorkspaceResult:
|
) -> DraftWorkspaceResult:
|
||||||
return await self.draft_authoring.set_step_input_bindings(
|
return await self._require_draft_authoring().set_step_input_bindings(
|
||||||
workspace_id=workspace_id,
|
workspace_id=workspace_id,
|
||||||
revision=revision,
|
revision=revision,
|
||||||
step_id=step_id,
|
step_id=step_id,
|
||||||
@@ -668,7 +714,7 @@ class WorkflowApi:
|
|||||||
step_id: str,
|
step_id: str,
|
||||||
bindings: Sequence[OutputBinding],
|
bindings: Sequence[OutputBinding],
|
||||||
) -> DraftWorkspaceResult:
|
) -> DraftWorkspaceResult:
|
||||||
return await self.draft_authoring.set_step_output_bindings(
|
return await self._require_draft_authoring().set_step_output_bindings(
|
||||||
workspace_id=workspace_id,
|
workspace_id=workspace_id,
|
||||||
revision=revision,
|
revision=revision,
|
||||||
step_id=step_id,
|
step_id=step_id,
|
||||||
@@ -684,7 +730,7 @@ class WorkflowApi:
|
|||||||
update: CapabilityStepUpdate,
|
update: CapabilityStepUpdate,
|
||||||
) -> DraftWorkspaceResult:
|
) -> DraftWorkspaceResult:
|
||||||
"""Return the updated workspace summary or a revision-conflict payload."""
|
"""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,
|
workspace_id=workspace_id,
|
||||||
revision=revision,
|
revision=revision,
|
||||||
step_id=step_id,
|
step_id=step_id,
|
||||||
@@ -700,7 +746,7 @@ class WorkflowApi:
|
|||||||
output_map: dict[str, str],
|
output_map: dict[str, str],
|
||||||
merge: bool = False,
|
merge: bool = False,
|
||||||
) -> DraftWorkspaceResult:
|
) -> DraftWorkspaceResult:
|
||||||
return await self.drafts.set_step_output_map(
|
return await self._require_drafts().set_step_output_map(
|
||||||
workspace_id=workspace_id,
|
workspace_id=workspace_id,
|
||||||
revision=revision,
|
revision=revision,
|
||||||
step_id=step_id,
|
step_id=step_id,
|
||||||
@@ -716,7 +762,7 @@ class WorkflowApi:
|
|||||||
output_map: dict[str, str],
|
output_map: dict[str, str],
|
||||||
merge: bool = False,
|
merge: bool = False,
|
||||||
) -> DraftWorkspaceResult:
|
) -> DraftWorkspaceResult:
|
||||||
return await self.drafts.set_workflow_output_map(
|
return await self._require_drafts().set_workflow_output_map(
|
||||||
workspace_id=workspace_id,
|
workspace_id=workspace_id,
|
||||||
revision=revision,
|
revision=revision,
|
||||||
output_map=output_map,
|
output_map=output_map,
|
||||||
@@ -730,7 +776,7 @@ class WorkflowApi:
|
|||||||
revision: int,
|
revision: int,
|
||||||
bindings: Sequence[InputBinding],
|
bindings: Sequence[InputBinding],
|
||||||
) -> DraftWorkspaceResult:
|
) -> DraftWorkspaceResult:
|
||||||
return await self.draft_authoring.set_workflow_output_bindings(
|
return await self._require_draft_authoring().set_workflow_output_bindings(
|
||||||
workspace_id=workspace_id,
|
workspace_id=workspace_id,
|
||||||
revision=revision,
|
revision=revision,
|
||||||
bindings=bindings,
|
bindings=bindings,
|
||||||
@@ -745,7 +791,7 @@ class WorkflowApi:
|
|||||||
source_path: str,
|
source_path: str,
|
||||||
target_path: str,
|
target_path: str,
|
||||||
) -> DraftWorkspaceResult:
|
) -> DraftWorkspaceResult:
|
||||||
return await self.draft_authoring.bind_draft(
|
return await self._require_draft_authoring().bind_draft(
|
||||||
workspace_id=workspace_id,
|
workspace_id=workspace_id,
|
||||||
revision=revision,
|
revision=revision,
|
||||||
step_id=step_id,
|
step_id=step_id,
|
||||||
@@ -770,7 +816,7 @@ class WorkflowApi:
|
|||||||
retry: int | None = None,
|
retry: int | None = None,
|
||||||
timeout_seconds: int | None = None,
|
timeout_seconds: int | None = None,
|
||||||
) -> DraftWorkspaceResult:
|
) -> DraftWorkspaceResult:
|
||||||
return await self.draft_authoring.add_step_from_capability(
|
return await self._require_draft_authoring().add_step_from_capability(
|
||||||
workspace_id=workspace_id,
|
workspace_id=workspace_id,
|
||||||
revision=revision,
|
revision=revision,
|
||||||
step_id=step_id,
|
step_id=step_id,
|
||||||
@@ -796,7 +842,7 @@ class WorkflowApi:
|
|||||||
incoming: RouteSource | None = None,
|
incoming: RouteSource | None = None,
|
||||||
routes: dict[str, str] | None = None,
|
routes: dict[str, str] | None = None,
|
||||||
) -> DraftWorkspaceResult:
|
) -> DraftWorkspaceResult:
|
||||||
return await self.draft_authoring.add_step(
|
return await self._require_draft_authoring().add_step(
|
||||||
workspace_id=workspace_id,
|
workspace_id=workspace_id,
|
||||||
revision=revision,
|
revision=revision,
|
||||||
step_id=step_id,
|
step_id=step_id,
|
||||||
@@ -813,7 +859,7 @@ class WorkflowApi:
|
|||||||
step_id: str,
|
step_id: str,
|
||||||
routes: dict[str, str],
|
routes: dict[str, str],
|
||||||
) -> DraftWorkspaceResult:
|
) -> DraftWorkspaceResult:
|
||||||
return await self.draft_authoring.branch_draft(
|
return await self._require_draft_authoring().branch_draft(
|
||||||
workspace_id=workspace_id,
|
workspace_id=workspace_id,
|
||||||
revision=revision,
|
revision=revision,
|
||||||
step_id=step_id,
|
step_id=step_id,
|
||||||
@@ -831,7 +877,7 @@ class WorkflowApi:
|
|||||||
refs = [
|
refs = [
|
||||||
RouteSource(step_id=b["step_id"], outcome=b["outcome"]) for b in branches
|
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,
|
workspace_id=workspace_id,
|
||||||
revision=revision,
|
revision=revision,
|
||||||
branches=refs,
|
branches=refs,
|
||||||
@@ -854,7 +900,7 @@ class WorkflowApi:
|
|||||||
error_message_source: Any | None = None,
|
error_message_source: Any | None = None,
|
||||||
title: str | None = None,
|
title: str | None = None,
|
||||||
) -> DraftWorkspaceResult:
|
) -> DraftWorkspaceResult:
|
||||||
return await self.draft_authoring.create_minimal_draft_workspace(
|
return await self._require_draft_authoring().create_minimal_draft_workspace(
|
||||||
workspace_id=workspace_id,
|
workspace_id=workspace_id,
|
||||||
name=name,
|
name=name,
|
||||||
capability_name=capability_name,
|
capability_name=capability_name,
|
||||||
@@ -877,7 +923,7 @@ class WorkflowApi:
|
|||||||
step_id: str,
|
step_id: str,
|
||||||
outcome: str,
|
outcome: str,
|
||||||
) -> DraftWorkspaceResult:
|
) -> DraftWorkspaceResult:
|
||||||
return await self.draft_authoring.remove_draft_route(
|
return await self._require_draft_authoring().remove_draft_route(
|
||||||
workspace_id=workspace_id,
|
workspace_id=workspace_id,
|
||||||
revision=revision,
|
revision=revision,
|
||||||
step_id=step_id,
|
step_id=step_id,
|
||||||
@@ -891,7 +937,7 @@ class WorkflowApi:
|
|||||||
revision: int,
|
revision: int,
|
||||||
step_id: str,
|
step_id: str,
|
||||||
) -> DraftWorkspaceResult:
|
) -> DraftWorkspaceResult:
|
||||||
return await self.draft_authoring.remove_draft_step(
|
return await self._require_draft_authoring().remove_draft_step(
|
||||||
workspace_id=workspace_id,
|
workspace_id=workspace_id,
|
||||||
revision=revision,
|
revision=revision,
|
||||||
step_id=step_id,
|
step_id=step_id,
|
||||||
@@ -906,7 +952,7 @@ class WorkflowApi:
|
|||||||
inputs: Sequence[str] = (),
|
inputs: Sequence[str] = (),
|
||||||
outputs: Sequence[str] = (),
|
outputs: Sequence[str] = (),
|
||||||
) -> DraftWorkspaceResult:
|
) -> DraftWorkspaceResult:
|
||||||
return await self.draft_authoring.remove_draft_binding(
|
return await self._require_draft_authoring().remove_draft_binding(
|
||||||
workspace_id=workspace_id,
|
workspace_id=workspace_id,
|
||||||
revision=revision,
|
revision=revision,
|
||||||
step_id=step_id,
|
step_id=step_id,
|
||||||
|
|||||||
+13
-4
@@ -18,16 +18,25 @@ class WorkflowStores:
|
|||||||
"""Protocol-neutral persistence dependencies for workflow APIs."""
|
"""Protocol-neutral persistence dependencies for workflow APIs."""
|
||||||
|
|
||||||
artifact_store: WorkflowArtifactStore
|
artifact_store: WorkflowArtifactStore
|
||||||
draft_workspace_store: DraftWorkspaceStore
|
draft_workspace_store: DraftWorkspaceStore | None
|
||||||
run_store: RunStore
|
run_store: RunStore
|
||||||
|
|
||||||
|
|
||||||
def file_workflow_stores(root: str | Path) -> WorkflowStores:
|
def file_workflow_stores(
|
||||||
"""Create process-local file-backed workflow stores under one root."""
|
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)
|
store_root = Path(root)
|
||||||
return WorkflowStores(
|
return WorkflowStores(
|
||||||
artifact_store=FileWorkflowArtifactStore(store_root),
|
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),
|
run_store=FileRunStore(store_root),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ from .models import (
|
|||||||
SaveArtifactResult,
|
SaveArtifactResult,
|
||||||
SaveDeploymentResult,
|
SaveDeploymentResult,
|
||||||
SourceDiagnosisResult,
|
SourceDiagnosisResult,
|
||||||
|
ValidateArtifactPlanResult,
|
||||||
ValidateDeploymentResult,
|
ValidateDeploymentResult,
|
||||||
ValidateDraftResult,
|
ValidateDraftResult,
|
||||||
WorkflowArtifactPayload,
|
WorkflowArtifactPayload,
|
||||||
@@ -459,6 +460,15 @@ class WorkflowArtifactSurface(Protocol):
|
|||||||
created_from_catalog_version: str | None = None,
|
created_from_catalog_version: str | None = None,
|
||||||
) -> SaveArtifactResult: ...
|
) -> 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):
|
class WorkflowDeploymentSurface(Protocol):
|
||||||
"""Deployment methods exposed by workflow frontends."""
|
"""Deployment methods exposed by workflow frontends."""
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ from .drafts import (
|
|||||||
patch_workflow_draft,
|
patch_workflow_draft,
|
||||||
validate_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 (
|
from .models import (
|
||||||
ArtifactKind,
|
ArtifactKind,
|
||||||
AvailableCapability,
|
AvailableCapability,
|
||||||
@@ -78,6 +78,7 @@ __all__ = [
|
|||||||
"WorkflowArtifact",
|
"WorkflowArtifact",
|
||||||
"WorkflowArtifactCatalogEntry",
|
"WorkflowArtifactCatalogEntry",
|
||||||
"WorkflowArtifactStore",
|
"WorkflowArtifactStore",
|
||||||
|
"WorkflowPlanValidationError",
|
||||||
"WorkflowCapabilityRef",
|
"WorkflowCapabilityRef",
|
||||||
"WorkflowDeployment",
|
"WorkflowDeployment",
|
||||||
"WorkflowDraftWorkspace",
|
"WorkflowDraftWorkspace",
|
||||||
|
|||||||
@@ -2,13 +2,20 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from collections.abc import Mapping
|
from collections.abc import Mapping
|
||||||
|
|
||||||
|
from pydantic import ValidationError
|
||||||
|
|
||||||
from wf_core import ReducerRef, Workflow
|
from wf_core import ReducerRef, Workflow
|
||||||
|
from wf_core.models.workflow_refs import WorkflowRef
|
||||||
from wf_platform import CapabilityRef, NodeSpecInventory, hash_json_schema
|
from wf_platform import CapabilityRef, NodeSpecInventory, hash_json_schema
|
||||||
|
|
||||||
from .models import ArtifactKind, JsonObject, RequiredCapability, WorkflowArtifact
|
from .models import ArtifactKind, JsonObject, RequiredCapability, WorkflowArtifact
|
||||||
from .references import normalize_plan_node_refs
|
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(
|
def create_workflow_artifact_from_plan(
|
||||||
*,
|
*,
|
||||||
artifact_id: str,
|
artifact_id: str,
|
||||||
@@ -47,6 +54,7 @@ def create_workflow_artifact_from_plan(
|
|||||||
outcomes=outcomes,
|
outcomes=outcomes,
|
||||||
plan=normalized_plan,
|
plan=normalized_plan,
|
||||||
required_capabilities=list(required.values()),
|
required_capabilities=list(required.values()),
|
||||||
|
workflow_dependencies=_workflow_dependencies_from_plan(normalized_plan),
|
||||||
created_from_catalog_version=created_from_catalog_version,
|
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:
|
def _required_object_field(plan: JsonObject, field_name: str) -> JsonObject:
|
||||||
value = plan.get(field_name)
|
value = plan.get(field_name)
|
||||||
if not isinstance(value, dict):
|
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
|
return value
|
||||||
|
|
||||||
|
|
||||||
def _validate_workflow_plan(plan: JsonObject) -> None:
|
def _validate_workflow_plan(plan: JsonObject) -> None:
|
||||||
try:
|
try:
|
||||||
workflow = Workflow.model_validate(plan)
|
workflow = Workflow.model_validate(plan)
|
||||||
except Exception as exc:
|
except ValidationError as exc:
|
||||||
raise ValueError(f"invalid workflow plan: {exc}") from exc
|
raise WorkflowPlanValidationError(f"invalid workflow plan: {exc}") from exc
|
||||||
|
|
||||||
node_ids = {node.id for node in workflow.nodes}
|
node_ids = {node.id for node in workflow.nodes}
|
||||||
if workflow.start not in node_ids:
|
if workflow.start not in node_ids:
|
||||||
raise ValueError(
|
raise WorkflowPlanValidationError(
|
||||||
f"invalid workflow plan: start node {workflow.start!r} does not exist"
|
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)
|
edge_sources = set(node_ids)
|
||||||
for edge in workflow.edges:
|
for edge in workflow.edges:
|
||||||
if edge.from_ not in edge_sources:
|
if edge.from_ not in edge_sources:
|
||||||
raise ValueError(
|
raise WorkflowPlanValidationError(
|
||||||
f"invalid workflow plan: edge source {edge.from_!r} does not exist"
|
f"invalid workflow plan: edge source {edge.from_!r} does not exist"
|
||||||
)
|
)
|
||||||
if edge.to not in node_ids and edge.to != "__end__":
|
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"
|
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]:
|
def _required_reducers_from_plan(plan: JsonObject) -> dict[str, RequiredCapability]:
|
||||||
"""Infer reducer dependencies from declared state fields in one plan."""
|
"""Infer reducer dependencies from declared state fields in one plan."""
|
||||||
state_schema = plan.get("state_schema")
|
state_schema = plan.get("state_schema")
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
from .core import WorkflowBuilder
|
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
|
from .refs import BranchRef, BranchResult, DecisionResult, HandleResult, StepRef
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
@@ -8,4 +9,6 @@ __all__ = [
|
|||||||
"HandleResult",
|
"HandleResult",
|
||||||
"StepRef",
|
"StepRef",
|
||||||
"WorkflowBuilder",
|
"WorkflowBuilder",
|
||||||
|
"auto_input_map_from_schema",
|
||||||
|
"auto_output_map_from_schema",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ from wf_core import (
|
|||||||
ForeachItemErrorPolicy,
|
ForeachItemErrorPolicy,
|
||||||
ForeachNode,
|
ForeachNode,
|
||||||
InterruptNode,
|
InterruptNode,
|
||||||
|
NodeDef,
|
||||||
NodeHandler,
|
NodeHandler,
|
||||||
NodeUse,
|
NodeUse,
|
||||||
PreparedSubgraph,
|
PreparedSubgraph,
|
||||||
@@ -22,6 +23,7 @@ from wf_core import (
|
|||||||
SchemaRef,
|
SchemaRef,
|
||||||
StateSchema,
|
StateSchema,
|
||||||
SubgraphNode,
|
SubgraphNode,
|
||||||
|
ValidationReport,
|
||||||
Workflow,
|
Workflow,
|
||||||
WorkflowRef,
|
WorkflowRef,
|
||||||
execute_workflow,
|
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 BinaryCondition, ExistsCondition, PathOperand
|
||||||
from wf_core.models.conditions import Condition as CoreCondition
|
from wf_core.models.conditions import Condition as CoreCondition
|
||||||
from wf_core.models.steps import (
|
from wf_core.models.steps import (
|
||||||
|
InputBinding,
|
||||||
InputPathBinding,
|
InputPathBinding,
|
||||||
InputValueBinding,
|
InputValueBinding,
|
||||||
OutputBinding,
|
OutputBinding,
|
||||||
@@ -54,7 +57,9 @@ from .mapping import (
|
|||||||
OutputBindingArg,
|
OutputBindingArg,
|
||||||
StepInputBindingArg,
|
StepInputBindingArg,
|
||||||
auto_input_map,
|
auto_input_map,
|
||||||
|
auto_input_map_from_schema,
|
||||||
auto_output_map,
|
auto_output_map,
|
||||||
|
auto_output_map_from_schema,
|
||||||
coerce_path,
|
coerce_path,
|
||||||
normalize_input_mapping,
|
normalize_input_mapping,
|
||||||
normalize_input_values,
|
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(
|
def _reject_mixed_binding_styles(
|
||||||
*,
|
*,
|
||||||
input: object | None,
|
input: object | None,
|
||||||
@@ -213,6 +223,8 @@ class WorkflowBuilder:
|
|||||||
node_specs: dict[str, NodeSpec[Any, Any]] = field(default_factory=dict)
|
node_specs: dict[str, NodeSpec[Any, Any]] = field(default_factory=dict)
|
||||||
nodes: list[Step] = field(default_factory=list)
|
nodes: list[Step] = field(default_factory=list)
|
||||||
edges: list[Edge] = 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(
|
prepared_subgraphs: dict[str, PreparedSubgraph[NodeHandler]] = field(
|
||||||
default_factory=dict
|
default_factory=dict
|
||||||
)
|
)
|
||||||
@@ -392,6 +404,134 @@ class WorkflowBuilder:
|
|||||||
self.nodes.append(node)
|
self.nodes.append(node)
|
||||||
return 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(
|
def subgraph(
|
||||||
self,
|
self,
|
||||||
*,
|
*,
|
||||||
@@ -869,21 +1009,45 @@ class WorkflowBuilder:
|
|||||||
)
|
)
|
||||||
return self.match(value, cases, id=id, default=default)
|
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:
|
def compile(self) -> Workflow:
|
||||||
if self.start is None:
|
if self.start is None:
|
||||||
raise WorkflowExecutionError(
|
raise WorkflowExecutionError(
|
||||||
"workflow builder requires an explicit start; "
|
"workflow builder requires an explicit start; "
|
||||||
"call set_entry_point(...) or pass start=..."
|
"call set_entry_point(...) or pass start=..."
|
||||||
)
|
)
|
||||||
node_defs = [spec.to_node_def() for spec in self.node_specs.values()]
|
return self._build_workflow(start=self.start)
|
||||||
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,
|
|
||||||
)
|
|
||||||
|
|||||||
@@ -143,11 +143,25 @@ def auto_input_map(
|
|||||||
state_schema: StateSchema,
|
state_schema: StateSchema,
|
||||||
) -> dict[str, str]:
|
) -> dict[str, str]:
|
||||||
"""Map node input fields from state first, then workflow input."""
|
"""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 {
|
return {
|
||||||
_auto_source_path(
|
_auto_source_path(
|
||||||
field, input_schema=input_schema, state_schema=state_schema
|
field, input_schema=input_schema, state_schema=state_schema
|
||||||
): field
|
): 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,
|
state_schema: StateSchema,
|
||||||
) -> dict[str, str]:
|
) -> dict[str, str]:
|
||||||
"""Map node output fields back into matching state fields."""
|
"""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()
|
state_fields = state_schema.field_map()
|
||||||
return {
|
return {
|
||||||
field: f"state.{field}"
|
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
|
if field in state_fields
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -97,7 +97,9 @@ def build_workflow_server_from_workflow_config(
|
|||||||
"""Build the local server without importing the server runtime at CLI startup."""
|
"""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
|
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(
|
def load_cli_context(
|
||||||
@@ -150,7 +152,8 @@ def load_cli_context(
|
|||||||
return CliContext(
|
return CliContext(
|
||||||
config_path=resolved_config_path,
|
config_path=resolved_config_path,
|
||||||
service=service,
|
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)),
|
source_admin=WorkflowSourceAdminApi(context_from_service(service)),
|
||||||
admin=WorkflowAdminApi(
|
admin=WorkflowAdminApi(
|
||||||
connections=service.connection_service,
|
connections=service.connection_service,
|
||||||
|
|||||||
@@ -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",
|
||||||
|
]
|
||||||
@@ -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,
|
||||||
|
)
|
||||||
@@ -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}"
|
||||||
|
),
|
||||||
|
)
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
"""Small, inert renderers shared by the public workflow-client snapshots.
|
||||||
|
|
||||||
|
Representations are a debugging aid, not another client operation. This
|
||||||
|
module deliberately accepts already-loaded values and never knows about the
|
||||||
|
workflow transport port. The same bounded projector is used for plain and
|
||||||
|
HTML representations so notebooks cannot accidentally expose an unbounded
|
||||||
|
trace, output, or credential-shaped value.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import html
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
from collections.abc import Mapping, Sequence
|
||||||
|
from itertools import islice
|
||||||
|
|
||||||
|
_SENSITIVE_KEYS = {
|
||||||
|
"authorization",
|
||||||
|
"cookie",
|
||||||
|
"set_cookie",
|
||||||
|
"token",
|
||||||
|
"access_token",
|
||||||
|
"refresh_token",
|
||||||
|
"secret",
|
||||||
|
"password",
|
||||||
|
"api_key",
|
||||||
|
}
|
||||||
|
_MAX_DEPTH = 2
|
||||||
|
_MAX_ITEMS = 8
|
||||||
|
_MAX_STRING = 160
|
||||||
|
_MAX_RENDERED = 1_200
|
||||||
|
|
||||||
|
|
||||||
|
def _canonical_key(key: object) -> str:
|
||||||
|
"""Normalize snake/kebab/camel spellings to the shared evidence keys."""
|
||||||
|
value = re.sub(r"([a-z0-9])([A-Z])", r"\1_\2", str(key))
|
||||||
|
return value.replace(" ", "_").replace("-", "_").lower()
|
||||||
|
|
||||||
|
|
||||||
|
def _secret_key(key: object) -> bool:
|
||||||
|
# Match the evidence policy's exact key set; substring matching would
|
||||||
|
# incorrectly redact harmless fields such as ``tokenCount`` or ``secretary``.
|
||||||
|
return _canonical_key(key) in _SENSITIVE_KEYS
|
||||||
|
|
||||||
|
|
||||||
|
def bounded_value(value: object, *, depth: int = 0) -> object:
|
||||||
|
"""Project loaded JSON-like data into a small, secret-safe preview."""
|
||||||
|
if depth >= _MAX_DEPTH:
|
||||||
|
return "[truncated]"
|
||||||
|
if isinstance(value, str):
|
||||||
|
return value if len(value) <= _MAX_STRING else value[:_MAX_STRING] + "…"
|
||||||
|
if value is None or isinstance(value, bool | int | float):
|
||||||
|
return value
|
||||||
|
if isinstance(value, Mapping):
|
||||||
|
iterator = iter(value.items())
|
||||||
|
items = list(islice(iterator, _MAX_ITEMS + 1))
|
||||||
|
preview = {
|
||||||
|
str(key): "[redacted]"
|
||||||
|
if _secret_key(key)
|
||||||
|
else bounded_value(item, depth=depth + 1)
|
||||||
|
for key, item in items[:_MAX_ITEMS]
|
||||||
|
}
|
||||||
|
if len(items) > _MAX_ITEMS:
|
||||||
|
preview["…"] = "more entries"
|
||||||
|
return preview
|
||||||
|
if isinstance(value, Sequence) and not isinstance(value, str | bytes | bytearray):
|
||||||
|
iterator = iter(value)
|
||||||
|
items = list(islice(iterator, _MAX_ITEMS + 1))
|
||||||
|
preview = [bounded_value(item, depth=depth + 1) for item in items[:_MAX_ITEMS]]
|
||||||
|
if len(items) > _MAX_ITEMS:
|
||||||
|
preview.append("… more items")
|
||||||
|
return preview
|
||||||
|
if isinstance(value, Sequence):
|
||||||
|
return "[truncated sequence]"
|
||||||
|
if hasattr(value, "__iter__"):
|
||||||
|
iterator = iter(value) # type: ignore[call-overload]
|
||||||
|
items = list(islice(iterator, _MAX_ITEMS + 1))
|
||||||
|
preview = [bounded_value(item, depth=depth + 1) for item in items[:_MAX_ITEMS]]
|
||||||
|
if len(items) > _MAX_ITEMS:
|
||||||
|
preview.append("… more items")
|
||||||
|
return preview
|
||||||
|
rendered = repr(value)
|
||||||
|
return rendered if len(rendered) <= _MAX_STRING else rendered[:_MAX_STRING] + "…"
|
||||||
|
|
||||||
|
|
||||||
|
def preview(value: object) -> str:
|
||||||
|
"""Render a bounded value without allowing an object's repr to grow freely."""
|
||||||
|
try:
|
||||||
|
rendered = json.dumps(bounded_value(value), sort_keys=True, default=str)
|
||||||
|
except TypeError, ValueError:
|
||||||
|
rendered = str(bounded_value(value))
|
||||||
|
return rendered[:_MAX_RENDERED] + ("…" if len(rendered) > _MAX_RENDERED else "")
|
||||||
|
|
||||||
|
|
||||||
|
def short_repr(type_name: str, **fields: object) -> str:
|
||||||
|
"""Build a compact Python repr from already-loaded field values."""
|
||||||
|
body = ", ".join(f"{name}={preview(value)}" for name, value in fields.items())
|
||||||
|
rendered = f"{type_name}({body})"
|
||||||
|
return rendered[:_MAX_RENDERED] + ("…" if len(rendered) > _MAX_RENDERED else "")
|
||||||
|
|
||||||
|
|
||||||
|
def html_repr(type_name: str, **fields: object) -> str:
|
||||||
|
"""Build a bounded HTML table suitable for IPython rich display."""
|
||||||
|
|
||||||
|
def html_preview(value: object) -> str:
|
||||||
|
rendered = preview(value)
|
||||||
|
return rendered[:400] + ("…" if len(rendered) > 400 else "")
|
||||||
|
|
||||||
|
rows = "".join(
|
||||||
|
'<tr><th scope="row">'
|
||||||
|
+ html.escape(name)
|
||||||
|
+ "</th><td><code>"
|
||||||
|
+ html.escape(html_preview(value))
|
||||||
|
+ "</code></td></tr>"
|
||||||
|
for name, value in fields.items()
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
'<div class="wf-client-repr"><strong>'
|
||||||
|
+ html.escape(type_name)
|
||||||
|
+ "</strong><table><tbody>"
|
||||||
|
+ rows
|
||||||
|
+ "</tbody></table></div>"
|
||||||
|
)
|
||||||
@@ -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",
|
||||||
|
)
|
||||||
@@ -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)},
|
||||||
|
}
|
||||||
@@ -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),
|
||||||
|
)
|
||||||
@@ -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))
|
||||||
|
)
|
||||||
@@ -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)
|
||||||
@@ -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."""
|
||||||
@@ -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: ...
|
||||||
@@ -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,
|
||||||
|
)
|
||||||
@@ -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,
|
||||||
|
)
|
||||||
@@ -22,6 +22,7 @@ def __getattr__(name: str) -> object:
|
|||||||
return generate_manifest
|
return generate_manifest
|
||||||
raise AttributeError(name)
|
raise AttributeError(name)
|
||||||
|
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"ContractManifest",
|
"ContractManifest",
|
||||||
"JsonSchema",
|
"JsonSchema",
|
||||||
|
|||||||
@@ -15,7 +15,9 @@ from .model import ManifestError
|
|||||||
|
|
||||||
|
|
||||||
def _parser() -> argparse.ArgumentParser:
|
def _parser() -> argparse.ArgumentParser:
|
||||||
parser = argparse.ArgumentParser(description="Manage the checked workflow API contract manifest.")
|
parser = argparse.ArgumentParser(
|
||||||
|
description="Manage the checked workflow API contract manifest."
|
||||||
|
)
|
||||||
parser.add_argument("command", choices=("write", "check"))
|
parser.add_argument("command", choices=("write", "check"))
|
||||||
return parser
|
return parser
|
||||||
|
|
||||||
|
|||||||
@@ -14,8 +14,15 @@ from .normalize import manifest_from_openrpc
|
|||||||
def generate_manifest() -> ContractManifest:
|
def generate_manifest() -> ContractManifest:
|
||||||
"""Compose the real server against an isolated store and normalize OpenRPC."""
|
"""Compose the real server against an isolated store and normalize OpenRPC."""
|
||||||
with TemporaryDirectory(prefix="wf-contract-manifest-") as directory:
|
with TemporaryDirectory(prefix="wf-contract-manifest-") as directory:
|
||||||
server = build_local_static_workflow_server(Path(directory) / "store")
|
# The checked contract describes the complete opt-in API. Product
|
||||||
document = cast(dict[str, object], create_rpc_app(server).get_openrpc())
|
# 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
|
# Normalization deliberately drops framework metadata that could carry
|
||||||
# process-local paths or transport details.
|
# process-local paths or transport details.
|
||||||
return manifest_from_openrpc(document)
|
return manifest_from_openrpc(document)
|
||||||
|
|||||||
@@ -163,7 +163,9 @@ def build_service_from_config(config: BrokerConfig) -> WfMcpService:
|
|||||||
"""Create a broker service with SDK adapters for configured connections."""
|
"""Create a broker service with SDK adapters for configured connections."""
|
||||||
runtime_factory = PersistentSessionFactory()
|
runtime_factory = PersistentSessionFactory()
|
||||||
store_roots = config.store_roots
|
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
|
# Keep FileStore as the compatibility facade on WfMcpService.store while
|
||||||
# focused services receive role-specific stores.
|
# focused services receive role-specific stores.
|
||||||
auth_store = FileAuthStore(store_roots.auth_root)
|
auth_store = FileAuthStore(store_roots.auth_root)
|
||||||
|
|||||||
@@ -64,7 +64,8 @@ def workflow_server_from_service(
|
|||||||
raise ValueError("MCP-backed WorkflowServer requires workflow stores")
|
raise ValueError("MCP-backed WorkflowServer requires workflow stores")
|
||||||
|
|
||||||
context = context_from_service(service)
|
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(
|
source_diagnostics = SourceDiagnosticsProvider(
|
||||||
connection_lookup=service.connections.get,
|
connection_lookup=service.connections.get,
|
||||||
auth_store=service.auth_store or service.store,
|
auth_store=service.auth_store or service.store,
|
||||||
|
|||||||
@@ -13,14 +13,16 @@ if TYPE_CHECKING:
|
|||||||
class WorkflowSurfaceHandlers(WorkflowApi):
|
class WorkflowSurfaceHandlers(WorkflowApi):
|
||||||
"""Compatibility wrapper for old wf_mcp.workflow_surface imports.
|
"""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
|
directly. This shim keeps tests and legacy broker artifact tools working
|
||||||
for legacy callers.
|
for legacy callers.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, service: WfMcpService) -> None:
|
def __init__(self, service: WfMcpService) -> None:
|
||||||
self.service = service
|
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"]
|
__all__ = ["WorkflowSurfaceHandlers"]
|
||||||
|
|||||||
@@ -71,7 +71,9 @@ from .models import (
|
|||||||
|
|
||||||
def register_workflow_tools(server: FastMCP[Any], service: WfMcpService) -> None:
|
def register_workflow_tools(server: FastMCP[Any], service: WfMcpService) -> None:
|
||||||
"""Register stable workflow tools on the public MCP server surface."""
|
"""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(
|
@server.tool(
|
||||||
name="wf.workflow.list_artifacts",
|
name="wf.workflow.list_artifacts",
|
||||||
|
|||||||
@@ -68,7 +68,11 @@ def serve(
|
|||||||
workflow_config = load_workflow_config(config)
|
workflow_config = load_workflow_config(config)
|
||||||
store = workflow_config.server.workflow_store
|
store = workflow_config.server.workflow_store
|
||||||
if server is None and store_root is None:
|
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:
|
elif server is None:
|
||||||
has_mcp_sources = any(
|
has_mcp_sources = any(
|
||||||
getattr(source, "kind", None) == "mcp"
|
getattr(source, "kind", None) == "mcp"
|
||||||
@@ -101,9 +105,11 @@ def serve(
|
|||||||
raise typer.BadParameter(
|
raise typer.BadParameter(
|
||||||
"--store-root is required when --config is not supplied"
|
"--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(
|
uvicorn.run(
|
||||||
rpc_app,
|
rpc_app,
|
||||||
host=resolved_host or "127.0.0.1",
|
host=resolved_host or "127.0.0.1",
|
||||||
|
|||||||
@@ -53,11 +53,15 @@ def _build_mcp_workflow_server_from_legacy_config(path: Path) -> WorkflowServer:
|
|||||||
|
|
||||||
def build_workflow_server_from_workflow_config(
|
def build_workflow_server_from_workflow_config(
|
||||||
config: WorkflowConfigFile,
|
config: WorkflowConfigFile,
|
||||||
|
*,
|
||||||
|
drafts: bool = False,
|
||||||
) -> WorkflowServer:
|
) -> WorkflowServer:
|
||||||
"""Build a WorkflowServer from neutral workflow config.
|
"""Build a WorkflowServer from neutral workflow config.
|
||||||
|
|
||||||
Local/static configs use built-in sources. Configs with ``kind: "mcp"``
|
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):
|
if _has_mcp_sources(config):
|
||||||
return _build_mcp_workflow_server_from_workflow_config(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")
|
raise ValueError("wf-rpc-server currently requires filesystem store")
|
||||||
return build_local_static_workflow_server(
|
return build_local_static_workflow_server(
|
||||||
store.root,
|
store.root,
|
||||||
|
drafts=drafts,
|
||||||
extra_sources=collect_static_sources(_static_source_providers(config)),
|
extra_sources=collect_static_sources(_static_source_providers(config)),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -295,10 +295,11 @@ def build_local_static_workflow_server(
|
|||||||
root: str | Path,
|
root: str | Path,
|
||||||
*,
|
*,
|
||||||
extra_sources: Mapping[str, CapabilitySource] | None = None,
|
extra_sources: Mapping[str, CapabilitySource] | None = None,
|
||||||
|
drafts: bool = False,
|
||||||
) -> WorkflowServer:
|
) -> 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))
|
config = WorkflowServerConfig(store_root=Path(root))
|
||||||
stores = file_workflow_stores(config.store_root)
|
stores = file_workflow_stores(config.store_root, drafts=drafts)
|
||||||
events = InMemoryWorkflowEventRecorder()
|
events = InMemoryWorkflowEventRecorder()
|
||||||
sources = builtin_sources()
|
sources = builtin_sources()
|
||||||
if extra_sources:
|
if extra_sources:
|
||||||
@@ -320,7 +321,7 @@ def build_local_static_workflow_server(
|
|||||||
runtime=runtime,
|
runtime=runtime,
|
||||||
live_sources=None,
|
live_sources=None,
|
||||||
)
|
)
|
||||||
api = durable_workflow_api(context)
|
api = durable_workflow_api(context, drafts=drafts)
|
||||||
source_admin = WorkflowSourceAdminApi(context)
|
source_admin = WorkflowSourceAdminApi(context)
|
||||||
admin = WorkflowAdminApi(
|
admin = WorkflowAdminApi(
|
||||||
connections=EmptyWorkflowConnectionProvider(),
|
connections=EmptyWorkflowConnectionProvider(),
|
||||||
|
|||||||
@@ -24,7 +24,12 @@ from .methods.source_registry import (
|
|||||||
from .methods.sources import register_methods as register_source_methods
|
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.
|
"""Build a JSON-RPC HTTP app over an existing WorkflowServer.
|
||||||
|
|
||||||
Transport code owns only JSON-RPC envelope handling. Workflow semantics stay
|
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_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_artifact_methods(entrypoint, server)
|
||||||
register_deployment_methods(entrypoint, server)
|
register_deployment_methods(entrypoint, server)
|
||||||
register_run_methods(entrypoint, server)
|
register_run_methods(entrypoint, server)
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ from wf_api.models import (
|
|||||||
DeleteArtifactResult,
|
DeleteArtifactResult,
|
||||||
ListArtifactsResult,
|
ListArtifactsResult,
|
||||||
SaveArtifactResult,
|
SaveArtifactResult,
|
||||||
|
ValidateArtifactPlanResult,
|
||||||
WorkflowArtifactPayload,
|
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(
|
async def create_artifact_from_plan(
|
||||||
self: RpcCaller,
|
self: RpcCaller,
|
||||||
*,
|
*,
|
||||||
|
|||||||
@@ -7,6 +7,31 @@ from uuid import uuid4
|
|||||||
import httpx
|
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):
|
class RpcCaller(Protocol):
|
||||||
"""Transport primitive required by domain RPC client mixins."""
|
"""Transport primitive required by domain RPC client mixins."""
|
||||||
|
|
||||||
@@ -26,9 +51,10 @@ class RpcClientTransport:
|
|||||||
http_client: httpx.AsyncClient | None = None
|
http_client: httpx.AsyncClient | None = None
|
||||||
|
|
||||||
async def _call(self, method: str, params: dict[str, Any]) -> dict[str, Any]:
|
async def _call(self, method: str, params: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
request_id = uuid4().hex
|
||||||
request = {
|
request = {
|
||||||
"jsonrpc": "2.0",
|
"jsonrpc": "2.0",
|
||||||
"id": uuid4().hex,
|
"id": request_id,
|
||||||
"method": method,
|
"method": method,
|
||||||
"params": params,
|
"params": params,
|
||||||
}
|
}
|
||||||
@@ -39,13 +65,23 @@ class RpcClientTransport:
|
|||||||
response = await self.http_client.post(self.url, json=request)
|
response = await self.http_client.post(self.url, json=request)
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
payload = response.json()
|
payload = response.json()
|
||||||
|
if not isinstance(payload, dict):
|
||||||
|
raise RuntimeError("JSON-RPC response must be an object")
|
||||||
|
if 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:
|
if "error" in payload:
|
||||||
error = payload["error"]
|
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")
|
message = error.get("message", "JSON-RPC error")
|
||||||
data = error.get("data")
|
if not isinstance(message, str):
|
||||||
if isinstance(data, dict) and data.get("message"):
|
message = "JSON-RPC error"
|
||||||
message = f"{message}: {data['message']}"
|
raise RpcProtocolError(code, message, error.get("data"))
|
||||||
raise RuntimeError(message)
|
|
||||||
result = payload.get("result")
|
result = payload.get("result")
|
||||||
if not isinstance(result, dict):
|
if not isinstance(result, dict):
|
||||||
raise RuntimeError("JSON-RPC response result must be an object")
|
raise RuntimeError("JSON-RPC response result must be an object")
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ from wf_api.models import (
|
|||||||
DeleteArtifactResult,
|
DeleteArtifactResult,
|
||||||
ListArtifactsResult,
|
ListArtifactsResult,
|
||||||
SaveArtifactResult,
|
SaveArtifactResult,
|
||||||
|
ValidateArtifactPlanResult,
|
||||||
WorkflowArtifactPayload,
|
WorkflowArtifactPayload,
|
||||||
)
|
)
|
||||||
from wf_server import WorkflowServer
|
from wf_server import WorkflowServer
|
||||||
@@ -21,6 +22,7 @@ from ..models import (
|
|||||||
InspectArtifactParams,
|
InspectArtifactParams,
|
||||||
ListArtifactsParams,
|
ListArtifactsParams,
|
||||||
SaveArtifactParams,
|
SaveArtifactParams,
|
||||||
|
ValidateArtifactPlanParams,
|
||||||
)
|
)
|
||||||
from ..params import RpcParams
|
from ..params import RpcParams
|
||||||
|
|
||||||
@@ -63,6 +65,22 @@ def register_methods(
|
|||||||
except (ValueError, KeyError, LookupError, FileNotFoundError) as exc:
|
except (ValueError, KeyError, LookupError, FileNotFoundError) as exc:
|
||||||
raise_workflow_rpc_error(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])
|
@entrypoint.method(name="workflow.artifacts.list", errors=[WorkflowRpcError])
|
||||||
async def workflow_artifacts_list(
|
async def workflow_artifacts_list(
|
||||||
params: ListArtifactsParams = RpcParams(),
|
params: ListArtifactsParams = RpcParams(),
|
||||||
|
|||||||
@@ -120,6 +120,13 @@ class SaveArtifactParams(RpcParamsModel):
|
|||||||
artifact: dict[str, Any]
|
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):
|
class SaveDeploymentParams(RpcParamsModel):
|
||||||
deployment: dict[str, Any]
|
deployment: dict[str, Any]
|
||||||
|
|
||||||
|
|||||||
@@ -111,6 +111,34 @@ def test_create_workflow_artifact_from_plan_rewrites_bound_node_specs() -> None:
|
|||||||
assert str(required.observed_concrete_source) == "demo.personal"
|
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:
|
def test_create_workflow_artifact_from_plan_snapshots_observed_node_spec() -> None:
|
||||||
plan = _plan()
|
plan = _plan()
|
||||||
_set_first_node_ref(plan, "demo.personal.echo_tool")
|
_set_first_node_ref(plan, "demo.personal.echo_tool")
|
||||||
|
|||||||
@@ -21,7 +21,15 @@ from wf_authoring import (
|
|||||||
state_path,
|
state_path,
|
||||||
)
|
)
|
||||||
from wf_authoring.builder.mapping import normalize_input_mapping
|
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 (
|
from wf_core.models.steps import (
|
||||||
InputExpressionBinding,
|
InputExpressionBinding,
|
||||||
InputPathBinding,
|
InputPathBinding,
|
||||||
@@ -31,6 +39,163 @@ from wf_core.paths import GraphSourcePath, LocalPath, StatePath
|
|||||||
from wf_platform import CapabilityRef
|
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:
|
def test_builder_auto_binds_matching_node_inputs_and_outputs_to_state() -> None:
|
||||||
builder = WorkflowBuilder(
|
builder = WorkflowBuilder(
|
||||||
name="auto_bind_demo",
|
name="auto_bind_demo",
|
||||||
|
|||||||
@@ -256,9 +256,7 @@ async def test_lda_report_workflow_artifact_interrupt_resume_path(
|
|||||||
"approved",
|
"approved",
|
||||||
"selected_issue_ids",
|
"selected_issue_ids",
|
||||||
}
|
}
|
||||||
proposed_ids = [
|
proposed_ids = [issue["id"] for issue in interrupt["payload"]["proposed_issues"]]
|
||||||
issue["id"] for issue in interrupt["payload"]["proposed_issues"]
|
|
||||||
]
|
|
||||||
assert proposed_ids
|
assert proposed_ids
|
||||||
started_run_id = started["run_id"]
|
started_run_id = started["run_id"]
|
||||||
assert isinstance(started_run_id, str)
|
assert isinstance(started_run_id, str)
|
||||||
|
|||||||
@@ -131,7 +131,7 @@ def _artifact_api(
|
|||||||
)
|
)
|
||||||
service.register_specs("demo.personal", echo_tool)
|
service.register_specs("demo.personal", echo_tool)
|
||||||
context = context_from_service(service)
|
context = context_from_service(service)
|
||||||
return WorkflowArtifactApi(context), service
|
return WorkflowArtifactApi(context, drafts=True), service
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@@ -185,6 +185,169 @@ async def test_create_artifact_from_plan_saves_with_observed_node_specs(
|
|||||||
assert saved.id == "echo"
|
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
|
@pytest.mark.asyncio
|
||||||
async def test_create_artifact_from_workspace_suggests_exact_available_source_binding(
|
async def test_create_artifact_from_workspace_suggests_exact_available_source_binding(
|
||||||
tmp_path: Path,
|
tmp_path: Path,
|
||||||
@@ -349,7 +512,7 @@ def _api(root: Path) -> WorkflowApi:
|
|||||||
artifact_store=FileWorkflowArtifactStore(root),
|
artifact_store=FileWorkflowArtifactStore(root),
|
||||||
draft_workspace_store=FileDraftWorkspaceStore(mcp_root),
|
draft_workspace_store=FileDraftWorkspaceStore(mcp_root),
|
||||||
)
|
)
|
||||||
return WorkflowApi(context_from_service(service))
|
return WorkflowApi(context_from_service(service), drafts=True)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ def _capability_api(
|
|||||||
)
|
)
|
||||||
service.register_specs("demo.personal", failing_tool)
|
service.register_specs("demo.personal", failing_tool)
|
||||||
context = context_from_service(service)
|
context = context_from_service(service)
|
||||||
return WorkflowCapabilityApi(context), service
|
return WorkflowCapabilityApi(context, drafts=True), service
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@@ -226,6 +226,7 @@ async def test_create_draft_workspace_from_capability(tmp_path: Path) -> None:
|
|||||||
assert "next_actions" in result
|
assert "next_actions" in result
|
||||||
assert result["wrapper_hints"]["capability_name"] == "demo.personal.echo_tool"
|
assert result["wrapper_hints"]["capability_name"] == "demo.personal.echo_tool"
|
||||||
|
|
||||||
|
assert api.drafts is not None
|
||||||
fetched = await api.drafts.get_draft_workspace(
|
fetched = await api.drafts.get_draft_workspace(
|
||||||
workspace_id="echo_ws", include_draft=True
|
workspace_id="echo_ws", include_draft=True
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -42,12 +42,13 @@ def _composite_concat_draft() -> dict[str, object]:
|
|||||||
async def test_composite_concat_runs_through_the_platform_registry(
|
async def test_composite_concat_runs_through_the_platform_registry(
|
||||||
tmp_path: Path,
|
tmp_path: Path,
|
||||||
) -> None:
|
) -> None:
|
||||||
server = build_local_static_workflow_server(tmp_path / "store")
|
server = build_local_static_workflow_server(tmp_path / "store", drafts=True)
|
||||||
await server.api.create_draft_workspace(
|
await server.api.create_draft_workspace(
|
||||||
workspace_id="composite_concat",
|
workspace_id="composite_concat",
|
||||||
draft=_composite_concat_draft(),
|
draft=_composite_concat_draft(),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
assert server.api.draft_authoring is not None
|
||||||
authored = await server.api.draft_authoring.set_step_input_bindings(
|
authored = await server.api.draft_authoring.set_step_input_bindings(
|
||||||
workspace_id="composite_concat",
|
workspace_id="composite_concat",
|
||||||
revision=1,
|
revision=1,
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ def _api(root: Path) -> WorkflowApi:
|
|||||||
ConnectionConfig(id="demo.personal", server="demo", account="personal")
|
ConnectionConfig(id="demo.personal", server="demo", account="personal")
|
||||||
)
|
)
|
||||||
service.register_specs("demo.personal", echo_tool)
|
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:
|
def test_workflow_api_composes_domain_services(tmp_path: Path) -> None:
|
||||||
|
|||||||
@@ -171,7 +171,7 @@ async def test_inspect_draft_authoring_contract_projects_selected_capability(
|
|||||||
"properties": {"echoed": {"type": "string"}},
|
"properties": {"echoed": {"type": "string"}},
|
||||||
}
|
}
|
||||||
await draft_api.create_draft_workspace(workspace_id="authoring", draft=draft)
|
await draft_api.create_draft_workspace(workspace_id="authoring", draft=draft)
|
||||||
api = WorkflowApi(authoring.context)
|
api = WorkflowApi(authoring.context, drafts=True)
|
||||||
|
|
||||||
inventory = _authoring_inventory(
|
inventory = _authoring_inventory(
|
||||||
await api.inspect_draft_authoring_contract(
|
await api.inspect_draft_authoring_contract(
|
||||||
@@ -219,7 +219,7 @@ async def test_inspect_draft_authoring_contract_tolerates_invalid_workflow_schem
|
|||||||
"properties": {"echoed": {"type": "string"}},
|
"properties": {"echoed": {"type": "string"}},
|
||||||
}
|
}
|
||||||
await draft_api.create_draft_workspace(workspace_id="authoring", draft=draft)
|
await draft_api.create_draft_workspace(workspace_id="authoring", draft=draft)
|
||||||
api = WorkflowApi(authoring.context)
|
api = WorkflowApi(authoring.context, drafts=True)
|
||||||
|
|
||||||
inventory = _authoring_inventory(
|
inventory = _authoring_inventory(
|
||||||
await api.inspect_draft_authoring_contract(
|
await api.inspect_draft_authoring_contract(
|
||||||
@@ -256,7 +256,7 @@ async def test_inspect_draft_authoring_contract_resolves_saved_wrapper_capabilit
|
|||||||
"properties": {"echoed": {"type": "string"}},
|
"properties": {"echoed": {"type": "string"}},
|
||||||
}
|
}
|
||||||
await draft_api.create_draft_workspace(workspace_id="authoring", draft=draft)
|
await draft_api.create_draft_workspace(workspace_id="authoring", draft=draft)
|
||||||
api = WorkflowApi(authoring.context)
|
api = WorkflowApi(authoring.context, drafts=True)
|
||||||
|
|
||||||
inventory = _authoring_inventory(
|
inventory = _authoring_inventory(
|
||||||
await api.inspect_draft_authoring_contract(
|
await api.inspect_draft_authoring_contract(
|
||||||
@@ -296,7 +296,7 @@ async def test_inspect_draft_authoring_contract_preserves_empty_capability_schem
|
|||||||
"properties": {"echoed": {"type": "string"}},
|
"properties": {"echoed": {"type": "string"}},
|
||||||
}
|
}
|
||||||
await draft_api.create_draft_workspace(workspace_id="authoring", draft=draft)
|
await draft_api.create_draft_workspace(workspace_id="authoring", draft=draft)
|
||||||
api = WorkflowApi(authoring.context)
|
api = WorkflowApi(authoring.context, drafts=True)
|
||||||
|
|
||||||
inventory = _authoring_inventory(
|
inventory = _authoring_inventory(
|
||||||
await api.inspect_draft_authoring_contract(
|
await api.inspect_draft_authoring_contract(
|
||||||
@@ -344,7 +344,7 @@ async def test_inspect_draft_authoring_contract_warns_for_invalid_capability_sch
|
|||||||
workspace_id="authoring",
|
workspace_id="authoring",
|
||||||
draft=_echo_draft(),
|
draft=_echo_draft(),
|
||||||
)
|
)
|
||||||
api = WorkflowApi(authoring.context)
|
api = WorkflowApi(authoring.context, drafts=True)
|
||||||
|
|
||||||
inventory = _authoring_inventory(
|
inventory = _authoring_inventory(
|
||||||
await api.inspect_draft_authoring_contract(
|
await api.inspect_draft_authoring_contract(
|
||||||
@@ -375,7 +375,7 @@ async def test_inspect_draft_authoring_contract_rejects_unknown_selected_step(
|
|||||||
await draft_api.create_draft_workspace(
|
await draft_api.create_draft_workspace(
|
||||||
workspace_id="authoring", draft=_echo_draft()
|
workspace_id="authoring", draft=_echo_draft()
|
||||||
)
|
)
|
||||||
api = WorkflowApi(authoring.context)
|
api = WorkflowApi(authoring.context, drafts=True)
|
||||||
|
|
||||||
with pytest.raises(KeyError, match="unknown draft step"):
|
with pytest.raises(KeyError, match="unknown draft step"):
|
||||||
await api.inspect_draft_authoring_contract(
|
await api.inspect_draft_authoring_contract(
|
||||||
@@ -397,7 +397,7 @@ async def test_inspect_draft_authoring_contract_tolerates_invalid_selected_step(
|
|||||||
draft["steps"] = {"broken": {"unknown_kind": {}}}
|
draft["steps"] = {"broken": {"unknown_kind": {}}}
|
||||||
draft["start"] = "broken"
|
draft["start"] = "broken"
|
||||||
await draft_api.create_draft_workspace(workspace_id="authoring", draft=draft)
|
await draft_api.create_draft_workspace(workspace_id="authoring", draft=draft)
|
||||||
api = WorkflowApi(authoring.context)
|
api = WorkflowApi(authoring.context, drafts=True)
|
||||||
|
|
||||||
inventory = _authoring_inventory(
|
inventory = _authoring_inventory(
|
||||||
await api.inspect_draft_authoring_contract(
|
await api.inspect_draft_authoring_contract(
|
||||||
@@ -426,7 +426,7 @@ async def test_inspect_draft_authoring_contract_stale_revision_is_read_only(
|
|||||||
await draft_api.create_draft_workspace(
|
await draft_api.create_draft_workspace(
|
||||||
workspace_id="authoring", draft=_echo_draft()
|
workspace_id="authoring", draft=_echo_draft()
|
||||||
)
|
)
|
||||||
api = WorkflowApi(authoring.context)
|
api = WorkflowApi(authoring.context, drafts=True)
|
||||||
changed = await api.set_draft_name(
|
changed = await api.set_draft_name(
|
||||||
workspace_id="authoring",
|
workspace_id="authoring",
|
||||||
revision=1,
|
revision=1,
|
||||||
@@ -1023,7 +1023,7 @@ async def _create_structured_binding_api(
|
|||||||
workspace_id=workspace_id,
|
workspace_id=workspace_id,
|
||||||
draft=_structured_report_draft(),
|
draft=_structured_report_draft(),
|
||||||
)
|
)
|
||||||
return draft_api, service, WorkflowApi(authoring.context)
|
return draft_api, service, WorkflowApi(authoring.context, drafts=True)
|
||||||
|
|
||||||
|
|
||||||
async def _create_nested_output_binding_api(
|
async def _create_nested_output_binding_api(
|
||||||
@@ -1058,7 +1058,7 @@ async def _create_nested_output_binding_api(
|
|||||||
workspace_id=workspace_id,
|
workspace_id=workspace_id,
|
||||||
draft=_nested_report_draft(),
|
draft=_nested_report_draft(),
|
||||||
)
|
)
|
||||||
return draft_api, service, WorkflowApi(authoring.context)
|
return draft_api, service, WorkflowApi(authoring.context, drafts=True)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@@ -1163,7 +1163,7 @@ async def test_create_empty_draft_workspace_persists_invalid_skeleton(
|
|||||||
) -> None:
|
) -> None:
|
||||||
artifact_store = FileWorkflowArtifactStore(tmp_path / "drafts_create_empty")
|
artifact_store = FileWorkflowArtifactStore(tmp_path / "drafts_create_empty")
|
||||||
_drafts, _service, authoring = _draft_api(artifact_store)
|
_drafts, _service, authoring = _draft_api(artifact_store)
|
||||||
facade = WorkflowApi(authoring.context)
|
facade = WorkflowApi(authoring.context, drafts=True)
|
||||||
|
|
||||||
created = await facade.create_empty_draft_workspace(
|
created = await facade.create_empty_draft_workspace(
|
||||||
workspace_id="control_first",
|
workspace_id="control_first",
|
||||||
@@ -1198,7 +1198,7 @@ async def test_create_empty_draft_workspace_preserves_custom_contract(
|
|||||||
) -> None:
|
) -> None:
|
||||||
artifact_store = FileWorkflowArtifactStore(tmp_path / "drafts_create_contract")
|
artifact_store = FileWorkflowArtifactStore(tmp_path / "drafts_create_contract")
|
||||||
_drafts, _service, authoring = _draft_api(artifact_store)
|
_drafts, _service, authoring = _draft_api(artifact_store)
|
||||||
facade = WorkflowApi(authoring.context)
|
facade = WorkflowApi(authoring.context, drafts=True)
|
||||||
input_schema = {
|
input_schema = {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {"topic": {"type": "string"}},
|
"properties": {"topic": {"type": "string"}},
|
||||||
@@ -1249,7 +1249,7 @@ async def test_create_empty_draft_workspace_isolates_default_schemas(
|
|||||||
) -> None:
|
) -> None:
|
||||||
artifact_store = FileWorkflowArtifactStore(tmp_path / "drafts_schema_isolation")
|
artifact_store = FileWorkflowArtifactStore(tmp_path / "drafts_schema_isolation")
|
||||||
_drafts, _service, authoring = _draft_api(artifact_store)
|
_drafts, _service, authoring = _draft_api(artifact_store)
|
||||||
facade = WorkflowApi(authoring.context)
|
facade = WorkflowApi(authoring.context, drafts=True)
|
||||||
input_schema = {
|
input_schema = {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {"topic": {"type": "string"}},
|
"properties": {"topic": {"type": "string"}},
|
||||||
@@ -1283,7 +1283,7 @@ async def test_create_empty_draft_workspace_reports_duplicate_conflict(
|
|||||||
) -> None:
|
) -> None:
|
||||||
artifact_store = FileWorkflowArtifactStore(tmp_path / "drafts_create_conflict")
|
artifact_store = FileWorkflowArtifactStore(tmp_path / "drafts_create_conflict")
|
||||||
_drafts, _service, authoring = _draft_api(artifact_store)
|
_drafts, _service, authoring = _draft_api(artifact_store)
|
||||||
facade = WorkflowApi(authoring.context)
|
facade = WorkflowApi(authoring.context, drafts=True)
|
||||||
await facade.create_empty_draft_workspace(
|
await facade.create_empty_draft_workspace(
|
||||||
workspace_id="control_first",
|
workspace_id="control_first",
|
||||||
name="control_first",
|
name="control_first",
|
||||||
@@ -1317,7 +1317,7 @@ async def test_create_empty_draft_workspace_rejects_invalid_contract_before_muta
|
|||||||
) -> None:
|
) -> None:
|
||||||
artifact_store = FileWorkflowArtifactStore(tmp_path / "drafts_create_rejected")
|
artifact_store = FileWorkflowArtifactStore(tmp_path / "drafts_create_rejected")
|
||||||
_drafts, _service, authoring = _draft_api(artifact_store)
|
_drafts, _service, authoring = _draft_api(artifact_store)
|
||||||
facade = WorkflowApi(authoring.context)
|
facade = WorkflowApi(authoring.context, drafts=True)
|
||||||
|
|
||||||
with pytest.raises(ValueError):
|
with pytest.raises(ValueError):
|
||||||
await facade.create_empty_draft_workspace(
|
await facade.create_empty_draft_workspace(
|
||||||
@@ -1335,7 +1335,7 @@ async def test_set_draft_start_and_contract_replace_top_level_fields_atomically(
|
|||||||
) -> None:
|
) -> None:
|
||||||
artifact_store = FileWorkflowArtifactStore(tmp_path / "drafts_set_lifecycle")
|
artifact_store = FileWorkflowArtifactStore(tmp_path / "drafts_set_lifecycle")
|
||||||
_drafts, _service, authoring = _draft_api(artifact_store)
|
_drafts, _service, authoring = _draft_api(artifact_store)
|
||||||
facade = WorkflowApi(authoring.context)
|
facade = WorkflowApi(authoring.context, drafts=True)
|
||||||
await facade.create_empty_draft_workspace(
|
await facade.create_empty_draft_workspace(
|
||||||
workspace_id="control_first",
|
workspace_id="control_first",
|
||||||
name="control_first",
|
name="control_first",
|
||||||
@@ -1412,7 +1412,7 @@ async def test_lifecycle_edits_reject_invalid_envelopes_without_mutation(
|
|||||||
tmp_path / f"drafts_lifecycle_rejected_{operation}"
|
tmp_path / f"drafts_lifecycle_rejected_{operation}"
|
||||||
)
|
)
|
||||||
_drafts, _service, authoring = _draft_api(artifact_store)
|
_drafts, _service, authoring = _draft_api(artifact_store)
|
||||||
facade = WorkflowApi(authoring.context)
|
facade = WorkflowApi(authoring.context, drafts=True)
|
||||||
await facade.create_empty_draft_workspace(
|
await facade.create_empty_draft_workspace(
|
||||||
workspace_id="control_first",
|
workspace_id="control_first",
|
||||||
name="control_first",
|
name="control_first",
|
||||||
@@ -1453,7 +1453,7 @@ async def test_lifecycle_edits_report_stale_revision_without_mutation(
|
|||||||
tmp_path / f"drafts_lifecycle_stale_{operation}"
|
tmp_path / f"drafts_lifecycle_stale_{operation}"
|
||||||
)
|
)
|
||||||
_drafts, _service, authoring = _draft_api(artifact_store)
|
_drafts, _service, authoring = _draft_api(artifact_store)
|
||||||
facade = WorkflowApi(authoring.context)
|
facade = WorkflowApi(authoring.context, drafts=True)
|
||||||
await facade.create_empty_draft_workspace(
|
await facade.create_empty_draft_workspace(
|
||||||
workspace_id="control_first",
|
workspace_id="control_first",
|
||||||
name="control_first",
|
name="control_first",
|
||||||
@@ -2187,7 +2187,7 @@ async def test_facade_delegates_semantic_authoring_to_authoring_service(
|
|||||||
service.register_specs("demo.personal", echo_tool, _snapshot_tool)
|
service.register_specs("demo.personal", echo_tool, _snapshot_tool)
|
||||||
|
|
||||||
context = context_from_service(service)
|
context = context_from_service(service)
|
||||||
facade = WorkflowApi(context)
|
facade = WorkflowApi(context, drafts=True)
|
||||||
|
|
||||||
assert facade.draft_authoring is not None
|
assert facade.draft_authoring is not None
|
||||||
assert isinstance(facade.draft_authoring, WorkflowDraftAuthoringApi)
|
assert isinstance(facade.draft_authoring, WorkflowDraftAuthoringApi)
|
||||||
@@ -3469,7 +3469,7 @@ async def test_set_step_input_bindings_rejects_remote_target_reference_without_m
|
|||||||
workspace_id="remote_target",
|
workspace_id="remote_target",
|
||||||
draft=_structured_report_draft("demo.personal.remote_structured_report"),
|
draft=_structured_report_draft("demo.personal.remote_structured_report"),
|
||||||
)
|
)
|
||||||
api = WorkflowApi(authoring.context)
|
api = WorkflowApi(authoring.context, drafts=True)
|
||||||
before = await draft_api.get_draft_workspace(
|
before = await draft_api.get_draft_workspace(
|
||||||
workspace_id="remote_target",
|
workspace_id="remote_target",
|
||||||
include_draft=True,
|
include_draft=True,
|
||||||
@@ -3506,7 +3506,7 @@ async def test_set_step_input_bindings_rejects_non_capability_step_without_mutat
|
|||||||
draft = _structured_report_draft()
|
draft = _structured_report_draft()
|
||||||
draft["steps"]["report"] = {"join": {}}
|
draft["steps"]["report"] = {"join": {}}
|
||||||
await draft_api.create_draft_workspace(workspace_id="non_capability", draft=draft)
|
await draft_api.create_draft_workspace(workspace_id="non_capability", draft=draft)
|
||||||
api = WorkflowApi(authoring.context)
|
api = WorkflowApi(authoring.context, drafts=True)
|
||||||
before = await draft_api.get_draft_workspace(
|
before = await draft_api.get_draft_workspace(
|
||||||
workspace_id="non_capability",
|
workspace_id="non_capability",
|
||||||
include_draft=True,
|
include_draft=True,
|
||||||
@@ -4007,7 +4007,7 @@ async def test_add_step_accepts_every_typed_draft_step(
|
|||||||
) -> None:
|
) -> None:
|
||||||
artifact_store = FileWorkflowArtifactStore(tmp_path / f"draft_add_{step_name}")
|
artifact_store = FileWorkflowArtifactStore(tmp_path / f"draft_add_{step_name}")
|
||||||
draft_api, _service, authoring = _draft_api(artifact_store, register_echo=True)
|
draft_api, _service, authoring = _draft_api(artifact_store, register_echo=True)
|
||||||
api = WorkflowApi(authoring.context)
|
api = WorkflowApi(authoring.context, drafts=True)
|
||||||
await draft_api.create_draft_workspace(workspace_id="draft_ws", draft=_echo_draft())
|
await draft_api.create_draft_workspace(workspace_id="draft_ws", draft=_echo_draft())
|
||||||
|
|
||||||
step = TypeAdapter(DraftStep).validate_python(step_payload)
|
step = TypeAdapter(DraftStep).validate_python(step_payload)
|
||||||
@@ -4033,7 +4033,7 @@ async def test_add_step_routes_incoming_and_outgoing_edges_atomically(
|
|||||||
) -> None:
|
) -> None:
|
||||||
artifact_store = FileWorkflowArtifactStore(tmp_path / "draft_add_routes")
|
artifact_store = FileWorkflowArtifactStore(tmp_path / "draft_add_routes")
|
||||||
draft_api, _service, authoring = _draft_api(artifact_store, register_echo=True)
|
draft_api, _service, authoring = _draft_api(artifact_store, register_echo=True)
|
||||||
api = WorkflowApi(authoring.context)
|
api = WorkflowApi(authoring.context, drafts=True)
|
||||||
await draft_api.create_draft_workspace(workspace_id="draft_ws", draft=_echo_draft())
|
await draft_api.create_draft_workspace(workspace_id="draft_ws", draft=_echo_draft())
|
||||||
|
|
||||||
step = TypeAdapter(DraftStep).validate_python(
|
step = TypeAdapter(DraftStep).validate_python(
|
||||||
@@ -4062,7 +4062,7 @@ async def test_add_step_stale_revision_wins_over_content_preflight(
|
|||||||
) -> None:
|
) -> None:
|
||||||
artifact_store = FileWorkflowArtifactStore(tmp_path / "draft_add_stale")
|
artifact_store = FileWorkflowArtifactStore(tmp_path / "draft_add_stale")
|
||||||
draft_api, _service, authoring = _draft_api(artifact_store, register_echo=True)
|
draft_api, _service, authoring = _draft_api(artifact_store, register_echo=True)
|
||||||
api = WorkflowApi(authoring.context)
|
api = WorkflowApi(authoring.context, drafts=True)
|
||||||
await draft_api.create_draft_workspace(workspace_id="draft_ws", draft=_echo_draft())
|
await draft_api.create_draft_workspace(workspace_id="draft_ws", draft=_echo_draft())
|
||||||
before = await draft_api.get_draft_workspace(
|
before = await draft_api.get_draft_workspace(
|
||||||
workspace_id="draft_ws", include_draft=True
|
workspace_id="draft_ws", include_draft=True
|
||||||
@@ -4135,7 +4135,7 @@ async def test_add_step_adds_missing_incoming_route_parent_atomically(
|
|||||||
) -> None:
|
) -> None:
|
||||||
artifact_store = FileWorkflowArtifactStore(tmp_path / "draft_add_missing_parent")
|
artifact_store = FileWorkflowArtifactStore(tmp_path / "draft_add_missing_parent")
|
||||||
draft_api, _service, authoring = _draft_api(artifact_store, register_echo=True)
|
draft_api, _service, authoring = _draft_api(artifact_store, register_echo=True)
|
||||||
api = WorkflowApi(authoring.context)
|
api = WorkflowApi(authoring.context, drafts=True)
|
||||||
draft = _echo_draft()
|
draft = _echo_draft()
|
||||||
draft["routes"] = {}
|
draft["routes"] = {}
|
||||||
await draft_api.create_draft_workspace(workspace_id="draft_ws", draft=draft)
|
await draft_api.create_draft_workspace(workspace_id="draft_ws", draft=draft)
|
||||||
@@ -4164,7 +4164,7 @@ async def test_add_step_distinguishes_missing_and_explicit_empty_routes(
|
|||||||
) -> None:
|
) -> None:
|
||||||
artifact_store = FileWorkflowArtifactStore(tmp_path / "draft_add_empty_routes")
|
artifact_store = FileWorkflowArtifactStore(tmp_path / "draft_add_empty_routes")
|
||||||
draft_api, _service, authoring = _draft_api(artifact_store, register_echo=True)
|
draft_api, _service, authoring = _draft_api(artifact_store, register_echo=True)
|
||||||
api = WorkflowApi(authoring.context)
|
api = WorkflowApi(authoring.context, drafts=True)
|
||||||
await draft_api.create_draft_workspace(workspace_id="draft_ws", draft=_echo_draft())
|
await draft_api.create_draft_workspace(workspace_id="draft_ws", draft=_echo_draft())
|
||||||
|
|
||||||
step_adapter = TypeAdapter(DraftStep)
|
step_adapter = TypeAdapter(DraftStep)
|
||||||
@@ -4207,7 +4207,7 @@ async def test_add_step_rejects_unknown_incoming_outcome_without_mutation(
|
|||||||
) -> None:
|
) -> None:
|
||||||
artifact_store = FileWorkflowArtifactStore(tmp_path / "draft_add_bad_incoming")
|
artifact_store = FileWorkflowArtifactStore(tmp_path / "draft_add_bad_incoming")
|
||||||
draft_api, _service, authoring = _draft_api(artifact_store, register_echo=True)
|
draft_api, _service, authoring = _draft_api(artifact_store, register_echo=True)
|
||||||
api = WorkflowApi(authoring.context)
|
api = WorkflowApi(authoring.context, drafts=True)
|
||||||
await draft_api.create_draft_workspace(workspace_id="draft_ws", draft=_echo_draft())
|
await draft_api.create_draft_workspace(workspace_id="draft_ws", draft=_echo_draft())
|
||||||
|
|
||||||
draft_store = authoring.drafts._draft_store()
|
draft_store = authoring.drafts._draft_store()
|
||||||
@@ -4277,7 +4277,7 @@ async def test_add_step_rejects_invalid_routing_inputs_atomically(
|
|||||||
) -> None:
|
) -> None:
|
||||||
artifact_store = FileWorkflowArtifactStore(tmp_path / "draft_add_errors")
|
artifact_store = FileWorkflowArtifactStore(tmp_path / "draft_add_errors")
|
||||||
draft_api, _service, authoring = _draft_api(artifact_store, register_echo=True)
|
draft_api, _service, authoring = _draft_api(artifact_store, register_echo=True)
|
||||||
api = WorkflowApi(authoring.context)
|
api = WorkflowApi(authoring.context, drafts=True)
|
||||||
await draft_api.create_draft_workspace(workspace_id="draft_ws", draft=_echo_draft())
|
await draft_api.create_draft_workspace(workspace_id="draft_ws", draft=_echo_draft())
|
||||||
|
|
||||||
await _assert_add_step_rejected_without_mutation(
|
await _assert_add_step_rejected_without_mutation(
|
||||||
@@ -4338,7 +4338,7 @@ async def test_add_step_rejects_routes_for_non_routable_steps_atomically(
|
|||||||
) -> None:
|
) -> None:
|
||||||
artifact_store = FileWorkflowArtifactStore(tmp_path / "draft_add_forbidden_routes")
|
artifact_store = FileWorkflowArtifactStore(tmp_path / "draft_add_forbidden_routes")
|
||||||
draft_api, _service, authoring = _draft_api(artifact_store, register_echo=True)
|
draft_api, _service, authoring = _draft_api(artifact_store, register_echo=True)
|
||||||
api = WorkflowApi(authoring.context)
|
api = WorkflowApi(authoring.context, drafts=True)
|
||||||
await draft_api.create_draft_workspace(workspace_id="draft_ws", draft=_echo_draft())
|
await draft_api.create_draft_workspace(workspace_id="draft_ws", draft=_echo_draft())
|
||||||
|
|
||||||
before = await draft_api.get_draft_workspace(
|
before = await draft_api.get_draft_workspace(
|
||||||
@@ -4367,7 +4367,7 @@ async def test_add_step_accepts_incomplete_declared_route_subset(
|
|||||||
artifact_store = FileWorkflowArtifactStore(tmp_path / "draft_add_partial_routes")
|
artifact_store = FileWorkflowArtifactStore(tmp_path / "draft_add_partial_routes")
|
||||||
draft_api, service, authoring = _draft_api(artifact_store, register_echo=True)
|
draft_api, service, authoring = _draft_api(artifact_store, register_echo=True)
|
||||||
service.register_specs("demo.personal", echo_tool, _snapshot_tool)
|
service.register_specs("demo.personal", echo_tool, _snapshot_tool)
|
||||||
api = WorkflowApi(authoring.context)
|
api = WorkflowApi(authoring.context, drafts=True)
|
||||||
await draft_api.create_draft_workspace(workspace_id="draft_ws", draft=_echo_draft())
|
await draft_api.create_draft_workspace(workspace_id="draft_ws", draft=_echo_draft())
|
||||||
|
|
||||||
step = TypeAdapter(DraftStep).validate_python(
|
step = TypeAdapter(DraftStep).validate_python(
|
||||||
@@ -6300,7 +6300,7 @@ def _browser_click_api(
|
|||||||
_collect_snapshots,
|
_collect_snapshots,
|
||||||
)
|
)
|
||||||
context = context_from_service(service)
|
context = context_from_service(service)
|
||||||
return WorkflowApi(context), service
|
return WorkflowApi(context, drafts=True), service
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
|
|||||||
@@ -54,3 +54,23 @@ def test_durable_workflow_api_returns_workflow_api_with_same_context(tmp_path) -
|
|||||||
|
|
||||||
assert isinstance(api, WorkflowApi)
|
assert isinstance(api, WorkflowApi)
|
||||||
assert api.context is context
|
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
|
||||||
|
|||||||
@@ -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"
|
root = tmp_path / "wf_api_file_workflow_stores"
|
||||||
|
|
||||||
stores = file_workflow_stores(root)
|
stores = file_workflow_stores(root)
|
||||||
|
|
||||||
assert isinstance(stores, WorkflowStores)
|
assert isinstance(stores, WorkflowStores)
|
||||||
assert isinstance(stores.artifact_store, FileWorkflowArtifactStore)
|
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 isinstance(stores.run_store, FileRunStore)
|
||||||
assert stores.artifact_store.root == root
|
assert stores.artifact_store.root == root
|
||||||
assert stores.draft_workspace_store.root == root
|
|
||||||
assert stores.run_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:
|
def test_wf_api_exports_workflow_stores() -> None:
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import typer
|
|||||||
from typer.core import TyperCommand
|
from typer.core import TyperCommand
|
||||||
|
|
||||||
from wf_api import WorkflowApi
|
from wf_api import WorkflowApi
|
||||||
|
from wf_artifacts import FileWorkflowArtifactStore
|
||||||
from wf_cli.context import (
|
from wf_cli.context import (
|
||||||
CliTyperState,
|
CliTyperState,
|
||||||
config_path_from_context,
|
config_path_from_context,
|
||||||
@@ -16,7 +17,6 @@ from wf_cli.context import (
|
|||||||
rpc_timeout_from_context,
|
rpc_timeout_from_context,
|
||||||
rpc_url_from_context,
|
rpc_url_from_context,
|
||||||
)
|
)
|
||||||
from wf_server.config import build_workflow_server_from_workflow_config
|
|
||||||
|
|
||||||
from .conftest import write_python_source_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(
|
def test_load_cli_context_local_uses_workflow_store_override(
|
||||||
tmp_path: Path,
|
tmp_path: Path,
|
||||||
monkeypatch,
|
|
||||||
) -> None:
|
) -> None:
|
||||||
config_path = tmp_path / "wf.json"
|
config_path = tmp_path / "wf.json"
|
||||||
config_path.write_text(
|
config_path.write_text(
|
||||||
@@ -125,21 +124,14 @@ def test_load_cli_context_local_uses_workflow_store_override(
|
|||||||
),
|
),
|
||||||
encoding="utf-8",
|
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)
|
context = load_cli_context(config_path)
|
||||||
|
|
||||||
assert context.service is None
|
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
|
@pytest.mark.asyncio
|
||||||
|
|||||||
@@ -329,7 +329,7 @@ def _patch_rpc_client_to_server(monkeypatch, server) -> None:
|
|||||||
url=url,
|
url=url,
|
||||||
timeout_seconds=timeout_seconds,
|
timeout_seconds=timeout_seconds,
|
||||||
http_client=httpx.AsyncClient(
|
http_client=httpx.AsyncClient(
|
||||||
transport=httpx.ASGITransport(app=create_rpc_app(server)),
|
transport=httpx.ASGITransport(app=create_rpc_app(server, drafts=True)),
|
||||||
base_url="http://test",
|
base_url="http://test",
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@@ -340,7 +340,7 @@ def _patch_rpc_client_to_server(monkeypatch, server) -> None:
|
|||||||
|
|
||||||
|
|
||||||
def test_wf_cap_commands_use_rpc_url_override(monkeypatch, tmp_path) -> None:
|
def test_wf_cap_commands_use_rpc_url_override(monkeypatch, tmp_path) -> None:
|
||||||
server = build_local_static_workflow_server(tmp_path / "store")
|
server = build_local_static_workflow_server(tmp_path / "store", drafts=True)
|
||||||
_patch_rpc_client_to_server(monkeypatch, server)
|
_patch_rpc_client_to_server(monkeypatch, server)
|
||||||
config_path = tmp_path / "wf.json"
|
config_path = tmp_path / "wf.json"
|
||||||
config_path.write_text('{"version": 1}', encoding="utf-8")
|
config_path.write_text('{"version": 1}', encoding="utf-8")
|
||||||
@@ -433,7 +433,7 @@ def test_wf_cap_commands_use_rpc_url_override(monkeypatch, tmp_path) -> None:
|
|||||||
|
|
||||||
|
|
||||||
def test_wf_source_commands_use_rpc_url_override(monkeypatch, tmp_path) -> None:
|
def test_wf_source_commands_use_rpc_url_override(monkeypatch, tmp_path) -> None:
|
||||||
server = build_local_static_workflow_server(tmp_path / "store")
|
server = build_local_static_workflow_server(tmp_path / "store", drafts=True)
|
||||||
_patch_rpc_client_to_server(monkeypatch, server)
|
_patch_rpc_client_to_server(monkeypatch, server)
|
||||||
config_path = tmp_path / "wf.json"
|
config_path = tmp_path / "wf.json"
|
||||||
config_path.write_text('{"version": 1}', encoding="utf-8")
|
config_path.write_text('{"version": 1}', encoding="utf-8")
|
||||||
@@ -511,7 +511,7 @@ def test_wf_remote_source_inspect_formats_expected_rpc_error(
|
|||||||
monkeypatch,
|
monkeypatch,
|
||||||
tmp_path,
|
tmp_path,
|
||||||
) -> None:
|
) -> None:
|
||||||
server = build_local_static_workflow_server(tmp_path / "store")
|
server = build_local_static_workflow_server(tmp_path / "store", drafts=True)
|
||||||
_patch_rpc_client_to_server(monkeypatch, server)
|
_patch_rpc_client_to_server(monkeypatch, server)
|
||||||
config_path = tmp_path / "wf.json"
|
config_path = tmp_path / "wf.json"
|
||||||
config_path.write_text('{"version": 1}', encoding="utf-8")
|
config_path.write_text('{"version": 1}', encoding="utf-8")
|
||||||
@@ -607,7 +607,7 @@ def test_wf_verbose_shows_full_traceback_for_unexpected_error(
|
|||||||
|
|
||||||
|
|
||||||
def test_wf_admin_commands_use_rpc_url_override(monkeypatch, tmp_path) -> None:
|
def test_wf_admin_commands_use_rpc_url_override(monkeypatch, tmp_path) -> None:
|
||||||
server = build_local_static_workflow_server(tmp_path / "store")
|
server = build_local_static_workflow_server(tmp_path / "store", drafts=True)
|
||||||
server.events.record_workflow_event(
|
server.events.record_workflow_event(
|
||||||
"workflow_test_event",
|
"workflow_test_event",
|
||||||
capability_id="workflow.demo.v1",
|
capability_id="workflow.demo.v1",
|
||||||
@@ -632,7 +632,7 @@ def test_wf_admin_commands_use_rpc_url_override(monkeypatch, tmp_path) -> None:
|
|||||||
|
|
||||||
|
|
||||||
def test_wf_remote_draft_artifact_deploy_lifecycle(monkeypatch, tmp_path) -> None:
|
def test_wf_remote_draft_artifact_deploy_lifecycle(monkeypatch, tmp_path) -> None:
|
||||||
server = build_local_static_workflow_server(tmp_path / "store")
|
server = build_local_static_workflow_server(tmp_path / "store", drafts=True)
|
||||||
_patch_rpc_client_to_server(monkeypatch, server)
|
_patch_rpc_client_to_server(monkeypatch, server)
|
||||||
config_path = tmp_path / "wf.json"
|
config_path = tmp_path / "wf.json"
|
||||||
config_path.write_text('{"version": 1}', encoding="utf-8")
|
config_path.write_text('{"version": 1}', encoding="utf-8")
|
||||||
@@ -760,7 +760,7 @@ def test_wf_remote_draft_artifact_deploy_lifecycle(monkeypatch, tmp_path) -> Non
|
|||||||
|
|
||||||
|
|
||||||
def test_wf_remote_capability_free_draft_lifecycle(monkeypatch, tmp_path) -> None:
|
def test_wf_remote_capability_free_draft_lifecycle(monkeypatch, tmp_path) -> None:
|
||||||
server = build_local_static_workflow_server(tmp_path / "store")
|
server = build_local_static_workflow_server(tmp_path / "store", drafts=True)
|
||||||
_patch_rpc_client_to_server(monkeypatch, server)
|
_patch_rpc_client_to_server(monkeypatch, server)
|
||||||
config_path = tmp_path / "wf.json"
|
config_path = tmp_path / "wf.json"
|
||||||
config_path.write_text('{"version": 1}', encoding="utf-8")
|
config_path.write_text('{"version": 1}', encoding="utf-8")
|
||||||
@@ -833,7 +833,7 @@ def test_wf_remote_capability_free_draft_lifecycle(monkeypatch, tmp_path) -> Non
|
|||||||
def test_wf_draft_export_uses_remote_get_and_writes_only_draft(
|
def test_wf_draft_export_uses_remote_get_and_writes_only_draft(
|
||||||
monkeypatch, tmp_path
|
monkeypatch, tmp_path
|
||||||
) -> None:
|
) -> None:
|
||||||
server = build_local_static_workflow_server(tmp_path / "store")
|
server = build_local_static_workflow_server(tmp_path / "store", drafts=True)
|
||||||
_patch_rpc_client_to_server(monkeypatch, server)
|
_patch_rpc_client_to_server(monkeypatch, server)
|
||||||
rpc_calls: list[tuple[str, dict[str, Any]]] = []
|
rpc_calls: list[tuple[str, dict[str, Any]]] = []
|
||||||
original_call = RpcClientTransport._call
|
original_call = RpcClientTransport._call
|
||||||
@@ -885,7 +885,7 @@ def test_wf_draft_export_uses_remote_get_and_writes_only_draft(
|
|||||||
def test_wf_draft_import_uses_exact_remote_replacement_payload(
|
def test_wf_draft_import_uses_exact_remote_replacement_payload(
|
||||||
monkeypatch, tmp_path
|
monkeypatch, tmp_path
|
||||||
) -> None:
|
) -> None:
|
||||||
server = build_local_static_workflow_server(tmp_path / "store")
|
server = build_local_static_workflow_server(tmp_path / "store", drafts=True)
|
||||||
asyncio.run(
|
asyncio.run(
|
||||||
server.api.create_empty_draft_workspace(
|
server.api.create_empty_draft_workspace(
|
||||||
workspace_id="source_ws",
|
workspace_id="source_ws",
|
||||||
@@ -956,7 +956,7 @@ def test_wf_draft_import_uses_exact_remote_replacement_payload(
|
|||||||
def test_wf_draft_transfer_round_trip_preserves_document_and_destination_id(
|
def test_wf_draft_transfer_round_trip_preserves_document_and_destination_id(
|
||||||
monkeypatch, tmp_path
|
monkeypatch, tmp_path
|
||||||
) -> None:
|
) -> None:
|
||||||
server = build_local_static_workflow_server(tmp_path / "store")
|
server = build_local_static_workflow_server(tmp_path / "store", drafts=True)
|
||||||
asyncio.run(
|
asyncio.run(
|
||||||
server.api.create_empty_draft_workspace(
|
server.api.create_empty_draft_workspace(
|
||||||
workspace_id="source_ws",
|
workspace_id="source_ws",
|
||||||
@@ -1023,7 +1023,7 @@ def test_wf_draft_transfer_round_trip_preserves_document_and_destination_id(
|
|||||||
|
|
||||||
|
|
||||||
def test_wf_remote_run_resume_interrupted_deployment(monkeypatch, tmp_path) -> None:
|
def test_wf_remote_run_resume_interrupted_deployment(monkeypatch, tmp_path) -> None:
|
||||||
server = build_local_static_workflow_server(tmp_path / "store")
|
server = build_local_static_workflow_server(tmp_path / "store", drafts=True)
|
||||||
asyncio.run(
|
asyncio.run(
|
||||||
server.api.create_artifact_from_plan(
|
server.api.create_artifact_from_plan(
|
||||||
artifact_id="remote_approval",
|
artifact_id="remote_approval",
|
||||||
@@ -1083,7 +1083,7 @@ def test_wf_remote_run_resume_interrupted_deployment(monkeypatch, tmp_path) -> N
|
|||||||
|
|
||||||
|
|
||||||
def test_wf_status_uses_rpc_url_override(monkeypatch, tmp_path) -> None:
|
def test_wf_status_uses_rpc_url_override(monkeypatch, tmp_path) -> None:
|
||||||
server = build_local_static_workflow_server(tmp_path / "store")
|
server = build_local_static_workflow_server(tmp_path / "store", drafts=True)
|
||||||
asyncio.run(
|
asyncio.run(
|
||||||
server.api.create_artifact_from_plan(
|
server.api.create_artifact_from_plan(
|
||||||
artifact_id="status_constant",
|
artifact_id="status_constant",
|
||||||
@@ -1143,7 +1143,7 @@ def test_wf_status_uses_rpc_url_override(monkeypatch, tmp_path) -> None:
|
|||||||
|
|
||||||
|
|
||||||
def test_wf_status_reports_rpc_config_target(monkeypatch, tmp_path) -> None:
|
def test_wf_status_reports_rpc_config_target(monkeypatch, tmp_path) -> None:
|
||||||
server = build_local_static_workflow_server(tmp_path / "store")
|
server = build_local_static_workflow_server(tmp_path / "store", drafts=True)
|
||||||
_patch_rpc_client_to_server(monkeypatch, server)
|
_patch_rpc_client_to_server(monkeypatch, server)
|
||||||
config_path = tmp_path / "wf.json"
|
config_path = tmp_path / "wf.json"
|
||||||
config_path.write_text(
|
config_path.write_text(
|
||||||
@@ -1178,7 +1178,7 @@ def test_wf_status_reports_rpc_config_target(monkeypatch, tmp_path) -> None:
|
|||||||
|
|
||||||
|
|
||||||
def test_wf_draft_delete_requires_confirm(monkeypatch, tmp_path) -> None:
|
def test_wf_draft_delete_requires_confirm(monkeypatch, tmp_path) -> None:
|
||||||
server = build_local_static_workflow_server(tmp_path / "store")
|
server = build_local_static_workflow_server(tmp_path / "store", drafts=True)
|
||||||
_patch_rpc_client_to_server(monkeypatch, server)
|
_patch_rpc_client_to_server(monkeypatch, server)
|
||||||
config_path = tmp_path / "wf.json"
|
config_path = tmp_path / "wf.json"
|
||||||
config_path.write_text('{"version": 1}', encoding="utf-8")
|
config_path.write_text('{"version": 1}', encoding="utf-8")
|
||||||
@@ -1192,7 +1192,7 @@ def test_wf_draft_delete_requires_confirm(monkeypatch, tmp_path) -> None:
|
|||||||
|
|
||||||
|
|
||||||
def test_wf_draft_delete_succeeds_with_confirm(monkeypatch, tmp_path) -> None:
|
def test_wf_draft_delete_succeeds_with_confirm(monkeypatch, tmp_path) -> None:
|
||||||
server = build_local_static_workflow_server(tmp_path / "store")
|
server = build_local_static_workflow_server(tmp_path / "store", drafts=True)
|
||||||
_patch_rpc_client_to_server(monkeypatch, server)
|
_patch_rpc_client_to_server(monkeypatch, server)
|
||||||
config_path = tmp_path / "wf.json"
|
config_path = tmp_path / "wf.json"
|
||||||
config_path.write_text('{"version": 1}', encoding="utf-8")
|
config_path.write_text('{"version": 1}', encoding="utf-8")
|
||||||
@@ -1225,7 +1225,7 @@ def test_wf_draft_delete_succeeds_with_confirm(monkeypatch, tmp_path) -> None:
|
|||||||
|
|
||||||
|
|
||||||
def test_wf_source_diagnose_uses_rpc_url_override(monkeypatch, tmp_path) -> None:
|
def test_wf_source_diagnose_uses_rpc_url_override(monkeypatch, tmp_path) -> None:
|
||||||
server = build_local_static_workflow_server(tmp_path / "store")
|
server = build_local_static_workflow_server(tmp_path / "store", drafts=True)
|
||||||
_patch_rpc_client_to_server(monkeypatch, server)
|
_patch_rpc_client_to_server(monkeypatch, server)
|
||||||
config_path = tmp_path / "wf.json"
|
config_path = tmp_path / "wf.json"
|
||||||
config_path.write_text('{"version": 1}', encoding="utf-8")
|
config_path.write_text('{"version": 1}', encoding="utf-8")
|
||||||
@@ -1300,7 +1300,7 @@ def test_wf_draft_create_reports_optional_inputs_without_binding(
|
|||||||
def test_wf_draft_set_input_bindings_preserves_composite_expression_over_rpc(
|
def test_wf_draft_set_input_bindings_preserves_composite_expression_over_rpc(
|
||||||
monkeypatch, tmp_path
|
monkeypatch, tmp_path
|
||||||
) -> None:
|
) -> None:
|
||||||
server = build_local_static_workflow_server(tmp_path / "store")
|
server = build_local_static_workflow_server(tmp_path / "store", drafts=True)
|
||||||
_patch_rpc_client_to_server(monkeypatch, server)
|
_patch_rpc_client_to_server(monkeypatch, server)
|
||||||
rpc_calls: list[tuple[str, dict[str, Any]]] = []
|
rpc_calls: list[tuple[str, dict[str, Any]]] = []
|
||||||
original_call = RpcClientTransport._call
|
original_call = RpcClientTransport._call
|
||||||
@@ -1381,7 +1381,7 @@ def test_wf_draft_set_input_bindings_preserves_composite_expression_over_rpc(
|
|||||||
|
|
||||||
|
|
||||||
def test_wf_draft_focused_edit_commands_use_rpc_target(monkeypatch, tmp_path) -> None:
|
def test_wf_draft_focused_edit_commands_use_rpc_target(monkeypatch, tmp_path) -> None:
|
||||||
server = build_local_static_workflow_server(tmp_path / "store")
|
server = build_local_static_workflow_server(tmp_path / "store", drafts=True)
|
||||||
_patch_rpc_client_to_server(monkeypatch, server)
|
_patch_rpc_client_to_server(monkeypatch, server)
|
||||||
config_path = tmp_path / "wf.json"
|
config_path = tmp_path / "wf.json"
|
||||||
config_path.write_text('{"version": 1}', encoding="utf-8")
|
config_path.write_text('{"version": 1}', encoding="utf-8")
|
||||||
@@ -1536,7 +1536,7 @@ def test_wf_draft_focused_edit_commands_use_rpc_target(monkeypatch, tmp_path) ->
|
|||||||
def test_wf_draft_set_workflow_output_replaces_canonical_bindings_over_rpc(
|
def test_wf_draft_set_workflow_output_replaces_canonical_bindings_over_rpc(
|
||||||
monkeypatch, tmp_path
|
monkeypatch, tmp_path
|
||||||
) -> None:
|
) -> None:
|
||||||
server = build_local_static_workflow_server(tmp_path / "store")
|
server = build_local_static_workflow_server(tmp_path / "store", drafts=True)
|
||||||
_patch_rpc_client_to_server(monkeypatch, server)
|
_patch_rpc_client_to_server(monkeypatch, server)
|
||||||
rpc_calls: list[tuple[str, dict[str, Any]]] = []
|
rpc_calls: list[tuple[str, dict[str, Any]]] = []
|
||||||
original_call = RpcClientTransport._call
|
original_call = RpcClientTransport._call
|
||||||
@@ -1625,7 +1625,7 @@ def test_wf_draft_set_workflow_output_replaces_canonical_bindings_over_rpc(
|
|||||||
def test_wf_draft_set_workflow_output_merge_uses_compatibility_rpc_target(
|
def test_wf_draft_set_workflow_output_merge_uses_compatibility_rpc_target(
|
||||||
monkeypatch, tmp_path
|
monkeypatch, tmp_path
|
||||||
) -> None:
|
) -> None:
|
||||||
server = build_local_static_workflow_server(tmp_path / "store")
|
server = build_local_static_workflow_server(tmp_path / "store", drafts=True)
|
||||||
_patch_rpc_client_to_server(monkeypatch, server)
|
_patch_rpc_client_to_server(monkeypatch, server)
|
||||||
rpc_methods: list[str] = []
|
rpc_methods: list[str] = []
|
||||||
original_call = RpcClientTransport._call
|
original_call = RpcClientTransport._call
|
||||||
@@ -1685,7 +1685,7 @@ def test_wf_draft_set_workflow_output_merge_uses_compatibility_rpc_target(
|
|||||||
def test_wf_draft_set_workflow_output_merge_reports_canonical_replacement(
|
def test_wf_draft_set_workflow_output_merge_reports_canonical_replacement(
|
||||||
monkeypatch, tmp_path
|
monkeypatch, tmp_path
|
||||||
) -> None:
|
) -> None:
|
||||||
server = build_local_static_workflow_server(tmp_path / "store")
|
server = build_local_static_workflow_server(tmp_path / "store", drafts=True)
|
||||||
_patch_rpc_client_to_server(monkeypatch, server)
|
_patch_rpc_client_to_server(monkeypatch, server)
|
||||||
rpc_methods: list[str] = []
|
rpc_methods: list[str] = []
|
||||||
original_call = RpcClientTransport._call
|
original_call = RpcClientTransport._call
|
||||||
@@ -1760,7 +1760,7 @@ def test_wf_draft_set_workflow_output_merge_reports_canonical_replacement(
|
|||||||
|
|
||||||
|
|
||||||
def test_wf_draft_remove_route_uses_rpc_target(monkeypatch, tmp_path) -> None:
|
def test_wf_draft_remove_route_uses_rpc_target(monkeypatch, tmp_path) -> None:
|
||||||
server = build_local_static_workflow_server(tmp_path / "store")
|
server = build_local_static_workflow_server(tmp_path / "store", drafts=True)
|
||||||
_patch_rpc_client_to_server(monkeypatch, server)
|
_patch_rpc_client_to_server(monkeypatch, server)
|
||||||
config_path = tmp_path / "wf.json"
|
config_path = tmp_path / "wf.json"
|
||||||
config_path.write_text('{"version": 1}', encoding="utf-8")
|
config_path.write_text('{"version": 1}', encoding="utf-8")
|
||||||
@@ -1811,7 +1811,7 @@ def test_wf_draft_remove_route_uses_rpc_target(monkeypatch, tmp_path) -> None:
|
|||||||
|
|
||||||
|
|
||||||
def test_wf_draft_bind_uses_rpc_target(monkeypatch, tmp_path) -> None:
|
def test_wf_draft_bind_uses_rpc_target(monkeypatch, tmp_path) -> None:
|
||||||
server = build_local_static_workflow_server(tmp_path / "store")
|
server = build_local_static_workflow_server(tmp_path / "store", drafts=True)
|
||||||
_patch_rpc_client_to_server(monkeypatch, server)
|
_patch_rpc_client_to_server(monkeypatch, server)
|
||||||
config_path = tmp_path / "wf.json"
|
config_path = tmp_path / "wf.json"
|
||||||
config_path.write_text('{"version": 1}', encoding="utf-8")
|
config_path.write_text('{"version": 1}', encoding="utf-8")
|
||||||
@@ -1868,7 +1868,7 @@ def test_wf_draft_bind_uses_rpc_target(monkeypatch, tmp_path) -> None:
|
|||||||
def test_wf_draft_set_input_preserves_nested_target_over_rpc(
|
def test_wf_draft_set_input_preserves_nested_target_over_rpc(
|
||||||
monkeypatch, tmp_path
|
monkeypatch, tmp_path
|
||||||
) -> None:
|
) -> None:
|
||||||
server = build_local_static_workflow_server(tmp_path / "store")
|
server = build_local_static_workflow_server(tmp_path / "store", drafts=True)
|
||||||
_patch_rpc_client_to_server(monkeypatch, server)
|
_patch_rpc_client_to_server(monkeypatch, server)
|
||||||
config_path = tmp_path / "wf.json"
|
config_path = tmp_path / "wf.json"
|
||||||
config_path.write_text('{"version": 1}', encoding="utf-8")
|
config_path.write_text('{"version": 1}', encoding="utf-8")
|
||||||
@@ -1921,7 +1921,7 @@ def test_wf_draft_set_input_preserves_nested_target_over_rpc(
|
|||||||
def test_wf_draft_set_input_replaces_canonical_bindings_over_rpc(
|
def test_wf_draft_set_input_replaces_canonical_bindings_over_rpc(
|
||||||
monkeypatch, tmp_path
|
monkeypatch, tmp_path
|
||||||
) -> None:
|
) -> None:
|
||||||
server = build_local_static_workflow_server(tmp_path / "store")
|
server = build_local_static_workflow_server(tmp_path / "store", drafts=True)
|
||||||
_patch_rpc_client_to_server(monkeypatch, server)
|
_patch_rpc_client_to_server(monkeypatch, server)
|
||||||
rpc_methods: list[str] = []
|
rpc_methods: list[str] = []
|
||||||
original_call = RpcClientTransport._call
|
original_call = RpcClientTransport._call
|
||||||
@@ -2024,7 +2024,7 @@ def test_wf_draft_set_input_replaces_canonical_bindings_over_rpc(
|
|||||||
def test_wf_draft_set_output_replaces_canonical_bindings_over_rpc(
|
def test_wf_draft_set_output_replaces_canonical_bindings_over_rpc(
|
||||||
monkeypatch, tmp_path
|
monkeypatch, tmp_path
|
||||||
) -> None:
|
) -> None:
|
||||||
server = build_local_static_workflow_server(tmp_path / "store")
|
server = build_local_static_workflow_server(tmp_path / "store", drafts=True)
|
||||||
_patch_rpc_client_to_server(monkeypatch, server)
|
_patch_rpc_client_to_server(monkeypatch, server)
|
||||||
rpc_calls: list[tuple[str, dict[str, Any]]] = []
|
rpc_calls: list[tuple[str, dict[str, Any]]] = []
|
||||||
original_call = RpcClientTransport._call
|
original_call = RpcClientTransport._call
|
||||||
@@ -2117,7 +2117,7 @@ def test_wf_draft_set_output_replaces_canonical_bindings_over_rpc(
|
|||||||
|
|
||||||
|
|
||||||
def test_wf_draft_add_capability_uses_rpc_target(monkeypatch, tmp_path) -> None:
|
def test_wf_draft_add_capability_uses_rpc_target(monkeypatch, tmp_path) -> None:
|
||||||
server = build_local_static_workflow_server(tmp_path / "store")
|
server = build_local_static_workflow_server(tmp_path / "store", drafts=True)
|
||||||
_patch_rpc_client_to_server(monkeypatch, server)
|
_patch_rpc_client_to_server(monkeypatch, server)
|
||||||
rpc_methods: list[str] = []
|
rpc_methods: list[str] = []
|
||||||
original_call = RpcClientTransport._call
|
original_call = RpcClientTransport._call
|
||||||
@@ -2187,7 +2187,7 @@ def test_wf_draft_add_capability_uses_rpc_target(monkeypatch, tmp_path) -> None:
|
|||||||
def test_wf_draft_capability_add_and_update_preserve_rpc_payloads(
|
def test_wf_draft_capability_add_and_update_preserve_rpc_payloads(
|
||||||
monkeypatch, tmp_path
|
monkeypatch, tmp_path
|
||||||
) -> None:
|
) -> None:
|
||||||
server = build_local_static_workflow_server(tmp_path / "store")
|
server = build_local_static_workflow_server(tmp_path / "store", drafts=True)
|
||||||
_patch_rpc_client_to_server(monkeypatch, server)
|
_patch_rpc_client_to_server(monkeypatch, server)
|
||||||
rpc_calls: list[tuple[str, dict[str, Any]]] = []
|
rpc_calls: list[tuple[str, dict[str, Any]]] = []
|
||||||
original_call = RpcClientTransport._call
|
original_call = RpcClientTransport._call
|
||||||
@@ -2289,7 +2289,7 @@ def test_wf_draft_capability_add_and_update_preserve_rpc_payloads(
|
|||||||
def test_wf_draft_add_control_steps_use_generic_rpc_target(
|
def test_wf_draft_add_control_steps_use_generic_rpc_target(
|
||||||
monkeypatch, tmp_path
|
monkeypatch, tmp_path
|
||||||
) -> None:
|
) -> None:
|
||||||
server = build_local_static_workflow_server(tmp_path / "store")
|
server = build_local_static_workflow_server(tmp_path / "store", drafts=True)
|
||||||
_patch_rpc_client_to_server(monkeypatch, server)
|
_patch_rpc_client_to_server(monkeypatch, server)
|
||||||
rpc_methods: list[str] = []
|
rpc_methods: list[str] = []
|
||||||
original_call = RpcClientTransport._call
|
original_call = RpcClientTransport._call
|
||||||
@@ -2544,7 +2544,7 @@ def test_wf_draft_add_control_steps_use_generic_rpc_target(
|
|||||||
def test_wf_draft_add_capability_reports_bare_output_target_without_traceback(
|
def test_wf_draft_add_capability_reports_bare_output_target_without_traceback(
|
||||||
monkeypatch, tmp_path
|
monkeypatch, tmp_path
|
||||||
) -> None:
|
) -> None:
|
||||||
server = build_local_static_workflow_server(tmp_path / "store")
|
server = build_local_static_workflow_server(tmp_path / "store", drafts=True)
|
||||||
_patch_rpc_client_to_server(monkeypatch, server)
|
_patch_rpc_client_to_server(monkeypatch, server)
|
||||||
config_path = tmp_path / "wf.json"
|
config_path = tmp_path / "wf.json"
|
||||||
config_path.write_text('{"version": 1}', encoding="utf-8")
|
config_path.write_text('{"version": 1}', encoding="utf-8")
|
||||||
@@ -2592,7 +2592,7 @@ def test_wf_draft_add_capability_reports_bare_output_target_without_traceback(
|
|||||||
|
|
||||||
|
|
||||||
def test_wf_draft_compile_prints_compiled_plan(monkeypatch, tmp_path) -> None:
|
def test_wf_draft_compile_prints_compiled_plan(monkeypatch, tmp_path) -> None:
|
||||||
server = build_local_static_workflow_server(tmp_path / "store")
|
server = build_local_static_workflow_server(tmp_path / "store", drafts=True)
|
||||||
_patch_rpc_client_to_server(monkeypatch, server)
|
_patch_rpc_client_to_server(monkeypatch, server)
|
||||||
config_path = tmp_path / "wf.json"
|
config_path = tmp_path / "wf.json"
|
||||||
config_path.write_text('{"version": 1}', encoding="utf-8")
|
config_path.write_text('{"version": 1}', encoding="utf-8")
|
||||||
@@ -2625,7 +2625,7 @@ def test_wf_draft_compile_prints_compiled_plan(monkeypatch, tmp_path) -> None:
|
|||||||
def test_wf_draft_compile_invalid_prints_diagnostics_to_stderr(
|
def test_wf_draft_compile_invalid_prints_diagnostics_to_stderr(
|
||||||
monkeypatch, tmp_path
|
monkeypatch, tmp_path
|
||||||
) -> None:
|
) -> None:
|
||||||
server = build_local_static_workflow_server(tmp_path / "store")
|
server = build_local_static_workflow_server(tmp_path / "store", drafts=True)
|
||||||
asyncio.run(
|
asyncio.run(
|
||||||
server.api.create_draft_workspace(
|
server.api.create_draft_workspace(
|
||||||
workspace_id="invalid_compile_ws",
|
workspace_id="invalid_compile_ws",
|
||||||
@@ -2672,7 +2672,7 @@ def test_wf_draft_compile_invalid_prints_diagnostics_to_stderr(
|
|||||||
|
|
||||||
|
|
||||||
def test_wf_deploy_create_alias_saves_deployment(monkeypatch, tmp_path) -> None:
|
def test_wf_deploy_create_alias_saves_deployment(monkeypatch, tmp_path) -> None:
|
||||||
server = build_local_static_workflow_server(tmp_path / "store")
|
server = build_local_static_workflow_server(tmp_path / "store", drafts=True)
|
||||||
asyncio.run(
|
asyncio.run(
|
||||||
server.api.create_artifact_from_plan(
|
server.api.create_artifact_from_plan(
|
||||||
artifact_id="alias_artifact",
|
artifact_id="alias_artifact",
|
||||||
@@ -2710,7 +2710,7 @@ def test_wf_deploy_create_alias_saves_deployment(monkeypatch, tmp_path) -> None:
|
|||||||
|
|
||||||
|
|
||||||
def test_wf_draft_forward_route_invalid_via_rpc(monkeypatch, tmp_path) -> None:
|
def test_wf_draft_forward_route_invalid_via_rpc(monkeypatch, tmp_path) -> None:
|
||||||
server = build_local_static_workflow_server(tmp_path / "store")
|
server = build_local_static_workflow_server(tmp_path / "store", drafts=True)
|
||||||
_patch_rpc_client_to_server(monkeypatch, server)
|
_patch_rpc_client_to_server(monkeypatch, server)
|
||||||
config_path = tmp_path / "wf.json"
|
config_path = tmp_path / "wf.json"
|
||||||
config_path.write_text('{"version": 1}', encoding="utf-8")
|
config_path.write_text('{"version": 1}', encoding="utf-8")
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Tests for the transport-independent workflow client boundary."""
|
||||||
@@ -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()
|
||||||
@@ -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")
|
||||||
@@ -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"}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
)
|
||||||
@@ -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"
|
||||||
@@ -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"
|
||||||
@@ -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"}
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any, cast
|
||||||
|
|
||||||
|
from wf_client.capabilities import CapabilityResult, RemoteCapability
|
||||||
|
from wf_client.deployments import Deployment, DeploymentValidation
|
||||||
|
from wf_client.protocols import WorkflowClientPort
|
||||||
|
from wf_client.runs import Run, TracePage
|
||||||
|
from wf_client.workflows import (
|
||||||
|
ArtifactRef,
|
||||||
|
WorkflowDiagnostic,
|
||||||
|
WorkflowValidation,
|
||||||
|
)
|
||||||
|
from wf_core import ValidationReport
|
||||||
|
from wf_platform import CapabilityRef
|
||||||
|
|
||||||
|
|
||||||
|
class _Port:
|
||||||
|
"""A port that records accidental representation-time remote operations."""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.calls: list[tuple[str, dict[str, Any]]] = []
|
||||||
|
|
||||||
|
def __getattr__(self, name: str) -> Any:
|
||||||
|
async def operation(**params: Any) -> object:
|
||||||
|
self.calls.append((name, params))
|
||||||
|
return {}
|
||||||
|
|
||||||
|
return operation
|
||||||
|
|
||||||
|
|
||||||
|
def _port() -> _Port:
|
||||||
|
return _Port()
|
||||||
|
|
||||||
|
|
||||||
|
def test_capability_html_repr_is_bounded_and_does_not_call_port() -> None:
|
||||||
|
port = _port()
|
||||||
|
remote = RemoteCapability(
|
||||||
|
_port=cast(WorkflowClientPort, port),
|
||||||
|
ref=CapabilityRef.parse("app.default.search"),
|
||||||
|
qualified_name="app.default.search",
|
||||||
|
description="Search things <carefully>",
|
||||||
|
input_schema={"type": "object", "properties": {"query": {"type": "string"}}},
|
||||||
|
output_schema={"type": "object", "properties": {"items": {"type": "array"}}},
|
||||||
|
outcomes=("ok",),
|
||||||
|
is_async=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
rendered = remote._repr_html_()
|
||||||
|
|
||||||
|
assert "app.default.search" in rendered
|
||||||
|
assert "input schema" in rendered.lower()
|
||||||
|
assert "<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 == []
|
||||||
@@ -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"
|
||||||
@@ -18,7 +18,9 @@ def _manifest() -> ContractManifest:
|
|||||||
return manifest_from_openrpc(synthetic_openrpc_document())
|
return manifest_from_openrpc(synthetic_openrpc_document())
|
||||||
|
|
||||||
|
|
||||||
def test_write_generates_once_and_writes_requested_contract(monkeypatch, tmp_path: Path) -> None:
|
def test_write_generates_once_and_writes_requested_contract(
|
||||||
|
monkeypatch, tmp_path: Path
|
||||||
|
) -> None:
|
||||||
manifest = _manifest()
|
manifest = _manifest()
|
||||||
calls: list[tuple[object, Path]] = []
|
calls: list[tuple[object, Path]] = []
|
||||||
generate_calls = 0
|
generate_calls = 0
|
||||||
@@ -35,7 +37,10 @@ def test_write_generates_once_and_writes_requested_contract(monkeypatch, tmp_pat
|
|||||||
"wf_contract_manifest.__main__.write_manifest",
|
"wf_contract_manifest.__main__.write_manifest",
|
||||||
lambda value, path: calls.append((value, path)) or path,
|
lambda value, path: calls.append((value, path)) or path,
|
||||||
)
|
)
|
||||||
monkeypatch.setattr("wf_contract_manifest.__main__.DEFAULT_MANIFEST_PATH", tmp_path / "manifest.json")
|
monkeypatch.setattr(
|
||||||
|
"wf_contract_manifest.__main__.DEFAULT_MANIFEST_PATH",
|
||||||
|
tmp_path / "manifest.json",
|
||||||
|
)
|
||||||
|
|
||||||
assert main(["write"]) == 0
|
assert main(["write"]) == 0
|
||||||
assert calls == [(manifest, tmp_path / "manifest.json")]
|
assert calls == [(manifest, tmp_path / "manifest.json")]
|
||||||
|
|||||||
@@ -90,9 +90,9 @@ def test_generates_the_complete_real_workflow_contract() -> None:
|
|||||||
manifest = generate_manifest()
|
manifest = generate_manifest()
|
||||||
schemas = manifest["components"]["schemas"]
|
schemas = manifest["components"]["schemas"]
|
||||||
|
|
||||||
assert len(manifest["operations"]) == 71
|
assert len(manifest["operations"]) == 72
|
||||||
assert len({operation["method"] for operation in manifest["operations"]}) == 71
|
assert len({operation["method"] for operation in manifest["operations"]}) == 72
|
||||||
assert len(schemas) == 140
|
assert len(schemas) == 142
|
||||||
assert len(manifest["components"]["errors"]) == 1
|
assert len(manifest["components"]["errors"]) == 1
|
||||||
assert all(
|
assert all(
|
||||||
set(operation["result"]["schema"]) == {"$ref"}
|
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"]
|
input_binding_schema = schemas["InputExpressionBinding"]
|
||||||
properties = input_binding_schema.get("properties")
|
properties = input_binding_schema.get("properties")
|
||||||
assert isinstance(properties, dict)
|
assert isinstance(properties, dict)
|
||||||
assert properties["expression"] == {
|
assert properties["expression"] == {"$ref": "#/components/schemas/InputExpression"}
|
||||||
"$ref": "#/components/schemas/InputExpression"
|
|
||||||
}
|
|
||||||
expression_schema = schemas["InputExpression"]
|
expression_schema = schemas["InputExpression"]
|
||||||
assert expression_schema["discriminator"] == {
|
assert expression_schema["discriminator"] == {
|
||||||
"mapping": {
|
"mapping": {
|
||||||
|
|||||||
@@ -101,7 +101,7 @@ async def test_registered_output_bindings_tool_delegates_typed_bindings_once(
|
|||||||
recorder = RecordingWorkflowHandler()
|
recorder = RecordingWorkflowHandler()
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
"wf_mcp.workflow_surface.tools.WorkflowApi",
|
"wf_mcp.workflow_surface.tools.WorkflowApi",
|
||||||
lambda _context: recorder,
|
lambda _context, **_kwargs: recorder,
|
||||||
)
|
)
|
||||||
service = WfMcpService(
|
service = WfMcpService(
|
||||||
store=FileStore(tmp_path / "tool_invocation_store"),
|
store=FileStore(tmp_path / "tool_invocation_store"),
|
||||||
@@ -167,7 +167,7 @@ async def test_registered_workflow_output_bindings_tool_preserves_union_order(
|
|||||||
recorder = RecordingWorkflowHandler()
|
recorder = RecordingWorkflowHandler()
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
"wf_mcp.workflow_surface.tools.WorkflowApi",
|
"wf_mcp.workflow_surface.tools.WorkflowApi",
|
||||||
lambda _context: recorder,
|
lambda _context, **_kwargs: recorder,
|
||||||
)
|
)
|
||||||
service = WfMcpService(
|
service = WfMcpService(
|
||||||
store=FileStore(tmp_path / "workflow_output_tool_store"),
|
store=FileStore(tmp_path / "workflow_output_tool_store"),
|
||||||
@@ -242,7 +242,7 @@ async def test_registered_capability_tools_delegate_presence_aware_requests(
|
|||||||
recorder = RecordingWorkflowHandler()
|
recorder = RecordingWorkflowHandler()
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
"wf_mcp.workflow_surface.tools.WorkflowApi",
|
"wf_mcp.workflow_surface.tools.WorkflowApi",
|
||||||
lambda _context: recorder,
|
lambda _context, **_kwargs: recorder,
|
||||||
)
|
)
|
||||||
service = WfMcpService(
|
service = WfMcpService(
|
||||||
store=FileStore(tmp_path / "capability_tool_store"),
|
store=FileStore(tmp_path / "capability_tool_store"),
|
||||||
|
|||||||
@@ -69,6 +69,8 @@ def test_workflow_server_from_service_wires_neutral_surfaces(tmp_path) -> None:
|
|||||||
assert isinstance(server, WorkflowServer)
|
assert isinstance(server, WorkflowServer)
|
||||||
assert server.config.store_root == config.store_root
|
assert server.config.store_root == config.store_root
|
||||||
assert server.api.context is server.context
|
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.source_registry_admin is not None
|
||||||
assert server.admin.connections is service.connection_service
|
assert server.admin.connections is service.connection_service
|
||||||
assert server.admin.events is service.events
|
assert server.admin.events is service.events
|
||||||
|
|||||||
@@ -177,9 +177,7 @@ def test_interrupted_saved_child_blocks_resume_until_pinned_source_returns(
|
|||||||
assert blocked["status"] == "interrupted"
|
assert blocked["status"] == "interrupted"
|
||||||
assert blocked["resume_readiness"] == "blocked"
|
assert blocked["resume_readiness"] == "blocked"
|
||||||
assert blocked["diagnostics"][0]["code"] == "source_disabled"
|
assert blocked["diagnostics"][0]["code"] == "source_disabled"
|
||||||
assert (
|
assert run_store.get_run(paused_run_id).resume_readiness is ResumeReadiness.BLOCKED
|
||||||
run_store.get_run(paused_run_id).resume_readiness is ResumeReadiness.BLOCKED
|
|
||||||
)
|
|
||||||
assert run_store.get_latest_checkpoint(paused_run_id).sequence == 1
|
assert run_store.get_latest_checkpoint(paused_run_id).sequence == 1
|
||||||
|
|
||||||
handlers.service.capability_sources["demo.personal"].enabled = True
|
handlers.service.capability_sources["demo.personal"].enabled = True
|
||||||
|
|||||||
@@ -83,13 +83,15 @@ def test_rpc_server_cli_uses_configured_store_and_transport(
|
|||||||
)
|
)
|
||||||
captured: dict[str, object] = {}
|
captured: dict[str, object] = {}
|
||||||
|
|
||||||
def fake_build_server(config):
|
def fake_build_server(config, *, drafts=False):
|
||||||
captured["store_root"] = config.server.store.root
|
captured["store_root"] = config.server.store.root
|
||||||
|
captured["drafts"] = drafts
|
||||||
return object()
|
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["server"] = server
|
||||||
captured["rpc_path"] = rpc_path
|
captured["rpc_path"] = rpc_path
|
||||||
|
captured["drafts"] = drafts
|
||||||
return object()
|
return object()
|
||||||
|
|
||||||
def fake_uvicorn_run(app_obj, *, host, port, access_log):
|
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 result.exit_code == 0, result.output
|
||||||
assert captured["store_root"] == (tmp_path / ".wf_store").resolve()
|
assert captured["store_root"] == (tmp_path / ".wf_store").resolve()
|
||||||
assert captured["rpc_path"] == "/workflow-rpc"
|
assert captured["rpc_path"] == "/workflow-rpc"
|
||||||
|
assert captured["drafts"] is True
|
||||||
assert captured["host"] == "127.0.0.2"
|
assert captured["host"] == "127.0.0.2"
|
||||||
assert captured["port"] == 9999
|
assert captured["port"] == 9999
|
||||||
assert captured["access_log"] is False
|
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
|
captured["mcp_config_path"] = path
|
||||||
return object()
|
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["server"] = server
|
||||||
captured["rpc_path"] = rpc_path
|
captured["rpc_path"] = rpc_path
|
||||||
|
captured["drafts"] = drafts
|
||||||
return object()
|
return object()
|
||||||
|
|
||||||
def fake_uvicorn_run(app_obj, *, host, port, access_log):
|
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] = {}
|
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["source_registry_admin"] = server.source_registry_admin
|
||||||
captured["rpc_path"] = rpc_path
|
captured["rpc_path"] = rpc_path
|
||||||
|
captured["drafts"] = drafts
|
||||||
return object()
|
return object()
|
||||||
|
|
||||||
def fake_uvicorn_run(app_obj, *, host, port, access_log):
|
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] = {}
|
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["server"] = server
|
||||||
captured["rpc_path"] = rpc_path
|
captured["rpc_path"] = rpc_path
|
||||||
|
captured["drafts"] = drafts
|
||||||
return object()
|
return object()
|
||||||
|
|
||||||
def fake_uvicorn_run(app_obj, *, host, port, access_log):
|
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:
|
) -> None:
|
||||||
captured = {}
|
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["source_kinds"] = [source.kind for source in config.server.sources]
|
||||||
|
captured["build_drafts"] = drafts
|
||||||
return object()
|
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["server"] = server
|
||||||
captured["rpc_path"] = rpc_path
|
captured["rpc_path"] = rpc_path
|
||||||
|
captured["drafts"] = drafts
|
||||||
return "app"
|
return "app"
|
||||||
|
|
||||||
def fake_run(app, *, host, port, access_log):
|
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] = {}
|
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["workflow_store_root"] = config.server.workflow_store.root
|
||||||
|
captured["build_drafts"] = drafts
|
||||||
return object()
|
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["server"] = server
|
||||||
captured["rpc_path"] = rpc_path
|
captured["rpc_path"] = rpc_path
|
||||||
|
captured["drafts"] = drafts
|
||||||
return object()
|
return object()
|
||||||
|
|
||||||
def fake_uvicorn_run(app_obj, *, host, port, access_log):
|
def fake_uvicorn_run(app_obj, *, host, port, access_log):
|
||||||
|
|||||||
@@ -107,9 +107,7 @@ async def test_local_static_server_runs_deployment_and_persists_run(tmp_path) ->
|
|||||||
assert output["result"] == "hello from server"
|
assert output["result"] == "hello from server"
|
||||||
run_id = run_result["run_id"]
|
run_id = run_result["run_id"]
|
||||||
assert isinstance(run_id, str)
|
assert isinstance(run_id, str)
|
||||||
assert (
|
assert server.stores.run_store.get_run(run_id).id == run_id
|
||||||
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:
|
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
|
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:
|
def test_local_static_builtins_are_platform_sources(tmp_path) -> None:
|
||||||
server = build_local_static_workflow_server(tmp_path)
|
server = build_local_static_workflow_server(tmp_path)
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
import httpx
|
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(
|
async def _rpc(
|
||||||
client: httpx.AsyncClient, method: str, params: dict[str, Any]
|
client: httpx.AsyncClient, method: str, params: dict[str, Any]
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
@@ -32,6 +60,24 @@ async def _rpc(
|
|||||||
return response.json()
|
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]:
|
def _rpc_constant_draft() -> dict[str, Any]:
|
||||||
"""Return the canonical keyed draft shared by stateless RPC tests."""
|
"""Return the canonical keyed draft shared by stateless RPC tests."""
|
||||||
return {
|
return {
|
||||||
@@ -1333,6 +1379,33 @@ async def test_rpc_create_artifact_from_plan(tmp_path) -> None:
|
|||||||
assert inspected["result"]["plan"]["name"] == "rpc_constant"
|
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:
|
async def test_rpc_draft_workspace_focused_edit_methods(tmp_path) -> None:
|
||||||
server = build_local_static_workflow_server(tmp_path / "store")
|
server = build_local_static_workflow_server(tmp_path / "store")
|
||||||
app = create_rpc_app(server)
|
app = create_rpc_app(server)
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
@@ -26,10 +27,67 @@ from wf_core.models.steps import (
|
|||||||
from wf_core.paths import GraphSourcePath, LocalPath, StatePath
|
from wf_core.paths import GraphSourcePath, LocalPath, StatePath
|
||||||
from wf_server import build_local_static_workflow_server
|
from wf_server import build_local_static_workflow_server
|
||||||
from wf_transport_rpc_http import RpcWorkflowApiClient, create_rpc_app
|
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.drafts import RpcDraftClientMixin
|
||||||
from wf_transport_rpc_http.client.sources import RpcSourceAdminClientMixin
|
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:
|
def _constant_plan() -> RawWorkflowPlan:
|
||||||
return RawWorkflowPlan.model_validate(
|
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:
|
async def test_rpc_workflow_client_draft_workspace_lifecycle(tmp_path) -> None:
|
||||||
server = build_local_static_workflow_server(tmp_path / "store")
|
server = build_local_static_workflow_server(tmp_path / "store", drafts=True)
|
||||||
app = create_rpc_app(server)
|
app = create_rpc_app(server, drafts=True)
|
||||||
transport = httpx.ASGITransport(app=app)
|
transport = httpx.ASGITransport(app=app)
|
||||||
async with httpx.AsyncClient(
|
async with httpx.AsyncClient(
|
||||||
transport=transport, base_url="http://test"
|
transport=transport, base_url="http://test"
|
||||||
@@ -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:
|
async def test_rpc_client_builds_capability_free_draft_lifecycle(tmp_path) -> None:
|
||||||
server = build_local_static_workflow_server(tmp_path / "store")
|
server = build_local_static_workflow_server(tmp_path / "store", drafts=True)
|
||||||
app = create_rpc_app(server)
|
app = create_rpc_app(server, drafts=True)
|
||||||
transport = httpx.ASGITransport(app=app)
|
transport = httpx.ASGITransport(app=app)
|
||||||
async with httpx.AsyncClient(
|
async with httpx.AsyncClient(
|
||||||
transport=transport,
|
transport=transport,
|
||||||
@@ -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:
|
async def test_rpc_workflow_client_deletes_draft_workspace(tmp_path) -> None:
|
||||||
server = build_local_static_workflow_server(tmp_path / "store")
|
server = build_local_static_workflow_server(tmp_path / "store", drafts=True)
|
||||||
app = create_rpc_app(server)
|
app = create_rpc_app(server, drafts=True)
|
||||||
transport = httpx.ASGITransport(app=app)
|
transport = httpx.ASGITransport(app=app)
|
||||||
async with httpx.AsyncClient(
|
async with httpx.AsyncClient(
|
||||||
transport=transport, base_url="http://test"
|
transport=transport, base_url="http://test"
|
||||||
@@ -695,10 +753,35 @@ async def test_rpc_client_creates_artifact_from_plan(tmp_path) -> None:
|
|||||||
assert inspected["id"] == "client_plan"
|
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")
|
server = build_local_static_workflow_server(tmp_path / "store")
|
||||||
app = create_rpc_app(server)
|
app = create_rpc_app(server)
|
||||||
transport = httpx.ASGITransport(app=app)
|
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(
|
async with httpx.AsyncClient(
|
||||||
transport=transport, base_url="http://test"
|
transport=transport, base_url="http://test"
|
||||||
) as http_client:
|
) 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:
|
async def test_rpc_client_draft_workspace_focused_edit_methods(tmp_path) -> None:
|
||||||
server = build_local_static_workflow_server(tmp_path / "store")
|
server = build_local_static_workflow_server(tmp_path / "store", drafts=True)
|
||||||
app = create_rpc_app(server)
|
app = create_rpc_app(server, drafts=True)
|
||||||
transport = httpx.ASGITransport(app=app)
|
transport = httpx.ASGITransport(app=app)
|
||||||
async with httpx.AsyncClient(
|
async with httpx.AsyncClient(
|
||||||
transport=transport, base_url="http://test"
|
transport=transport, base_url="http://test"
|
||||||
@@ -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:
|
async def test_rpc_client_draft_workspace_add_step_from_capability(tmp_path) -> None:
|
||||||
server = build_local_static_workflow_server(tmp_path / "store")
|
server = build_local_static_workflow_server(tmp_path / "store", drafts=True)
|
||||||
app = create_rpc_app(server)
|
app = create_rpc_app(server, drafts=True)
|
||||||
transport = httpx.ASGITransport(app=app)
|
transport = httpx.ASGITransport(app=app)
|
||||||
async with httpx.AsyncClient(
|
async with httpx.AsyncClient(
|
||||||
transport=transport, base_url="http://test"
|
transport=transport, base_url="http://test"
|
||||||
|
|||||||
@@ -200,7 +200,7 @@ def _runtime_reuse_server(
|
|||||||
],
|
],
|
||||||
)
|
)
|
||||||
store_roots = config.store_roots
|
store_roots = config.store_roots
|
||||||
workflow_stores = file_workflow_stores(store_roots.workflow_root)
|
workflow_stores = file_workflow_stores(store_roots.workflow_root, drafts=True)
|
||||||
auth_store = FileAuthStore(store_roots.auth_root)
|
auth_store = FileAuthStore(store_roots.auth_root)
|
||||||
catalog_store = FileCatalogStore(store_roots.catalog_cache_root)
|
catalog_store = FileCatalogStore(store_roots.catalog_cache_root)
|
||||||
factory = _RecordingSessionFactory()
|
factory = _RecordingSessionFactory()
|
||||||
@@ -232,7 +232,7 @@ async def test_mcp_backed_rpc_lists_and_mutates_source_registry(tmp_path) -> Non
|
|||||||
SourceRegistryFile(sources=[_registry_entry("demo.registry")])
|
SourceRegistryFile(sources=[_registry_entry("demo.registry")])
|
||||||
)
|
)
|
||||||
server = build_workflow_server_from_config(config)
|
server = build_workflow_server_from_config(config)
|
||||||
app = create_rpc_app(server)
|
app = create_rpc_app(server, drafts=True)
|
||||||
transport = httpx.ASGITransport(app=app)
|
transport = httpx.ASGITransport(app=app)
|
||||||
|
|
||||||
async with httpx.AsyncClient(
|
async with httpx.AsyncClient(
|
||||||
@@ -255,7 +255,7 @@ async def test_mcp_backed_rpc_lists_and_mutates_source_registry(tmp_path) -> Non
|
|||||||
async def test_mcp_backed_rpc_capability_list_filters_by_source(tmp_path) -> None:
|
async def test_mcp_backed_rpc_capability_list_filters_by_source(tmp_path) -> None:
|
||||||
config = BrokerConfig(store_root=tmp_path / "store", connections=[])
|
config = BrokerConfig(store_root=tmp_path / "store", connections=[])
|
||||||
server = build_workflow_server_from_config(config)
|
server = build_workflow_server_from_config(config)
|
||||||
app = create_rpc_app(server)
|
app = create_rpc_app(server, drafts=True)
|
||||||
transport = httpx.ASGITransport(app=app)
|
transport = httpx.ASGITransport(app=app)
|
||||||
|
|
||||||
async with httpx.AsyncClient(
|
async with httpx.AsyncClient(
|
||||||
@@ -285,7 +285,7 @@ async def test_mcp_backed_rpc_reports_connections_and_events(tmp_path) -> None:
|
|||||||
],
|
],
|
||||||
)
|
)
|
||||||
server = build_workflow_server_from_config(config)
|
server = build_workflow_server_from_config(config)
|
||||||
app = create_rpc_app(server)
|
app = create_rpc_app(server, drafts=True)
|
||||||
transport = httpx.ASGITransport(app=app)
|
transport = httpx.ASGITransport(app=app)
|
||||||
|
|
||||||
async with httpx.AsyncClient(
|
async with httpx.AsyncClient(
|
||||||
@@ -299,7 +299,7 @@ async def test_mcp_backed_rpc_reports_connections_and_events(tmp_path) -> None:
|
|||||||
async def test_mcp_backed_rpc_applies_source_registry_changes(tmp_path) -> None:
|
async def test_mcp_backed_rpc_applies_source_registry_changes(tmp_path) -> None:
|
||||||
config = BrokerConfig(store_root=tmp_path / "store", connections=[])
|
config = BrokerConfig(store_root=tmp_path / "store", connections=[])
|
||||||
server = build_workflow_server_from_config(config)
|
server = build_workflow_server_from_config(config)
|
||||||
app = create_rpc_app(server)
|
app = create_rpc_app(server, drafts=True)
|
||||||
|
|
||||||
async with httpx.AsyncClient(
|
async with httpx.AsyncClient(
|
||||||
transport=httpx.ASGITransport(app=app),
|
transport=httpx.ASGITransport(app=app),
|
||||||
@@ -368,7 +368,7 @@ async def test_mcp_backed_rpc_can_be_built_from_neutral_workflow_config(
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
server = build_workflow_server_from_workflow_config(workflow_config)
|
server = build_workflow_server_from_workflow_config(workflow_config)
|
||||||
app = create_rpc_app(server)
|
app = create_rpc_app(server, drafts=True)
|
||||||
transport = httpx.ASGITransport(app=app)
|
transport = httpx.ASGITransport(app=app)
|
||||||
|
|
||||||
async with httpx.AsyncClient(
|
async with httpx.AsyncClient(
|
||||||
@@ -408,7 +408,7 @@ async def test_mcp_backed_rpc_resumes_interrupted_run_after_server_rebuild(
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
async with httpx.AsyncClient(
|
async with httpx.AsyncClient(
|
||||||
transport=httpx.ASGITransport(app=create_rpc_app(first_server)),
|
transport=httpx.ASGITransport(app=create_rpc_app(first_server, drafts=True)),
|
||||||
base_url="http://test",
|
base_url="http://test",
|
||||||
) as http_client:
|
) as http_client:
|
||||||
first_client = RpcWorkflowApiClient(
|
first_client = RpcWorkflowApiClient(
|
||||||
@@ -433,7 +433,7 @@ async def test_mcp_backed_rpc_resumes_interrupted_run_after_server_rebuild(
|
|||||||
|
|
||||||
rebuilt_server = build_workflow_server_from_workflow_config(workflow_config)
|
rebuilt_server = build_workflow_server_from_workflow_config(workflow_config)
|
||||||
async with httpx.AsyncClient(
|
async with httpx.AsyncClient(
|
||||||
transport=httpx.ASGITransport(app=create_rpc_app(rebuilt_server)),
|
transport=httpx.ASGITransport(app=create_rpc_app(rebuilt_server, drafts=True)),
|
||||||
base_url="http://test",
|
base_url="http://test",
|
||||||
) as http_client:
|
) as http_client:
|
||||||
rebuilt_client = RpcWorkflowApiClient(
|
rebuilt_client = RpcWorkflowApiClient(
|
||||||
@@ -463,7 +463,7 @@ async def test_mcp_backed_rpc_workflow_reuses_runtime_session_across_runs(
|
|||||||
assert factory.created_connections[0].id == "fixture.default"
|
assert factory.created_connections[0].id == "fixture.default"
|
||||||
|
|
||||||
async with httpx.AsyncClient(
|
async with httpx.AsyncClient(
|
||||||
transport=httpx.ASGITransport(app=create_rpc_app(server)),
|
transport=httpx.ASGITransport(app=create_rpc_app(server, drafts=True)),
|
||||||
base_url="http://test",
|
base_url="http://test",
|
||||||
) as http_client:
|
) as http_client:
|
||||||
client = RpcWorkflowApiClient(url="http://test/rpc", http_client=http_client)
|
client = RpcWorkflowApiClient(url="http://test/rpc", http_client=http_client)
|
||||||
@@ -597,7 +597,7 @@ async def test_mcp_backed_rpc_workflow_reuses_runtime_session_direct_setup(
|
|||||||
)
|
)
|
||||||
|
|
||||||
async with httpx.AsyncClient(
|
async with httpx.AsyncClient(
|
||||||
transport=httpx.ASGITransport(app=create_rpc_app(server)),
|
transport=httpx.ASGITransport(app=create_rpc_app(server, drafts=True)),
|
||||||
base_url="http://test",
|
base_url="http://test",
|
||||||
) as http_client:
|
) as http_client:
|
||||||
client = RpcWorkflowApiClient(url="http://test/rpc", http_client=http_client)
|
client = RpcWorkflowApiClient(url="http://test/rpc", http_client=http_client)
|
||||||
@@ -696,7 +696,7 @@ async def test_mcp_backed_rpc_deployment_becomes_unrunnable_after_source_removed
|
|||||||
)
|
)
|
||||||
|
|
||||||
async with httpx.AsyncClient(
|
async with httpx.AsyncClient(
|
||||||
transport=httpx.ASGITransport(app=create_rpc_app(server)),
|
transport=httpx.ASGITransport(app=create_rpc_app(server, drafts=True)),
|
||||||
base_url="http://test",
|
base_url="http://test",
|
||||||
) as http_client:
|
) as http_client:
|
||||||
client = RpcWorkflowApiClient(url="http://test/rpc", http_client=http_client)
|
client = RpcWorkflowApiClient(url="http://test/rpc", http_client=http_client)
|
||||||
@@ -748,7 +748,7 @@ async def test_mcp_backed_rpc_workflow_reuses_real_stdio_fixture_session(
|
|||||||
)
|
)
|
||||||
|
|
||||||
async with httpx.AsyncClient(
|
async with httpx.AsyncClient(
|
||||||
transport=httpx.ASGITransport(app=create_rpc_app(server)),
|
transport=httpx.ASGITransport(app=create_rpc_app(server, drafts=True)),
|
||||||
base_url="http://test",
|
base_url="http://test",
|
||||||
) as http_client:
|
) as http_client:
|
||||||
client = RpcWorkflowApiClient(url="http://test/rpc", http_client=http_client)
|
client = RpcWorkflowApiClient(url="http://test/rpc", http_client=http_client)
|
||||||
|
|||||||
@@ -31,7 +31,12 @@ def _assert_result_component(
|
|||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def openrpc_document(tmp_path: Path) -> dict[str, Any]:
|
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()
|
return app.get_openrpc()
|
||||||
|
|
||||||
|
|
||||||
@@ -399,6 +404,11 @@ def test_openrpc_exposes_typed_auth_delete_result(
|
|||||||
"SaveArtifactResult",
|
"SaveArtifactResult",
|
||||||
{"artifact_id", "version", "saved"},
|
{"artifact_id", "version", "saved"},
|
||||||
),
|
),
|
||||||
|
(
|
||||||
|
"workflow.artifacts.validate_plan",
|
||||||
|
"ValidateArtifactPlanResult",
|
||||||
|
{"status", "diagnostics", "required_capabilities", "workflow_dependencies"},
|
||||||
|
),
|
||||||
(
|
(
|
||||||
"workflow.artifacts.save",
|
"workflow.artifacts.save",
|
||||||
"SaveArtifactResult",
|
"SaveArtifactResult",
|
||||||
|
|||||||
@@ -436,6 +436,6 @@ describe("workflow contract generator", () => {
|
|||||||
|
|
||||||
const generatedSource = await generateWorkflowContractSource(manifestText);
|
const generatedSource = await generateWorkflowContractSource(manifestText);
|
||||||
expect(generatedSource).toBe(checkedSource);
|
expect(generatedSource).toBe(checkedSource);
|
||||||
expect(generatedSource.match(/^ \| "workflow\./gm)).toHaveLength(71);
|
expect(generatedSource.match(/^ \| "workflow\./gm)).toHaveLength(72);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -15,8 +15,8 @@ import {
|
|||||||
|
|
||||||
describe("generated workflow contract", () => {
|
describe("generated workflow contract", () => {
|
||||||
it("contains every operation exactly once", () => {
|
it("contains every operation exactly once", () => {
|
||||||
expect(workflowOperationNames).toHaveLength(71);
|
expect(workflowOperationNames).toHaveLength(72);
|
||||||
expect(new Set(workflowOperationNames)).toHaveLength(71);
|
expect(new Set(workflowOperationNames)).toHaveLength(72);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("contains every authored Effect operation without broadening its boundary", () => {
|
it("contains every authored Effect operation without broadening its boundary", () => {
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ export type WorkflowOperationName =
|
|||||||
| "workflow.artifacts.inspect"
|
| "workflow.artifacts.inspect"
|
||||||
| "workflow.artifacts.list"
|
| "workflow.artifacts.list"
|
||||||
| "workflow.artifacts.save"
|
| "workflow.artifacts.save"
|
||||||
|
| "workflow.artifacts.validate_plan"
|
||||||
| "workflow.capabilities.call"
|
| "workflow.capabilities.call"
|
||||||
| "workflow.capabilities.inspect"
|
| "workflow.capabilities.inspect"
|
||||||
| "workflow.capabilities.list"
|
| "workflow.capabilities.list"
|
||||||
@@ -95,6 +96,7 @@ export const workflowOperationNames: readonly WorkflowOperationName[] = [
|
|||||||
"workflow.artifacts.inspect",
|
"workflow.artifacts.inspect",
|
||||||
"workflow.artifacts.list",
|
"workflow.artifacts.list",
|
||||||
"workflow.artifacts.save",
|
"workflow.artifacts.save",
|
||||||
|
"workflow.artifacts.validate_plan",
|
||||||
"workflow.capabilities.call",
|
"workflow.capabilities.call",
|
||||||
"workflow.capabilities.inspect",
|
"workflow.capabilities.inspect",
|
||||||
"workflow.capabilities.list",
|
"workflow.capabilities.list",
|
||||||
@@ -380,6 +382,23 @@ export interface WorkflowContractMap {
|
|||||||
};
|
};
|
||||||
result: SaveArtifactResult;
|
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": {
|
"workflow.capabilities.call": {
|
||||||
params: {
|
params: {
|
||||||
qualified_name: string;
|
qualified_name: string;
|
||||||
@@ -1248,6 +1267,35 @@ export interface ArtifactCatalogEntryPayload {
|
|||||||
version: number;
|
version: number;
|
||||||
[k: string]: unknown;
|
[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.
|
* Outcome returned by a direct node-spec or wrapper capability call.
|
||||||
*
|
*
|
||||||
|
|||||||
Reference in New Issue
Block a user