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
|
||||
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
|
||||
|
||||
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_server` | Durable server composition boundary around `WorkflowApi` plus optional admin/source-registry surfaces. Owns the `wf-rpc-server` startup CLI/policy. | Transport packages and server startup code. |
|
||||
| `wf_transport_rpc_http` | JSON-RPC-over-HTTP app/client and compatibility CLI shim. | Remote `wf` clients and local server smoke tests. |
|
||||
| `wf_client` | Async-native Python client for capability discovery, local authoring, immutable artifacts, deployments, and durable runs. | Python applications and notebooks using a workflow server. |
|
||||
| `wf_sources_mcp` | MCP-as-upstream-source implementation: ids, registry DTOs, auth/catalog stores, discovery, SDK client/facade, runtime pool, wrappers. | `wf_server`, broker glue, MCP source tests. |
|
||||
| `wf_mcp` | MCP frontend/compatibility package: legacy `wf-mcp` entrypoints, broker glue, proxy/admin tools, and shims while extraction continues. | Compatibility callers and MCP transport work. |
|
||||
| `wf_cli` | Command-line frontend over local or remote workflow APIs. | Humans, scripts, agent skills. |
|
||||
@@ -87,6 +88,8 @@ permanent graph node or expose it as a final workflow-output source.
|
||||
- `wf_server.WorkflowServer`: durable workflow server composition object.
|
||||
- `wf_transport_rpc_http.RpcWorkflowApiClient`: JSON-RPC client implementing
|
||||
the workflow/admin surfaces over HTTP.
|
||||
- `wf_client.App`: transport-independent async Python facade over capabilities,
|
||||
authored workflows, saved artifacts, deployments, and durable runs.
|
||||
- `wf_transport_rpc_http.create_rpc_app`: JSON-RPC HTTP adapter over an existing
|
||||
`WorkflowServer`.
|
||||
- `wf_sources_mcp.McpRuntimePool`: persistent MCP source runtime for stateful
|
||||
@@ -121,6 +124,43 @@ permanent graph node or expose it as a final workflow-output source.
|
||||
`--keep-temp` to preserve the generated config/store on failure.
|
||||
- `examples/browser_click_workflow/` is a serial browser-click workflow
|
||||
example with bounded before/after snapshots and full lifecycle tests.
|
||||
|
||||
### Python client walkthrough
|
||||
|
||||
The Python client is intended for an application that already has a running
|
||||
workflow server. 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
|
||||
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_server` | `WorkflowServer` composition from config/store/source providers. | JSON-RPC method definitions, MCP protocol frontend. |
|
||||
| `wf_transport_rpc_http` | JSON-RPC HTTP app/client around an existing `WorkflowServer`. | Server startup policy, source-provider composition. |
|
||||
| `wf_client` | Async Python facade over a narrow client port: capability discovery, local authoring, artifact/deployment snapshots, and durable runs. | Draft workspace authoring, server composition, transport registration. |
|
||||
| `wf_mcp` | Legacy/special-purpose MCP frontend, broker glue, proxy, compatibility shims. | New durable product behavior unless explicitly retiring old callers. |
|
||||
|
||||
## Data Flow
|
||||
@@ -65,6 +66,17 @@ wf_config.server.sources[]
|
||||
-> transport or CLI
|
||||
```
|
||||
|
||||
Python applications consume the same server through `wf_client.App`; the
|
||||
client's local builder and immutable snapshots sit above the transport:
|
||||
|
||||
```text
|
||||
wf_client.App
|
||||
-> WorkflowClientPort
|
||||
-> RpcWorkflowApiClient
|
||||
-> JSON-RPC HTTP
|
||||
-> WorkflowServer / WorkflowApi
|
||||
```
|
||||
|
||||
The first shared provider seam is intentionally static:
|
||||
|
||||
```python
|
||||
@@ -212,3 +224,18 @@ Then add:
|
||||
|
||||
Do not add source-family branches inside `wf_api` run execution. Source-specific
|
||||
logic belongs in the provider package or server composition layer.
|
||||
|
||||
## Python client boundary
|
||||
|
||||
`wf_client` is a consumer-facing layer, not another server API.
|
||||
`App.from_http_jsonrpc()` creates a lazy HTTP transport; the first
|
||||
capability/artifact/deployment/run operation performs I/O. `EditableWorkflow`
|
||||
keeps graph construction local and uses the port only for remote validation and
|
||||
artifact persistence. The server remains responsible for durable artifact,
|
||||
deployment, and run stores and for resolving concrete source bindings.
|
||||
|
||||
The client intentionally omits draft workspace operations. Drafts are a
|
||||
server-side authoring/admin surface, and a server composition must opt in to
|
||||
registering their JSON-RPC methods. This keeps a normal Python application
|
||||
focused on the stable artifact -> deployment -> run lifecycle while retaining
|
||||
the underlying draft implementation for explicit server users.
|
||||
|
||||
@@ -20,6 +20,7 @@ frontends can share.
|
||||
| `wf_sources_mcp` | MCP-as-upstream-source implementation: source ids, source registry DTOs, auth/catalog stores, discovery, SDK client/facade, persistent runtime pool, and tool-wrapper helpers. |
|
||||
| `wf_mcp` | MCP frontend/compatibility package: old `wf-mcp` server entry points, broker glue around MCP-hosted services, proxy/admin tools, and compatibility shims while callers migrate. |
|
||||
| `wf_transport_rpc_http` | JSON-RPC-over-HTTP transport adapter and remote client over `WorkflowApiSurface`, not a reimplementation of workflow business logic. |
|
||||
| `wf_client` | Async Python consumer facade over a narrow capability/artifact/deployment/run port. It reconstructs immutable snapshots and keeps representations bounded and inert. |
|
||||
| future `wf_http` / WebSocket / MCP server transports | Additional transports over `WorkflowApiSurface`, not new workflow application APIs. |
|
||||
| `wf_cli` | CLI frontend over `WorkflowApiSurface`; it may run locally against process-local stores or target a remote JSON-RPC backend. |
|
||||
|
||||
@@ -107,6 +108,40 @@ Important rules:
|
||||
Do not add a catch-all `service` field to the context. If a domain API needs a
|
||||
new dependency, add a narrow protocol or explicit field.
|
||||
|
||||
## Python client lifecycle
|
||||
|
||||
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` is the public application contract shared by local and
|
||||
|
||||
@@ -5,25 +5,27 @@ from .service import WorkflowApi
|
||||
from .stores import WorkflowStores
|
||||
|
||||
|
||||
def require_workflow_stores(context: WorkflowOperationContext) -> WorkflowStores:
|
||||
def require_workflow_stores(
|
||||
context: WorkflowOperationContext,
|
||||
*,
|
||||
drafts: bool = True,
|
||||
) -> WorkflowStores:
|
||||
"""Return required stores or fail before constructing durable frontends.
|
||||
|
||||
`WorkflowOperationContext` keeps stores optional for compatibility tests and
|
||||
lightweight MCP surfaces. Durable API surfaces need all stores up front so a
|
||||
run cannot start without somewhere to persist artifacts, drafts, and stopped
|
||||
execution state.
|
||||
lightweight MCP surfaces. Durable API surfaces need artifact/run stores up
|
||||
front; a draft store is only required when draft APIs are enabled.
|
||||
"""
|
||||
missing = []
|
||||
if context.artifact_store is None:
|
||||
missing.append("artifact_store")
|
||||
if context.draft_workspace_store is None:
|
||||
if drafts and context.draft_workspace_store is None:
|
||||
missing.append("draft_workspace_store")
|
||||
if context.run_store is None:
|
||||
missing.append("run_store")
|
||||
if missing:
|
||||
raise ValueError("durable workflow API requires stores: " + ", ".join(missing))
|
||||
assert context.artifact_store is not None
|
||||
assert context.draft_workspace_store is not None
|
||||
assert context.run_store is not None
|
||||
return WorkflowStores(
|
||||
artifact_store=context.artifact_store,
|
||||
@@ -32,10 +34,14 @@ def require_workflow_stores(context: WorkflowOperationContext) -> WorkflowStores
|
||||
)
|
||||
|
||||
|
||||
def durable_workflow_api(context: WorkflowOperationContext) -> WorkflowApi:
|
||||
"""Construct a WorkflowApi only after durable store dependencies exist."""
|
||||
require_workflow_stores(context)
|
||||
return WorkflowApi(context)
|
||||
def durable_workflow_api(
|
||||
context: WorkflowOperationContext,
|
||||
*,
|
||||
drafts: bool = 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"]
|
||||
|
||||
+32
-4
@@ -1,7 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
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.drafts.models import DraftStep
|
||||
@@ -56,6 +56,19 @@ from .operation_context import WorkflowOperationContext
|
||||
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(
|
||||
value: object,
|
||||
*,
|
||||
@@ -100,11 +113,26 @@ class WorkflowApi:
|
||||
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.capabilities = WorkflowCapabilityApi(context)
|
||||
self.drafts = WorkflowDraftApi(context)
|
||||
self.draft_authoring = WorkflowDraftAuthoringApi(context, self.drafts)
|
||||
# ``drafts`` keeps server composition explicit: callers that omit draft
|
||||
# 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.deployments = WorkflowDeploymentApi(context)
|
||||
self.runs = WorkflowRunApi(context)
|
||||
|
||||
@@ -18,7 +18,7 @@ class WorkflowStores:
|
||||
"""Protocol-neutral persistence dependencies for workflow APIs."""
|
||||
|
||||
artifact_store: WorkflowArtifactStore
|
||||
draft_workspace_store: DraftWorkspaceStore
|
||||
draft_workspace_store: DraftWorkspaceStore | None
|
||||
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_platform import CapabilityRef
|
||||
|
||||
from ._repr import html_repr, short_repr
|
||||
from .codec import decode_capability_call, decode_capability_diagnostics
|
||||
from .errors import InvalidResponse
|
||||
from .protocols import WorkflowClientPort
|
||||
@@ -39,6 +40,24 @@ class CapabilitySummary:
|
||||
"""Compatibility alias for the wire row's ``name`` field."""
|
||||
return self.qualified_name
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return short_repr(
|
||||
type(self).__name__,
|
||||
name=self.qualified_name,
|
||||
source=self.source_id,
|
||||
outcomes=self.outcomes,
|
||||
)
|
||||
|
||||
def _repr_html_(self) -> str:
|
||||
return html_repr(
|
||||
type(self).__name__,
|
||||
name=self.qualified_name,
|
||||
source=self.source_id,
|
||||
outcomes=self.outcomes,
|
||||
inputs=f"{len(self.input_fields)} fields",
|
||||
outputs=f"{len(self.output_fields)} fields",
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class CapabilityResult:
|
||||
@@ -48,6 +67,22 @@ class CapabilityResult:
|
||||
output: dict[str, Any] | None
|
||||
diagnostics: tuple[DependencyDiagnostic, ...]
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return short_repr(
|
||||
type(self).__name__,
|
||||
outcome=self.outcome,
|
||||
output=self.output,
|
||||
diagnostics=f"{len(self.diagnostics)} diagnostics",
|
||||
)
|
||||
|
||||
def _repr_html_(self) -> str:
|
||||
return html_repr(
|
||||
type(self).__name__,
|
||||
outcome=self.outcome,
|
||||
output=self.output,
|
||||
diagnostics=f"{len(self.diagnostics)} diagnostics",
|
||||
)
|
||||
|
||||
|
||||
def _check_schema(schema: object, *, operation: str) -> dict[str, Any]:
|
||||
if not isinstance(schema, Mapping):
|
||||
@@ -106,6 +141,27 @@ class RemoteCapability:
|
||||
)
|
||||
object.__setattr__(self, "outcomes", tuple(self.outcomes))
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return short_repr(
|
||||
type(self).__name__,
|
||||
name=self.qualified_name,
|
||||
outcomes=self.outcomes,
|
||||
input_schema=f"{len(self.input_schema)} keys",
|
||||
output_schema=f"{len(self.output_schema)} keys",
|
||||
)
|
||||
|
||||
def _repr_html_(self) -> str:
|
||||
return html_repr(
|
||||
type(self).__name__,
|
||||
name=self.qualified_name,
|
||||
description=self.description,
|
||||
outcomes=self.outcomes,
|
||||
**{
|
||||
"input schema": f"{len(self.input_schema)} keys",
|
||||
"output schema": f"{len(self.output_schema)} keys",
|
||||
},
|
||||
)
|
||||
|
||||
async def __call__(
|
||||
self,
|
||||
payload: Mapping[str, Any] | None = None,
|
||||
|
||||
@@ -8,6 +8,7 @@ from typing import Any
|
||||
|
||||
from wf_artifacts import DependencyDiagnostic, DriftPolicy, WorkflowDeployment
|
||||
|
||||
from ._repr import html_repr, short_repr
|
||||
from .codec import (
|
||||
decode_dependency_diagnostics,
|
||||
decode_deployment,
|
||||
@@ -33,6 +34,22 @@ class DeploymentValidation:
|
||||
def runnable(self) -> bool:
|
||||
return self.status == "runnable"
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return short_repr(
|
||||
type(self).__name__,
|
||||
deployment_id=self.deployment_id,
|
||||
status=self.status,
|
||||
diagnostics=f"{len(self.diagnostics)} diagnostics",
|
||||
)
|
||||
|
||||
def _repr_html_(self) -> str:
|
||||
return html_repr(
|
||||
type(self).__name__,
|
||||
deployment_id=self.deployment_id,
|
||||
status=self.status,
|
||||
diagnostics=f"{len(self.diagnostics)} diagnostics",
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Deployment:
|
||||
@@ -51,6 +68,25 @@ class Deployment:
|
||||
def deployment_id(self) -> str:
|
||||
return self.model.id
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return short_repr(
|
||||
type(self).__name__,
|
||||
deployment_id=self.deployment_id,
|
||||
artifact=f"{self.artifact_id}.v{self.artifact_version}",
|
||||
runnable=self.runnable,
|
||||
diagnostics=f"{len(self.diagnostics)} diagnostics",
|
||||
)
|
||||
|
||||
def _repr_html_(self) -> str:
|
||||
return html_repr(
|
||||
type(self).__name__,
|
||||
deployment_id=self.deployment_id,
|
||||
artifact=f"{self.artifact_id}.v{self.artifact_version}",
|
||||
bindings=f"{len(self.bindings)} bindings",
|
||||
runnable=self.runnable,
|
||||
diagnostics=f"{len(self.diagnostics)} diagnostics",
|
||||
)
|
||||
|
||||
@property
|
||||
def artifact_id(self) -> str:
|
||||
return self.model.artifact_id
|
||||
|
||||
@@ -12,6 +12,7 @@ from wf_api import TraceRange
|
||||
from wf_artifacts import DependencyDiagnostic
|
||||
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 .errors import DeploymentNotRunnable, InvalidResponse
|
||||
from .protocols import WorkflowClientPort
|
||||
@@ -27,6 +28,24 @@ class TracePage:
|
||||
truncated: bool
|
||||
trace_count: int
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return short_repr(
|
||||
type(self).__name__,
|
||||
start=self.start,
|
||||
limit=self.limit,
|
||||
frames=f"{len(self.frames)} loaded/{self.trace_count} total",
|
||||
truncated=self.truncated,
|
||||
)
|
||||
|
||||
def _repr_html_(self) -> str:
|
||||
return html_repr(
|
||||
type(self).__name__,
|
||||
start=self.start,
|
||||
limit=self.limit,
|
||||
frames=f"{len(self.frames)} loaded/{self.trace_count} total",
|
||||
truncated=self.truncated,
|
||||
)
|
||||
|
||||
|
||||
def _interrupt(
|
||||
payload: Mapping[str, Any] | None,
|
||||
@@ -110,6 +129,30 @@ class Run:
|
||||
diagnostics: tuple[DependencyDiagnostic, ...]
|
||||
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
|
||||
def from_payload(
|
||||
cls,
|
||||
|
||||
@@ -14,6 +14,7 @@ from wf_artifacts.models import (
|
||||
)
|
||||
from wf_core import ValidationReport, Workflow
|
||||
|
||||
from ._repr import html_repr, short_repr
|
||||
from .errors import InvalidResponse, ValidationFailed
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -30,6 +31,16 @@ class ArtifactRef:
|
||||
artifact_id: str
|
||||
version: int
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return short_repr(
|
||||
type(self).__name__, artifact_id=self.artifact_id, version=self.version
|
||||
)
|
||||
|
||||
def _repr_html_(self) -> str:
|
||||
return html_repr(
|
||||
type(self).__name__, artifact_id=self.artifact_id, version=self.version
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class WorkflowDiagnostic:
|
||||
@@ -41,6 +52,24 @@ class WorkflowDiagnostic:
|
||||
message: str
|
||||
repair_hint: str | None = None
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return short_repr(
|
||||
type(self).__name__,
|
||||
severity=self.severity,
|
||||
code=self.code,
|
||||
path=self.path,
|
||||
message=self.message,
|
||||
)
|
||||
|
||||
def _repr_html_(self) -> str:
|
||||
return html_repr(
|
||||
type(self).__name__,
|
||||
severity=self.severity,
|
||||
code=self.code,
|
||||
path=self.path,
|
||||
message=self.message,
|
||||
)
|
||||
|
||||
|
||||
# Keep the short name used by the public design available without requiring a
|
||||
# second diagnostic implementation.
|
||||
@@ -59,6 +88,22 @@ class WorkflowValidation:
|
||||
def ok(self) -> bool:
|
||||
return self.local.ok and self.remote_status == "valid"
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return short_repr(
|
||||
type(self).__name__,
|
||||
ok=self.ok,
|
||||
remote_status=self.remote_status,
|
||||
diagnostics=f"{len(self.remote_diagnostics)} diagnostics",
|
||||
)
|
||||
|
||||
def _repr_html_(self) -> str:
|
||||
return html_repr(
|
||||
type(self).__name__,
|
||||
ok=self.ok,
|
||||
remote_status=self.remote_status,
|
||||
diagnostics=f"{len(self.remote_diagnostics)} diagnostics",
|
||||
)
|
||||
|
||||
def raise_for_errors(self) -> None:
|
||||
"""Raise a useful error for either local or remote validation failures."""
|
||||
self.local.raise_for_errors()
|
||||
@@ -86,6 +131,23 @@ class WorkflowArtifact:
|
||||
def ref(self) -> ArtifactRef:
|
||||
return ArtifactRef(self.artifact.id, self.artifact.version)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return short_repr(
|
||||
type(self).__name__,
|
||||
ref=self.ref,
|
||||
title=self.title,
|
||||
required_capabilities=f"{len(self.required_capabilities)} capabilities",
|
||||
)
|
||||
|
||||
def _repr_html_(self) -> str:
|
||||
return html_repr(
|
||||
type(self).__name__,
|
||||
ref=self.ref,
|
||||
title=self.title,
|
||||
description=self.description,
|
||||
required_capabilities=f"{len(self.required_capabilities)} capabilities",
|
||||
)
|
||||
|
||||
@property
|
||||
def title(self) -> str:
|
||||
return self.artifact.title
|
||||
@@ -137,8 +199,13 @@ class WorkflowArtifact:
|
||||
"drift_policy": drift_policy,
|
||||
}
|
||||
)
|
||||
if not isinstance(saved, Mapping) or saved.get("deployment_id") != deployment_id:
|
||||
saved_id = saved.get("deployment_id") if isinstance(saved, Mapping) else None
|
||||
if (
|
||||
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(
|
||||
operation="workflow.deployments.save",
|
||||
details=(
|
||||
|
||||
@@ -24,7 +24,12 @@ from .methods.source_registry import (
|
||||
from .methods.sources import register_methods as register_source_methods
|
||||
|
||||
|
||||
def create_rpc_app(server: WorkflowServer, *, rpc_path: str = "/rpc") -> jsonrpc.API:
|
||||
def create_rpc_app(
|
||||
server: WorkflowServer,
|
||||
*,
|
||||
rpc_path: str = "/rpc",
|
||||
drafts: bool | None = None,
|
||||
) -> jsonrpc.API:
|
||||
"""Build a JSON-RPC HTTP app over an existing WorkflowServer.
|
||||
|
||||
Transport code owns only JSON-RPC envelope handling. Workflow semantics stay
|
||||
@@ -49,7 +54,9 @@ def create_rpc_app(server: WorkflowServer, *, rpc_path: str = "/rpc") -> jsonrpc
|
||||
}
|
||||
|
||||
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_deployment_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 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()
|
||||
|
||||
|
||||
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]:
|
||||
"""Return the canonical keyed draft shared by stateless RPC tests."""
|
||||
return {
|
||||
|
||||
Reference in New Issue
Block a user