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
+16 -10
View File
@@ -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
View File
@@ -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)
+1 -1
View File
@@ -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
+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_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,
+36
View File
@@ -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
+43
View File
@@ -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,
+69 -2
View File
@@ -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=(
+9 -2
View File
@@ -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)