diff --git a/.superpowers/sdd/python-workflow-client/task-7-report.md b/.superpowers/sdd/python-workflow-client/task-7-report.md new file mode 100644 index 00000000..14fcf376 --- /dev/null +++ b/.superpowers/sdd/python-workflow-client/task-7-report.md @@ -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. diff --git a/docs/current_roadmap.md b/docs/current_roadmap.md index 2bc3d149..6871f9c3 100644 --- a/docs/current_roadmap.md +++ b/docs/current_roadmap.md @@ -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 diff --git a/docs/project_map.md b/docs/project_map.md index f807179a..695e9574 100644 --- a/docs/project_map.md +++ b/docs/project_map.md @@ -25,6 +25,7 @@ For verified Python 3.14 dependency constraints and their removal criteria, see | `wf_api` | Workflow application surface over core/artifacts/platform: capabilities, drafts, artifacts, deployments, runs, and source/admin surfaces. | `wf_cli`, `wf_server`, JSON-RPC clients, future transports. | | `wf_server` | Durable server composition boundary around `WorkflowApi` plus optional admin/source-registry surfaces. Owns the `wf-rpc-server` startup CLI/policy. | Transport packages and server startup code. | | `wf_transport_rpc_http` | JSON-RPC-over-HTTP app/client and compatibility CLI shim. | Remote `wf` clients and local server smoke tests. | +| `wf_client` | Async-native Python client for capability discovery, local authoring, immutable artifacts, deployments, and durable runs. | Python applications and notebooks using a workflow server. | | `wf_sources_mcp` | MCP-as-upstream-source implementation: ids, registry DTOs, auth/catalog stores, discovery, SDK client/facade, runtime pool, wrappers. | `wf_server`, broker glue, MCP source tests. | | `wf_mcp` | MCP frontend/compatibility package: legacy `wf-mcp` entrypoints, broker glue, proxy/admin tools, and shims while extraction continues. | Compatibility callers and MCP transport work. | | `wf_cli` | Command-line frontend over local or remote workflow APIs. | Humans, scripts, agent skills. | @@ -87,6 +88,8 @@ permanent graph node or expose it as a final workflow-output source. - `wf_server.WorkflowServer`: durable workflow server composition object. - `wf_transport_rpc_http.RpcWorkflowApiClient`: JSON-RPC client implementing the workflow/admin surfaces over HTTP. +- `wf_client.App`: transport-independent async Python facade over capabilities, + authored workflows, saved artifacts, deployments, and durable runs. - `wf_transport_rpc_http.create_rpc_app`: JSON-RPC HTTP adapter over an existing `WorkflowServer`. - `wf_sources_mcp.McpRuntimePool`: persistent MCP source runtime for stateful @@ -121,6 +124,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. diff --git a/docs/source_architecture.md b/docs/source_architecture.md index a91bdf46..ed964a71 100644 --- a/docs/source_architecture.md +++ b/docs/source_architecture.md @@ -51,6 +51,7 @@ OpenAPI, or a built-in source. It should see `CapabilitySource` and executable | `wf_sources_python` | Trusted Python module registry loading and projection to `CapabilitySource`. | Authoring primitives, registry mutation/apply, sandboxing. | | `wf_server` | `WorkflowServer` composition from config/store/source providers. | JSON-RPC method definitions, MCP protocol frontend. | | `wf_transport_rpc_http` | JSON-RPC HTTP app/client around an existing `WorkflowServer`. | Server startup policy, source-provider composition. | +| `wf_client` | Async Python facade over a narrow client port: capability discovery, local authoring, artifact/deployment snapshots, and durable runs. | Draft workspace authoring, server composition, transport registration. | | `wf_mcp` | Legacy/special-purpose MCP frontend, broker glue, proxy, compatibility shims. | New durable product behavior unless explicitly retiring old callers. | ## Data Flow @@ -65,6 +66,17 @@ wf_config.server.sources[] -> transport or CLI ``` +Python applications consume the same server through `wf_client.App`; the +client's local builder and immutable snapshots sit above the transport: + +```text +wf_client.App + -> WorkflowClientPort + -> RpcWorkflowApiClient + -> JSON-RPC HTTP + -> WorkflowServer / WorkflowApi +``` + The first shared provider seam is intentionally static: ```python @@ -212,3 +224,18 @@ Then add: Do not add source-family branches inside `wf_api` run execution. Source-specific logic belongs in the provider package or server composition layer. + +## Python client boundary + +`wf_client` is a consumer-facing layer, not another server API. +`App.from_http_jsonrpc()` creates a lazy HTTP transport; the first +capability/artifact/deployment/run operation performs I/O. `EditableWorkflow` +keeps graph construction local and uses the port only for remote validation and +artifact persistence. The server remains responsible for durable artifact, +deployment, and run stores and for resolving concrete source bindings. + +The client intentionally omits draft workspace operations. Drafts are a +server-side authoring/admin surface, and a server composition must opt in to +registering their JSON-RPC methods. This keeps a normal Python application +focused on the stable artifact -> deployment -> run lifecycle while retaining +the underlying draft implementation for explicit server users. diff --git a/docs/wf_api_architecture.md b/docs/wf_api_architecture.md index 3131c5c8..315ae5ca 100644 --- a/docs/wf_api_architecture.md +++ b/docs/wf_api_architecture.md @@ -20,6 +20,7 @@ frontends can share. | `wf_sources_mcp` | MCP-as-upstream-source implementation: source ids, source registry DTOs, auth/catalog stores, discovery, SDK client/facade, persistent runtime pool, and tool-wrapper helpers. | | `wf_mcp` | MCP frontend/compatibility package: old `wf-mcp` server entry points, broker glue around MCP-hosted services, proxy/admin tools, and compatibility shims while callers migrate. | | `wf_transport_rpc_http` | JSON-RPC-over-HTTP transport adapter and remote client over `WorkflowApiSurface`, not a reimplementation of workflow business logic. | +| `wf_client` | Async Python consumer facade over a narrow capability/artifact/deployment/run port. It reconstructs immutable snapshots and keeps representations bounded and inert. | | future `wf_http` / WebSocket / MCP server transports | Additional transports over `WorkflowApiSurface`, not new workflow application APIs. | | `wf_cli` | CLI frontend over `WorkflowApiSurface`; it may run locally against process-local stores or target a remote JSON-RPC backend. | @@ -107,6 +108,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 diff --git a/src/wf_api/durable_context.py b/src/wf_api/durable_context.py index 026e390f..102969f1 100644 --- a/src/wf_api/durable_context.py +++ b/src/wf_api/durable_context.py @@ -5,25 +5,27 @@ from .service import WorkflowApi from .stores import WorkflowStores -def require_workflow_stores(context: WorkflowOperationContext) -> WorkflowStores: +def require_workflow_stores( + context: WorkflowOperationContext, + *, + drafts: bool = 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"] diff --git a/src/wf_api/service.py b/src/wf_api/service.py index 641e9912..04ea7543 100644 --- a/src/wf_api/service.py +++ b/src/wf_api/service.py @@ -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) diff --git a/src/wf_api/stores.py b/src/wf_api/stores.py index 805a2ea7..68507a58 100644 --- a/src/wf_api/stores.py +++ b/src/wf_api/stores.py @@ -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 diff --git a/src/wf_client/_repr.py b/src/wf_client/_repr.py new file mode 100644 index 00000000..46e27241 --- /dev/null +++ b/src/wf_client/_repr.py @@ -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( + '
"
+ + html.escape(html_preview(value))
+ + "