feat: deliver Python workflow client

This commit is contained in:
lda
2026-08-31 02:49:07 +07:00 Verified
parent d53b96fd7c
commit 5315d4b66e
18 changed files with 717 additions and 19 deletions
@@ -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.
+9
View File
@@ -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
+40
View File
@@ -25,6 +25,7 @@ For verified Python 3.14 dependency constraints and their removal criteria, see
| `wf_api` | Workflow application surface over core/artifacts/platform: capabilities, drafts, artifacts, deployments, runs, and source/admin surfaces. | `wf_cli`, `wf_server`, JSON-RPC clients, future transports. | | `wf_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.
+27
View File
@@ -51,6 +51,7 @@ OpenAPI, or a built-in source. It should see `CapabilitySource` and executable
| `wf_sources_python` | Trusted Python module registry loading and projection to `CapabilitySource`. | Authoring primitives, registry mutation/apply, sandboxing. | | `wf_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.
+35
View File
@@ -20,6 +20,7 @@ frontends can share.
| `wf_sources_mcp` | MCP-as-upstream-source implementation: source ids, source registry DTOs, auth/catalog stores, discovery, SDK client/facade, persistent runtime pool, and tool-wrapper helpers. | | `wf_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
+16 -10
View File
@@ -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
View File
@@ -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)
+1 -1
View File
@@ -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
+103
View File
@@ -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>"
)
+56
View File
@@ -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,
+36
View File
@@ -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
+43
View File
@@ -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,
+69 -2
View File
@@ -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=(
+9 -2
View File
@@ -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)
+15
View File
@@ -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
+61
View File
@@ -0,0 +1,61 @@
from __future__ import annotations
import httpx
import pytest
from wf_authoring import input_from, input_value, output_to, state_path
from wf_client import App, ArtifactRef
from wf_server import build_local_static_workflow_server
from wf_transport_rpc_http import RpcWorkflowApiClient, create_rpc_app
@pytest.mark.asyncio
async def test_http_app_calls_authors_saves_deploys_and_runs(tmp_path) -> None:
"""Prove the public client lifecycle against the real JSON-RPC ASGI app."""
server = build_local_static_workflow_server(tmp_path / "store")
rpc_app = create_rpc_app(server)
transport = httpx.ASGITransport(app=rpc_app)
async with httpx.AsyncClient(
transport=transport,
base_url="http://test",
) as http_client:
app = App._from_port(
RpcWorkflowApiClient(
url="http://test/rpc",
http_client=http_client,
)
)
constant = await app.capability("wf.std.constant")
graph = app.new_workflow(
"http_client_proof",
input_schema={"type": "object", "properties": {}},
state_schema={
"type": "object",
"properties": {"value": {"type": "string"}},
},
output_schema={
"type": "object",
"properties": {"value": {"type": "string"}},
"required": ["value"],
},
)
step = graph.use(
constant,
id="constant",
input=[input_value("value", "hello")],
output=[output_to("value", state_path("value"))],
)
end = graph.end("ok", id="end_ok")
graph.set_entry_point(step)
graph.connect(step, "ok", end)
graph.set_output([input_from(state_path("value"), "value")])
validation = await graph.validate()
artifact = await graph.save(version=1, title="HTTP client proof")
run = await artifact.run({})
assert validation.ok is True
assert artifact.ref == ArtifactRef("http_client_proof", 1)
assert run.status == "completed"
assert run.output == {"value": "hello"}
+117
View File
@@ -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 "&lt;carefully&gt;" in rendered
assert port.calls == []
def test_rich_representations_bound_large_values_and_redact_secret_like_fields() -> (
None
):
port = _port()
result = CapabilityResult(
outcome="ok",
output={"token": "do-not-show", "items": ["x" * 400] * 20},
diagnostics=(),
)
run = Run(
_port=cast(WorkflowClientPort, port),
run_id="run-1",
deployment_id="deployment-1",
status="completed",
outcome="ok",
output=result.output,
interrupt=None,
diagnostics=(),
trace_count=1000,
)
rendered = repr(run)
html = run._repr_html_()
assert len(rendered) <= 1_201
assert len(html) <= 2_500
assert "do-not-show" not in rendered
assert "do-not-show" not in html
assert "1000 frames" in rendered
assert port.calls == []
def test_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 == []
+11
View File
@@ -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 {