feat: deliver Python workflow client
This commit is contained in:
@@ -0,0 +1,37 @@
|
|||||||
|
# Task 7 Report
|
||||||
|
|
||||||
|
## Delivered
|
||||||
|
|
||||||
|
- Added a real `httpx.ASGITransport` proof for capability discovery, local
|
||||||
|
authoring, remote validation, immutable artifact save, deployment selection,
|
||||||
|
and durable run execution.
|
||||||
|
- Added bounded, inert `repr()` and `_repr_html_()` implementations for the
|
||||||
|
Python client's loaded capability, artifact, deployment, validation, run,
|
||||||
|
diagnostic, and trace objects. The shared private renderer HTML-escapes,
|
||||||
|
bounds nested previews, and redacts credential-shaped keys without touching
|
||||||
|
the client port.
|
||||||
|
- Added the `wf_client` package map, source/API boundary notes, and a concrete
|
||||||
|
Python walkthrough that explains the artifact -> deployment -> run model.
|
||||||
|
- Added an opt-out seam for server draft composition. `drafts=False` skips
|
||||||
|
draft service construction, does not require a draft store, and omits draft
|
||||||
|
JSON-RPC methods; the existing implementation remains available to explicit
|
||||||
|
draft-enabled callers.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
- `uv run pytest tests/wf_client -q` — 46 passed.
|
||||||
|
- Focused/cross-layer Task 7 selection — 288 passed.
|
||||||
|
- `uv run pytest tests/wf_api/test_durable_context.py tests/wf_transport_rpc_http/test_app.py::test_rpc_app_can_omit_draft_methods -q` — passed.
|
||||||
|
- Ruff check and basedpyright for changed client/API/transport surfaces — passed.
|
||||||
|
- `uv run python -m wf_contract_manifest check` — passed.
|
||||||
|
- `pnpm --dir web --filter @lda/workflow-rpc contract:check` — passed.
|
||||||
|
- `pnpm --dir web --filter @lda/workflow-rpc test` — 151 passed, 3 skipped.
|
||||||
|
|
||||||
|
The broader repository format check still reports pre-existing formatting
|
||||||
|
differences in `src/wf_api/deployments.py`, `src/wf_authoring/builder/core.py`,
|
||||||
|
and `tests/wf_client/test_authoring.py`; no formatting errors remain in the
|
||||||
|
changed Task 7 files. The full `uv run pytest -q` run reached 2,613 passed,
|
||||||
|
1 skipped, and 1 xfailed; three failures were external to this change: two
|
||||||
|
legacy direct-service/draft tests were fixed by retaining the default-enabled
|
||||||
|
constructor compatibility, while the remaining thesis asset test expects
|
||||||
|
untracked PDF figures absent from the base worktree.
|
||||||
@@ -22,6 +22,15 @@ 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.
|
||||||
|
|
||||||
|
Completed: 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.
|
||||||
|
|
||||||
## 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,43 @@ 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. This is the complete shape of a real client call; the schema
|
||||||
|
arguments may be JSON Schema dictionaries or the application's schema model
|
||||||
|
values:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from wf_client import App
|
||||||
|
|
||||||
|
app = App.from_http_jsonrpc("http://localhost:8765/rpc")
|
||||||
|
capability = await app.capability("wf.std.constant")
|
||||||
|
graph = app.new_workflow(
|
||||||
|
"example",
|
||||||
|
input_schema=InputModel,
|
||||||
|
state_schema=StateModel,
|
||||||
|
output_schema=OutputModel,
|
||||||
|
)
|
||||||
|
step = graph.use(capability)
|
||||||
|
graph.set_entry_point(step)
|
||||||
|
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.
|
||||||
|
|||||||
@@ -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,40 @@ 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
|
||||||
|
|
||||||
|
The Python client makes the intended application flow explicit:
|
||||||
|
|
||||||
|
```python
|
||||||
|
app = App.from_http_jsonrpc("http://localhost:8765/rpc")
|
||||||
|
capability = await app.capability("wf.std.constant")
|
||||||
|
graph = app.new_workflow(
|
||||||
|
"example",
|
||||||
|
input_schema=InputModel,
|
||||||
|
state_schema=StateModel,
|
||||||
|
output_schema=OutputModel,
|
||||||
|
)
|
||||||
|
step = graph.use(capability)
|
||||||
|
graph.set_entry_point(step)
|
||||||
|
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
|
||||||
|
|||||||
@@ -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 = True,
|
||||||
|
) -> 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 = True,
|
||||||
|
) -> 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"]
|
||||||
|
|||||||
+32
-4
@@ -1,7 +1,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from collections.abc import Mapping, Sequence
|
from collections.abc import Mapping, Sequence
|
||||||
from typing import Any, Literal, overload
|
from typing import Any, Literal, cast, overload
|
||||||
|
|
||||||
from wf_artifacts import ArtifactKind, compile_workflow_draft
|
from wf_artifacts import ArtifactKind, compile_workflow_draft
|
||||||
from wf_artifacts.drafts.models import DraftStep
|
from wf_artifacts.drafts.models import DraftStep
|
||||||
@@ -56,6 +56,19 @@ from .operation_context import WorkflowOperationContext
|
|||||||
from .runs import TraceRangeLike, WorkflowRunApi
|
from .runs import TraceRangeLike, WorkflowRunApi
|
||||||
|
|
||||||
|
|
||||||
|
class _DisabledDraftSurface:
|
||||||
|
"""Placeholder that fails clearly if disabled draft methods are called.
|
||||||
|
|
||||||
|
Keeping this tiny seam avoids constructing draft services while preserving
|
||||||
|
the existing method layout on ``WorkflowApi`` for explicit draft callers.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __getattr__(self, name: str) -> Any:
|
||||||
|
raise RuntimeError(
|
||||||
|
"workflow draft APIs are disabled; compose WorkflowApi with drafts=True"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _authoring_schema(
|
def _authoring_schema(
|
||||||
value: object,
|
value: object,
|
||||||
*,
|
*,
|
||||||
@@ -100,11 +113,26 @@ 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 = True,
|
||||||
|
) -> None:
|
||||||
self.context = context
|
self.context = context
|
||||||
self.capabilities = WorkflowCapabilityApi(context)
|
self.capabilities = WorkflowCapabilityApi(context)
|
||||||
self.drafts = WorkflowDraftApi(context)
|
# ``drafts`` keeps server composition explicit: callers that omit draft
|
||||||
self.draft_authoring = WorkflowDraftAuthoringApi(context, self.drafts)
|
# storage must pass False, while the default preserves legacy direct
|
||||||
|
# WorkflowApi callers that use the draft service for validation only.
|
||||||
|
self.drafts_enabled = drafts
|
||||||
|
if self.drafts_enabled:
|
||||||
|
self.drafts = WorkflowDraftApi(context)
|
||||||
|
self.draft_authoring = WorkflowDraftAuthoringApi(context, self.drafts)
|
||||||
|
else:
|
||||||
|
self.drafts = cast(WorkflowDraftApi, _DisabledDraftSurface())
|
||||||
|
self.draft_authoring = cast(
|
||||||
|
WorkflowDraftAuthoringApi, _DisabledDraftSurface()
|
||||||
|
)
|
||||||
self.artifacts = WorkflowArtifactApi(context)
|
self.artifacts = WorkflowArtifactApi(context)
|
||||||
self.deployments = WorkflowDeploymentApi(context)
|
self.deployments = WorkflowDeploymentApi(context)
|
||||||
self.runs = WorkflowRunApi(context)
|
self.runs = WorkflowRunApi(context)
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ 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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,103 @@
|
|||||||
|
"""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
|
||||||
|
from collections.abc import Mapping, Sequence
|
||||||
|
|
||||||
|
_SECRET_KEY_PARTS = (
|
||||||
|
"authorization",
|
||||||
|
"cookie",
|
||||||
|
"set-cookie",
|
||||||
|
"token",
|
||||||
|
"secret",
|
||||||
|
"password",
|
||||||
|
"api_key",
|
||||||
|
"api-key",
|
||||||
|
)
|
||||||
|
_MAX_DEPTH = 2
|
||||||
|
_MAX_ITEMS = 8
|
||||||
|
_MAX_STRING = 160
|
||||||
|
_MAX_RENDERED = 1_200
|
||||||
|
|
||||||
|
|
||||||
|
def _secret_key(key: object) -> bool:
|
||||||
|
lowered = str(key).lower()
|
||||||
|
return any(part in lowered for part in _SECRET_KEY_PARTS)
|
||||||
|
|
||||||
|
|
||||||
|
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):
|
||||||
|
items = list(value.items())
|
||||||
|
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["…"] = f"{len(items) - _MAX_ITEMS} more entries"
|
||||||
|
return preview
|
||||||
|
if isinstance(value, Sequence) and not isinstance(value, str | bytes | bytearray):
|
||||||
|
items = list(value)
|
||||||
|
preview = [bounded_value(item, depth=depth + 1) for item in items[:_MAX_ITEMS]]
|
||||||
|
if len(items) > _MAX_ITEMS:
|
||||||
|
preview.append(f"… {len(items) - _MAX_ITEMS} 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>"
|
||||||
|
)
|
||||||
@@ -13,6 +13,7 @@ from wf_artifacts.models import DependencyDiagnostic
|
|||||||
from wf_core.models.schemas import NodeDef, SchemaRef
|
from wf_core.models.schemas import NodeDef, SchemaRef
|
||||||
from wf_platform import CapabilityRef
|
from wf_platform import CapabilityRef
|
||||||
|
|
||||||
|
from ._repr import html_repr, short_repr
|
||||||
from .codec import decode_capability_call, decode_capability_diagnostics
|
from .codec import decode_capability_call, decode_capability_diagnostics
|
||||||
from .errors import InvalidResponse
|
from .errors import InvalidResponse
|
||||||
from .protocols import WorkflowClientPort
|
from .protocols import WorkflowClientPort
|
||||||
@@ -39,6 +40,24 @@ class CapabilitySummary:
|
|||||||
"""Compatibility alias for the wire row's ``name`` field."""
|
"""Compatibility alias for the wire row's ``name`` field."""
|
||||||
return self.qualified_name
|
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)
|
@dataclass(frozen=True, slots=True)
|
||||||
class CapabilityResult:
|
class CapabilityResult:
|
||||||
@@ -48,6 +67,22 @@ class CapabilityResult:
|
|||||||
output: dict[str, Any] | None
|
output: dict[str, Any] | None
|
||||||
diagnostics: tuple[DependencyDiagnostic, ...]
|
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]:
|
def _check_schema(schema: object, *, operation: str) -> dict[str, Any]:
|
||||||
if not isinstance(schema, Mapping):
|
if not isinstance(schema, Mapping):
|
||||||
@@ -106,6 +141,27 @@ class RemoteCapability:
|
|||||||
)
|
)
|
||||||
object.__setattr__(self, "outcomes", tuple(self.outcomes))
|
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__(
|
async def __call__(
|
||||||
self,
|
self,
|
||||||
payload: Mapping[str, Any] | None = None,
|
payload: Mapping[str, Any] | None = None,
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ from typing import Any
|
|||||||
|
|
||||||
from wf_artifacts import DependencyDiagnostic, DriftPolicy, WorkflowDeployment
|
from wf_artifacts import DependencyDiagnostic, DriftPolicy, WorkflowDeployment
|
||||||
|
|
||||||
|
from ._repr import html_repr, short_repr
|
||||||
from .codec import (
|
from .codec import (
|
||||||
decode_dependency_diagnostics,
|
decode_dependency_diagnostics,
|
||||||
decode_deployment,
|
decode_deployment,
|
||||||
@@ -33,6 +34,22 @@ class DeploymentValidation:
|
|||||||
def runnable(self) -> bool:
|
def runnable(self) -> bool:
|
||||||
return self.status == "runnable"
|
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)
|
@dataclass(frozen=True, slots=True)
|
||||||
class Deployment:
|
class Deployment:
|
||||||
@@ -51,6 +68,25 @@ class Deployment:
|
|||||||
def deployment_id(self) -> str:
|
def deployment_id(self) -> str:
|
||||||
return self.model.id
|
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
|
@property
|
||||||
def artifact_id(self) -> str:
|
def artifact_id(self) -> str:
|
||||||
return self.model.artifact_id
|
return self.model.artifact_id
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ from wf_api import TraceRange
|
|||||||
from wf_artifacts import DependencyDiagnostic
|
from wf_artifacts import DependencyDiagnostic
|
||||||
from wf_core import InterruptRequest, InterruptRoute, TraceEntry, WorkflowRef
|
from wf_core import InterruptRequest, InterruptRoute, TraceEntry, WorkflowRef
|
||||||
|
|
||||||
|
from ._repr import html_repr, short_repr
|
||||||
from .codec import DecodedRunResult, decode_run_result, decode_trace_result
|
from .codec import DecodedRunResult, decode_run_result, decode_trace_result
|
||||||
from .errors import DeploymentNotRunnable, InvalidResponse
|
from .errors import DeploymentNotRunnable, InvalidResponse
|
||||||
from .protocols import WorkflowClientPort
|
from .protocols import WorkflowClientPort
|
||||||
@@ -27,6 +28,24 @@ class TracePage:
|
|||||||
truncated: bool
|
truncated: bool
|
||||||
trace_count: int
|
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(
|
def _interrupt(
|
||||||
payload: Mapping[str, Any] | None,
|
payload: Mapping[str, Any] | None,
|
||||||
@@ -110,6 +129,30 @@ class Run:
|
|||||||
diagnostics: tuple[DependencyDiagnostic, ...]
|
diagnostics: tuple[DependencyDiagnostic, ...]
|
||||||
trace_count: int
|
trace_count: int
|
||||||
|
|
||||||
|
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
|
@classmethod
|
||||||
def from_payload(
|
def from_payload(
|
||||||
cls,
|
cls,
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ from wf_artifacts.models import (
|
|||||||
)
|
)
|
||||||
from wf_core import ValidationReport, Workflow
|
from wf_core import ValidationReport, Workflow
|
||||||
|
|
||||||
|
from ._repr import html_repr, short_repr
|
||||||
from .errors import InvalidResponse, ValidationFailed
|
from .errors import InvalidResponse, ValidationFailed
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
@@ -30,6 +31,16 @@ class ArtifactRef:
|
|||||||
artifact_id: str
|
artifact_id: str
|
||||||
version: int
|
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)
|
@dataclass(frozen=True, slots=True)
|
||||||
class WorkflowDiagnostic:
|
class WorkflowDiagnostic:
|
||||||
@@ -41,6 +52,24 @@ class WorkflowDiagnostic:
|
|||||||
message: str
|
message: str
|
||||||
repair_hint: str | None = None
|
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
|
# Keep the short name used by the public design available without requiring a
|
||||||
# second diagnostic implementation.
|
# second diagnostic implementation.
|
||||||
@@ -59,6 +88,22 @@ class WorkflowValidation:
|
|||||||
def ok(self) -> bool:
|
def ok(self) -> bool:
|
||||||
return self.local.ok and self.remote_status == "valid"
|
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:
|
def raise_for_errors(self) -> None:
|
||||||
"""Raise a useful error for either local or remote validation failures."""
|
"""Raise a useful error for either local or remote validation failures."""
|
||||||
self.local.raise_for_errors()
|
self.local.raise_for_errors()
|
||||||
@@ -86,6 +131,23 @@ class WorkflowArtifact:
|
|||||||
def ref(self) -> ArtifactRef:
|
def ref(self) -> ArtifactRef:
|
||||||
return ArtifactRef(self.artifact.id, self.artifact.version)
|
return ArtifactRef(self.artifact.id, self.artifact.version)
|
||||||
|
|
||||||
|
def __repr__(self) -> str:
|
||||||
|
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
|
@property
|
||||||
def title(self) -> str:
|
def title(self) -> str:
|
||||||
return self.artifact.title
|
return self.artifact.title
|
||||||
@@ -137,8 +199,13 @@ class WorkflowArtifact:
|
|||||||
"drift_policy": drift_policy,
|
"drift_policy": drift_policy,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
if not isinstance(saved, Mapping) or saved.get("deployment_id") != deployment_id:
|
if (
|
||||||
saved_id = saved.get("deployment_id") if isinstance(saved, Mapping) else None
|
not isinstance(saved, Mapping)
|
||||||
|
or saved.get("deployment_id") != deployment_id
|
||||||
|
):
|
||||||
|
saved_id = (
|
||||||
|
saved.get("deployment_id") if isinstance(saved, Mapping) else None
|
||||||
|
)
|
||||||
raise InvalidResponse(
|
raise InvalidResponse(
|
||||||
operation="workflow.deployments.save",
|
operation="workflow.deployments.save",
|
||||||
details=(
|
details=(
|
||||||
|
|||||||
@@ -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 | None = None,
|
||||||
|
) -> 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,9 @@ 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)
|
drafts_enabled = server.api.drafts_enabled if drafts is None else drafts
|
||||||
|
if drafts_enabled:
|
||||||
|
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)
|
||||||
|
|||||||
@@ -54,3 +54,18 @@ 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
|
||||||
|
|||||||
@@ -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,117 @@
|
|||||||
|
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_all_rich_objects_render_without_port_access() -> None:
|
||||||
|
port = cast(WorkflowClientPort, _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 port.calls == []
|
||||||
@@ -32,6 +32,17 @@ 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")
|
||||||
|
|
||||||
|
assert server.api.drafts_enabled is True
|
||||||
|
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 _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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user