feat: add Python deployment and run objects

This commit is contained in:
lda
2026-08-31 02:02:13 +07:00 Verified
parent faa62e4878
commit 112e9656c2
9 changed files with 794 additions and 6 deletions
+137
View File
@@ -0,0 +1,137 @@
"""Immutable snapshots for durable workflow runs and bounded traces."""
from __future__ import annotations
from collections.abc import Mapping
from dataclasses import dataclass, field
from typing import Any
from wf_api import TraceRange
from wf_artifacts import DependencyDiagnostic
from wf_core import InterruptRequest, InterruptRoute, TraceEntry, WorkflowRef
from .codec import DecodedRunResult, decode_run_result, decode_trace_result
from .errors import DeploymentNotRunnable
from .protocols import WorkflowClientPort
@dataclass(frozen=True, slots=True)
class TracePage:
"""One bounded, already-loaded slice of a durable run's execution trace."""
start: int
limit: int
frames: tuple[TraceEntry, ...]
truncated: bool
trace_count: int
def _interrupt(payload: Mapping[str, Any] | None) -> InterruptRequest | None:
if payload is None:
return None
data = dict(payload)
route_data = data.get("route")
route = None
if route_data is not None:
route_values = dict(route_data)
workflow_ref = route_values.get("workflow_ref")
route = InterruptRoute(
frame_id=route_values["frame_id"],
node_id=route_values["node_id"],
scope_id=route_values["scope_id"],
lineage_id=route_values["lineage_id"],
parent_frame_id=route_values["parent_frame_id"],
workflow_ref=WorkflowRef.model_validate(workflow_ref),
)
data["route"] = route
# ``InterruptPayload`` is intentionally consumed at this boundary; public
# clients receive the core runtime request instead of a wire TypedDict.
return InterruptRequest(**data)
def _run_from_decoded(port: WorkflowClientPort, decoded: DecodedRunResult) -> Run:
if decoded.run_id is None:
raise DeploymentNotRunnable(
deployment_id=decoded.deployment_id,
diagnostics=decoded.diagnostics,
outcome=decoded.outcome,
)
return Run(
_port=port,
run_id=decoded.run_id,
deployment_id=decoded.deployment_id,
status=decoded.status,
outcome=decoded.outcome,
output=decoded.output,
interrupt=_interrupt(decoded.interrupt),
diagnostics=decoded.diagnostics,
trace_count=decoded.trace_count,
)
@dataclass(frozen=True, slots=True)
class Run:
"""Immutable client snapshot of one durable deployment run."""
_port: WorkflowClientPort = field(repr=False, compare=False)
run_id: str
deployment_id: str
status: str
outcome: str | None
output: dict[str, Any] | None
interrupt: InterruptRequest | None
diagnostics: tuple[DependencyDiagnostic, ...]
trace_count: int
@classmethod
def from_payload(cls, port: WorkflowClientPort, payload: object) -> Run:
"""Validate one run response and reconstruct its immutable snapshot."""
return _run_from_decoded(port, decode_run_result(payload))
async def refresh(self) -> Run:
"""Read the current server snapshot without mutating this run."""
return self.from_payload(
self._port,
await self._port.inspect_run(run_id=self.run_id),
)
async def resume(
self,
response: Mapping[str, Any],
*,
outcome: str = "submitted",
) -> Run:
"""Resume an interrupted run and return the server's new snapshot."""
if self.status != "interrupted" or self.interrupt is None:
raise ValueError("only interrupted runs can be resumed")
if not self.interrupt.resumable:
raise ValueError("run interrupt is not resumable")
return self.from_payload(
self._port,
await self._port.resume_run(
run_id=self.run_id,
resume_payload=dict(response),
resume_outcome=outcome,
),
)
async def trace(self, *, start: int = 0, limit: int = 25) -> TracePage:
"""Read a bounded trace page, validating bounds before remote I/O."""
if start < 0:
raise ValueError("start must be >= 0")
if limit <= 0 or limit > 100:
raise ValueError("limit must be between 1 and 100")
decoded = decode_trace_result(
await self._port.read_run_trace(
run_id=self.run_id,
trace_range=TraceRange(start=start, limit=limit),
)
)
frames = tuple(TraceEntry(**dict(frame)) for frame in (decoded.trace or ()))
return TracePage(
start=decoded.trace_start if decoded.trace_start is not None else start,
limit=decoded.trace_limit if decoded.trace_limit is not None else limit,
frames=frames,
truncated=bool(decoded.trace_truncated),
trace_count=decoded.trace_count,
)