652 lines
20 KiB
Markdown
652 lines
20 KiB
Markdown
# Python Workflow Client Design
|
|
|
|
## Status
|
|
|
|
Approved in conversation on 2026-08-30. This document specifies the public
|
|
Python object model; it does not authorize implementation until its companion
|
|
implementation plan is reviewed.
|
|
|
|
## Purpose
|
|
|
|
Add a public `wf_client` package for humans, IPython sessions, and AI agents
|
|
that have Python execution available. The package reconstructs workflow API
|
|
responses into useful Python objects, supports local graph authoring with
|
|
remote capabilities, saves immutable workflow versions through the existing
|
|
API, and operates deployments and runs without exposing JSON-RPC payloads.
|
|
|
|
This is not a new workflow backend. It is a deep Python interface over the
|
|
existing workflow API surface, HTTP JSON-RPC adapter, domain models, and
|
|
`WorkflowBuilder`.
|
|
|
|
## Design Principles
|
|
|
|
- `App` is the only connection entry point users need to learn.
|
|
- Network operations are async. A synchronous facade is allowed only if it can
|
|
be implemented without owning or nesting an event loop.
|
|
- Local graph mutations are synchronous; remote discovery, saving, deployment,
|
|
and execution are async.
|
|
- JSON serialization exists only at the transport seam. Public objects contain
|
|
validated domain values, not unchecked response dictionaries.
|
|
- Existing `wf_core`, `wf_artifacts`, and `wf_authoring` models remain the
|
|
source of truth. `wf_client` must not create parallel workflow semantics.
|
|
- Workflow artifacts remain immutable. Editing creates a mutable graph seeded
|
|
from an exact artifact version.
|
|
- Deployments remain explicit when source selection matters and become
|
|
conditional ceremony when no binding choice exists.
|
|
- Capability nodes and native subgraphs remain distinct authoring operations.
|
|
- Draft workspaces do not appear in the public `wf_client` interface.
|
|
|
|
## Hypothetical Usage
|
|
|
|
This section is the intended experience. Names may tighten during
|
|
implementation, but the lifecycle and amount of required ceremony are part of
|
|
the design contract.
|
|
|
|
### Connect and use one capability
|
|
|
|
```python
|
|
from wf_client import App
|
|
|
|
app = App.from_http_jsonrpc("http://localhost:8765/rpc")
|
|
|
|
search = await app.capability("app.default.search")
|
|
|
|
print(search.input_schema)
|
|
result = await search(query="durable workflow systems")
|
|
print(result.outcome)
|
|
print(result.output)
|
|
```
|
|
|
|
`search` is a validated Python object, not an RPC response dictionary. Calling
|
|
it validates input locally, uses the workflow capability operation, validates
|
|
the response, and returns a `CapabilityResult`.
|
|
|
|
### Build, save, and run a workflow
|
|
|
|
```python
|
|
from pydantic import BaseModel, Field
|
|
|
|
from wf_authoring import input_from, input_path, output_to, state_path
|
|
from wf_client import App
|
|
|
|
|
|
class ReportInput(BaseModel):
|
|
topic: str
|
|
|
|
|
|
class ReportState(BaseModel):
|
|
sources: list[str] = Field(default_factory=list)
|
|
|
|
|
|
class ReportOutput(BaseModel):
|
|
sources: list[str]
|
|
|
|
|
|
app = App.from_http_jsonrpc("http://localhost:8765/rpc")
|
|
search = await app.capability("app.default.search")
|
|
|
|
graph = app.new_workflow(
|
|
"report",
|
|
input_schema=ReportInput,
|
|
state_schema=ReportState,
|
|
output_schema=ReportOutput,
|
|
)
|
|
|
|
searched = graph.use(
|
|
search,
|
|
id="search",
|
|
input=[input_from(input_path("topic"), "query")],
|
|
output=[output_to("sources", state_path("sources"))],
|
|
)
|
|
done = graph.end("ok", id="end_ok")
|
|
|
|
graph.set_entry_point(searched)
|
|
graph.connect(searched, "ok", done)
|
|
graph.set_output([input_from(state_path("sources"), "sources")])
|
|
|
|
report_v1 = await graph.save(version=1, title="Research report")
|
|
run = await report_v1.run({"topic": "durable workflow systems"})
|
|
|
|
print(run.status)
|
|
print(run.output)
|
|
```
|
|
|
|
`graph` is an `EditableWorkflow`, which subclasses `WorkflowBuilder`. Existing
|
|
builder operations such as `use()`, `when()`, `choose()`, `match()`,
|
|
`foreach()`, `interrupt()`, `end()`, `connect()`, and `set_entry_point()` remain
|
|
available directly. `wf_client` adds remote-capability use, provenance,
|
|
lossless workflow output bindings, and `save()`.
|
|
|
|
The convenient `artifact.run()` form is available only when source bindings
|
|
are already unambiguous. It never chooses between accounts.
|
|
|
|
### Bind an environment explicitly
|
|
|
|
```python
|
|
production = await report_v1.deploy(
|
|
"report.production",
|
|
bindings={
|
|
"app.default": "company.production",
|
|
},
|
|
)
|
|
|
|
validation = await production.validate()
|
|
if not validation.runnable:
|
|
for diagnostic in validation.diagnostics:
|
|
print(diagnostic.message)
|
|
else:
|
|
run = await production.run({"topic": "durable workflow systems"})
|
|
```
|
|
|
|
Artifacts describe logical source requirements. Deployments select the actual
|
|
configured sources for an environment. Built-in platform-only workflows need
|
|
little or no deployment ceremony.
|
|
|
|
### Edit an immutable saved version
|
|
|
|
```python
|
|
report_v2_graph = await app.edit_workflow("report", version=1)
|
|
summarize = await app.capability("app.default.summarize")
|
|
|
|
summarized = report_v2_graph.use(summarize, id="summarize")
|
|
report_v2_graph.set_route("search", "ok", summarized)
|
|
report_v2_graph.connect(summarized, "ok", "end_ok")
|
|
|
|
report_v2 = await report_v2_graph.save(version=2)
|
|
```
|
|
|
|
Version 1 remains unchanged. `edit_workflow()` validates the artifact and its
|
|
plan, then seeds the same builder methods with every schema, node, edge,
|
|
binding, outcome, and subgraph reference preserved.
|
|
|
|
### Reuse a saved workflow as a native subgraph
|
|
|
|
```python
|
|
parent = app.new_workflow(
|
|
"weekly_digest",
|
|
input_schema=DigestInput,
|
|
state_schema=DigestState,
|
|
output_schema=DigestOutput,
|
|
)
|
|
|
|
report_step = parent.subgraph(
|
|
report_v2,
|
|
input=[input_from(input_path("topic"), "topic")],
|
|
output=[output_to("sources", state_path("report_sources"))],
|
|
)
|
|
```
|
|
|
|
An ordinary remote capability uses `graph.use(capability)`. An immutable saved
|
|
workflow uses `graph.subgraph(artifact)`. The distinction remains visible
|
|
because their dependency and runtime semantics differ.
|
|
|
|
### Resume an interrupted run
|
|
|
|
```python
|
|
run = await report_v2.run({"topic": "durable workflow systems"})
|
|
|
|
if run.interrupt is not None:
|
|
print(run.interrupt.request)
|
|
run = await run.resume({"approved": True})
|
|
|
|
for frame in (await run.trace(limit=25)).frames:
|
|
print(frame.node_id, frame.outcome)
|
|
```
|
|
|
|
Rich representations may summarize these objects in IPython, but displaying an
|
|
object never performs I/O or executes a capability.
|
|
|
|
## Public Package
|
|
|
|
```text
|
|
src/wf_client/
|
|
├── __init__.py
|
|
├── app.py
|
|
├── authoring.py
|
|
├── capabilities.py
|
|
├── codec.py
|
|
├── deployments.py
|
|
├── errors.py
|
|
├── protocols.py
|
|
├── runs.py
|
|
└── workflows.py
|
|
```
|
|
|
|
Responsibilities:
|
|
|
|
- `app.py`: connection construction and top-level discovery.
|
|
- `protocols.py`: the narrow internal port consumed by rich client objects.
|
|
- `codec.py`: wire payload to existing domain-model validation.
|
|
- `capabilities.py`: remote capability inspection and invocation.
|
|
- `authoring.py`: editable graph composition over `WorkflowBuilder`.
|
|
- `workflows.py`: immutable artifact and editable-workflow lifecycle.
|
|
- `deployments.py`: deployment validation and execution.
|
|
- `runs.py`: run refresh, trace, interrupt, and resume.
|
|
- `errors.py`: stable structured Python exceptions.
|
|
|
|
The existing `RpcWorkflowApiClient` is the production HTTP adapter for the
|
|
internal port. Tests use an in-memory adapter over the process-local workflow
|
|
API. Transport operation strings do not appear outside adapters.
|
|
|
|
## Python Protocols
|
|
|
|
The following declarations describe the intended public behavior. Concrete
|
|
classes may contain private state and helper methods not shown here.
|
|
|
|
```python
|
|
from __future__ import annotations
|
|
|
|
from collections.abc import Mapping, Sequence
|
|
from dataclasses import dataclass
|
|
from typing import Any, Literal, Protocol, Self
|
|
|
|
type JsonObject = dict[str, Any]
|
|
type JsonSchema = JsonObject
|
|
type DriftPolicy = Literal["block", "warn", "allow"]
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class ArtifactRef:
|
|
artifact_id: str
|
|
version: int
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class CapabilityRef:
|
|
source: str
|
|
capability_key: str
|
|
|
|
|
|
class App:
|
|
@classmethod
|
|
def from_http_jsonrpc(
|
|
cls,
|
|
url: str,
|
|
*,
|
|
timeout_seconds: float = 30.0,
|
|
) -> Self: ...
|
|
|
|
async def capability(self, name: str) -> RemoteCapability: ...
|
|
|
|
async def capabilities(
|
|
self,
|
|
*,
|
|
query: str | None = None,
|
|
source_id: str | None = None,
|
|
limit: int = 50,
|
|
) -> Page[CapabilitySummary]: ...
|
|
|
|
def new_workflow(
|
|
self,
|
|
name: str,
|
|
*,
|
|
input_schema: SchemaLike,
|
|
state_schema: StateSchemaLike,
|
|
output_schema: SchemaLike,
|
|
outcomes: Sequence[str] = ("ok",),
|
|
) -> EditableWorkflow: ...
|
|
|
|
async def workflow(
|
|
self,
|
|
artifact_id: str,
|
|
*,
|
|
version: int,
|
|
) -> WorkflowArtifact: ...
|
|
|
|
async def edit_workflow(
|
|
self,
|
|
artifact_id: str,
|
|
*,
|
|
version: int,
|
|
) -> EditableWorkflow: ...
|
|
|
|
async def deployment(self, deployment_id: str) -> Deployment: ...
|
|
|
|
async def run(self, run_id: str) -> Run: ...
|
|
```
|
|
|
|
`App.from_http_jsonrpc()` configures a transport adapter but does not perform
|
|
network I/O. Returned objects retain the internal client port so their remote
|
|
methods do not require callers to pass `App` repeatedly.
|
|
|
|
### Remote capability
|
|
|
|
```python
|
|
class RemoteCapability:
|
|
ref: CapabilityRef
|
|
qualified_name: str
|
|
description: str | None
|
|
input_schema: JsonSchema
|
|
output_schema: JsonSchema
|
|
outcomes: tuple[str, ...]
|
|
is_async: bool
|
|
|
|
async def __call__(
|
|
self,
|
|
payload: Mapping[str, Any] | None = None,
|
|
/,
|
|
**fields: Any,
|
|
) -> CapabilityResult: ...
|
|
|
|
async def call(
|
|
self,
|
|
payload: Mapping[str, Any],
|
|
*,
|
|
deployment_id: str | None = None,
|
|
) -> CapabilityResult: ...
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class CapabilityResult:
|
|
outcome: str
|
|
output: JsonObject | None
|
|
diagnostics: tuple[Diagnostic, ...]
|
|
```
|
|
|
|
The callable shorthand accepts one positional mapping or keyword fields, never
|
|
both. The explicit `call()` method carries advanced client options so a real
|
|
capability input field named `deployment_id` remains unambiguous. Inputs are
|
|
validated against the inspected capability schema before transport. Output and
|
|
outcome are validated against the inspected contract after transport.
|
|
|
|
`RemoteCapability` is not a local `NodeSpec`: it has no process-local handler.
|
|
The authoring adapter consumes its reference and schema contract directly.
|
|
|
|
### Editable workflow
|
|
|
|
```python
|
|
class EditableWorkflow(WorkflowBuilder):
|
|
name: str
|
|
based_on: ArtifactRef | None
|
|
|
|
def use(
|
|
self,
|
|
capability: RemoteCapability,
|
|
*,
|
|
id: str | None = None,
|
|
input: Sequence[StepInputBindingArg] | None = None,
|
|
output: Sequence[OutputBindingArg] | None = None,
|
|
desc: str | None = None,
|
|
) -> NodeUse: ...
|
|
|
|
def subgraph(
|
|
self,
|
|
workflow: WorkflowArtifact,
|
|
*,
|
|
id: str | None = None,
|
|
input: Sequence[StepInputBindingArg] | None = None,
|
|
output: Sequence[OutputBindingArg] | None = None,
|
|
desc: str | None = None,
|
|
) -> SubgraphNode: ...
|
|
|
|
def set_entry_point(self, step: StepRef) -> None: ...
|
|
def connect(self, source: StepRef, outcome: str, target: StepRef) -> None: ...
|
|
|
|
def set_route(
|
|
self,
|
|
source: StepRef,
|
|
outcome: str,
|
|
target: StepRef,
|
|
) -> None: ...
|
|
|
|
def remove_route(self, source: StepRef, outcome: str) -> None: ...
|
|
def remove_step(self, step: StepRef) -> None: ...
|
|
|
|
def set_output(
|
|
self,
|
|
bindings: Sequence[StepInputBindingArg],
|
|
) -> None: ...
|
|
|
|
def validate(self) -> ValidationReport: ...
|
|
def compile(self) -> Workflow: ...
|
|
|
|
async def save(
|
|
self,
|
|
*,
|
|
artifact_id: str | None = None,
|
|
version: int,
|
|
title: str | None = None,
|
|
description: str | None = None,
|
|
) -> WorkflowArtifact: ...
|
|
```
|
|
|
|
`EditableWorkflow` subclasses `WorkflowBuilder` from the separate `wf_client`
|
|
package. `WorkflowBuilder` stays transport-free, while the subclass inherits
|
|
its full authoring interface and adds remote-capability adaptation, artifact
|
|
provenance, and saving through the connected client port. Do not copy builder
|
|
methods into a parallel wrapper and do not use dynamic `__getattr__`
|
|
delegation; both approaches would create typing and drift problems.
|
|
|
|
Seeded editing also requires focused `set_route()`, `remove_route()`, and
|
|
`remove_step()` mutations. `set_route()` replaces the unique edge for one
|
|
source/outcome pair rather than appending a duplicate. `remove_step()` rejects
|
|
removal while routes still reference the step, so callers must deliberately
|
|
rewire or remove those routes first. These are local builder operations, not
|
|
draft API calls.
|
|
|
|
`set_output()` must first become a lossless `WorkflowBuilder` operation.
|
|
`compile()` must preserve workflow output bindings. This repair is required
|
|
before artifact editing is exposed.
|
|
|
|
`use()` accepts `RemoteCapability`; `subgraph()` accepts a workflow artifact.
|
|
These methods are intentionally not overloaded into one operation because a
|
|
capability call and a native subgraph boundary have different execution,
|
|
dependency, and trace semantics.
|
|
|
|
### Workflow artifact
|
|
|
|
```python
|
|
class WorkflowArtifact:
|
|
ref: ArtifactRef
|
|
title: str
|
|
description: str | None
|
|
workflow: Workflow
|
|
required_capabilities: tuple[RequiredCapability, ...]
|
|
workflow_dependencies: Mapping[str, int]
|
|
|
|
def inspect(self) -> Workflow: ...
|
|
def edit(self) -> EditableWorkflow: ...
|
|
|
|
async def deploy(
|
|
self,
|
|
deployment_id: str,
|
|
*,
|
|
bindings: Mapping[str, str] | None = None,
|
|
drift_policy: DriftPolicy = "block",
|
|
) -> Deployment: ...
|
|
|
|
async def run(
|
|
self,
|
|
input: Mapping[str, Any],
|
|
*,
|
|
deployment_id: str | None = None,
|
|
bindings: Mapping[str, str] | None = None,
|
|
drift_policy: DriftPolicy = "block",
|
|
) -> Run: ...
|
|
```
|
|
|
|
`edit()` is local because the artifact already holds its validated workflow.
|
|
It seeds a new editable workflow without mutating the artifact.
|
|
|
|
Convenience `run()` follows strict policy:
|
|
|
|
1. Use `deployment_id` when supplied.
|
|
2. Otherwise use an existing unambiguous default deployment if the backend
|
|
exposes one.
|
|
3. Otherwise create or reuse an implicit deployment only when every dependency
|
|
is platform-provided or has one unambiguous concrete binding.
|
|
4. Otherwise raise `DeploymentRequired` with unresolved logical sources and
|
|
candidate bindings.
|
|
|
|
The client never guesses between accounts or environments.
|
|
|
|
### Deployment and run
|
|
|
|
```python
|
|
class Deployment:
|
|
deployment_id: str
|
|
artifact: ArtifactRef
|
|
bindings: Mapping[str, str]
|
|
drift_policy: DriftPolicy
|
|
diagnostics: tuple[Diagnostic, ...]
|
|
runnable: bool
|
|
|
|
async def validate(self) -> DeploymentValidation: ...
|
|
async def run(self, input: Mapping[str, Any]) -> Run: ...
|
|
|
|
|
|
class Run:
|
|
run_id: str
|
|
deployment_id: str
|
|
status: Literal["running", "interrupted", "completed", "failed"]
|
|
outcome: str | None
|
|
output: JsonObject | None
|
|
interrupt: Interrupt | None
|
|
diagnostics: tuple[Diagnostic, ...]
|
|
|
|
async def refresh(self) -> Self: ...
|
|
async def resume(self, response: Mapping[str, Any]) -> Self: ...
|
|
async def trace(self, *, start: int = 0, limit: int = 25) -> TracePage: ...
|
|
```
|
|
|
|
Methods that change remote lifecycle state return refreshed immutable
|
|
snapshots. They do not mutate a Python object behind the caller's back.
|
|
|
|
## Data Flow
|
|
|
|
Capability discovery:
|
|
|
|
```text
|
|
App.capability(name)
|
|
-> client port.inspect_capability(name)
|
|
-> JSON-RPC adapter
|
|
-> wire response validation
|
|
-> RemoteCapability
|
|
```
|
|
|
|
Authoring and saving:
|
|
|
|
```text
|
|
EditableWorkflow local mutations
|
|
-> WorkflowBuilder.compile()
|
|
-> core Workflow validation
|
|
-> JSON-compatible plan at the adapter seam
|
|
-> workflow.artifacts.create_from_plan
|
|
-> WorkflowArtifact reconstruction
|
|
```
|
|
|
|
Editing:
|
|
|
|
```text
|
|
App.workflow(ref)
|
|
-> inspect artifact
|
|
-> WorkflowArtifact model validation
|
|
-> Workflow model validation from artifact.plan
|
|
-> WorkflowArtifact.edit()
|
|
-> lossless EditableWorkflow seeded from Workflow
|
|
```
|
|
|
|
## Serialization Boundary
|
|
|
|
HTTP JSON-RPC necessarily serializes requests and responses. The client must
|
|
localize that fact:
|
|
|
|
```text
|
|
public/domain object
|
|
<-> codec using existing Pydantic/domain models
|
|
<-> transport DTO
|
|
<-> JSON-RPC adapter
|
|
```
|
|
|
|
The current RPC mixins use static `cast()` calls over unchecked dictionaries.
|
|
The new package must not repeat that pattern. The adapter or codec validates
|
|
each operation result before a public object is constructed. Invalid server
|
|
responses raise `InvalidResponse` with the operation name and validation
|
|
details.
|
|
|
|
## Error Model
|
|
|
|
`wf_client` exposes structured exceptions rooted at `WorkflowClientError`:
|
|
|
|
```python
|
|
class WorkflowClientError(Exception): ...
|
|
class TransportError(WorkflowClientError): ...
|
|
class ProtocolError(WorkflowClientError): ...
|
|
class InvalidResponse(ProtocolError): ...
|
|
class CapabilityNotFound(WorkflowClientError): ...
|
|
class ArtifactNotFound(WorkflowClientError): ...
|
|
class ArtifactVersionConflict(WorkflowClientError): ...
|
|
class DeploymentRequired(WorkflowClientError): ...
|
|
class DeploymentNotRunnable(WorkflowClientError): ...
|
|
class ValidationFailed(WorkflowClientError): ...
|
|
class RevisionConflict(WorkflowClientError): ...
|
|
```
|
|
|
|
The HTTP adapter preserves the server error code, structured data, and repair
|
|
information. Rich client objects translate only stable workflow errors; unknown
|
|
server codes remain inspectable `ProtocolError` instances.
|
|
|
|
## IPython Representation
|
|
|
|
Rich objects provide concise `repr()` output. `_repr_html_()` is allowed for
|
|
capabilities, workflows, deployments, and runs when it displays only bounded,
|
|
already-loaded state. Representation must never trigger network I/O, execute a
|
|
capability, expose secrets, or fetch an unbounded trace.
|
|
|
|
## Draft Boundary
|
|
|
|
`wf_client` does not import draft models or expose draft methods. Direct Python
|
|
authoring compiles a complete workflow and saves it through the existing
|
|
artifact-from-plan operation.
|
|
|
|
Making draft support uninitialized by default is a separate server-composition
|
|
slice. Today `WorkflowApi` constructs draft modules unconditionally, durable
|
|
context validation requires a draft store, and the JSON-RPC app always
|
|
registers draft methods. That follow-up must make draft storage, domain modules,
|
|
and RPC registration opt-in without weakening artifact, deployment, or run
|
|
durability.
|
|
|
|
## Testing Strategy
|
|
|
|
Tests exercise the same public interface callers use:
|
|
|
|
- Contract tests run `App` against an in-memory client-port adapter.
|
|
- HTTP integration tests run `App.from_http_jsonrpc()` against the local ASGI
|
|
JSON-RPC application.
|
|
- Capability tests cover inspection, schema validation, direct calls, and graph
|
|
use.
|
|
- Round-trip tests require exact preservation of schemas, nodes, edges, step
|
|
bindings, workflow output bindings, outcomes, and subgraph references across
|
|
artifact inspection, editing, compilation, and saving.
|
|
- Deployment tests cover implicit platform-only execution, explicit bindings,
|
|
ambiguous binding rejection, drift diagnostics, and run creation.
|
|
- Run tests cover completed output, failure, interrupt inspection, resume, and
|
|
bounded trace reads.
|
|
- Error tests prove structured server errors survive the HTTP adapter.
|
|
- Representation tests prove `repr()` and `_repr_html_()` perform no I/O and
|
|
redact bounded fields where necessary.
|
|
|
|
## Delivery Slices
|
|
|
|
1. Repair lossless builder workflow-output and `Workflow` reconstruction.
|
|
2. Add the internal client port, codecs, and structured errors.
|
|
3. Add `App` and callable `RemoteCapability`.
|
|
4. Add `EditableWorkflow` and artifact save/inspect/edit round trips.
|
|
5. Add `Deployment`, `Run`, and strict convenience execution.
|
|
6. Add bounded IPython representations and user documentation.
|
|
7. Separately make draft server composition opt-in and disabled by default.
|
|
|
|
Each slice must be independently tested and preserve the existing low-level
|
|
RPC adapter for CLI and compatibility callers.
|
|
|
|
## Out of Scope
|
|
|
|
- Scheduling or cron semantics.
|
|
- Deleting draft implementation.
|
|
- A synchronous facade that manages event loops.
|
|
- Dynamic Python source registration.
|
|
- Generating Python classes from arbitrary JSON Schema.
|
|
- UI authoring changes.
|
|
- Changing workflow runtime execution semantics.
|
|
- Automatically choosing among multiple concrete source accounts.
|