feat: deliver Python workflow client
This commit is contained in:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user