feat: add editable remote workflow lifecycle

This commit is contained in:
lda
2026-08-31 01:24:23 +07:00 Verified
parent 5a4b69a27b
commit 370e1bd57c
7 changed files with 535 additions and 21 deletions
+16
View File
@@ -3,6 +3,7 @@
from wf_platform import CapabilityRef, Page
from .app import App
from .authoring import EditableWorkflow
from .capabilities import CapabilityResult, CapabilitySummary, RemoteCapability
from .codec import (
DecodedRunResult,
@@ -15,6 +16,7 @@ from .codec import (
decode_deployment,
decode_run_result,
decode_trace_result,
decode_validate_artifact_plan,
decode_workflow_artifact,
)
from .errors import (
@@ -31,17 +33,26 @@ from .errors import (
WorkflowClientError,
)
from .protocols import WorkflowClientPort
from .workflows import (
ArtifactRef,
Diagnostic,
WorkflowArtifact,
WorkflowDiagnostic,
WorkflowValidation,
)
__all__ = [
"ArtifactNotFound",
"ArtifactVersionConflict",
"App",
"ArtifactRef",
"CapabilityNotFound",
"CapabilityRef",
"CapabilityResult",
"CapabilitySummary",
"DecodedRunResult",
"DecodedTracePage",
"Diagnostic",
"DeploymentNotRunnable",
"DeploymentRequired",
"InvalidResponse",
@@ -53,6 +64,10 @@ __all__ = [
"ValidationFailed",
"WorkflowClientError",
"WorkflowClientPort",
"EditableWorkflow",
"WorkflowArtifact",
"WorkflowDiagnostic",
"WorkflowValidation",
"decode_capabilities_page",
"decode_capability_call",
"decode_capability_diagnostics",
@@ -61,5 +76,6 @@ __all__ = [
"decode_deployment",
"decode_run_result",
"decode_trace_result",
"decode_validate_artifact_plan",
"decode_workflow_artifact",
]
+25 -3
View File
@@ -6,17 +6,19 @@ from collections.abc import Sequence
from dataclasses import dataclass, field
from typing import Any
from wf_authoring import WorkflowBuilder
from wf_platform import CapabilityRef, Page, SourceRef
from wf_transport_rpc_http import RpcWorkflowApiClient
from .authoring import EditableWorkflow
from .capabilities import CapabilitySummary, RemoteCapability
from .codec import (
decode_capabilities_page,
decode_capability_inspect,
decode_workflow_artifact,
)
from .errors import InvalidResponse
from .protocols import WorkflowClientPort
from .workflows import WorkflowArtifact
def _capability_ref(qualified_name: str, source_id: str) -> CapabilityRef:
@@ -145,12 +147,32 @@ class App:
state_schema: Any,
output_schema: Any,
outcomes: Sequence[str] = ("ok",),
) -> WorkflowBuilder:
) -> EditableWorkflow:
"""Construct a local builder; remote operations remain opt-in/async."""
return WorkflowBuilder(
return EditableWorkflow(
_port=self._port,
name=name,
input_schema=input_schema,
state_schema=state_schema,
output_schema=output_schema,
outcomes=outcomes,
)
async def workflow(self, artifact_id: str, *, version: int) -> WorkflowArtifact:
"""Inspect and reconstruct one exact immutable workflow artifact version."""
artifact, workflow = decode_workflow_artifact(
await self._port.inspect_artifact(
artifact_id=artifact_id,
version=version,
)
)
return WorkflowArtifact(self._port, artifact, workflow)
async def edit_workflow(
self,
artifact_id: str,
*,
version: int,
) -> EditableWorkflow:
"""Inspect an exact artifact version and seed an editable builder."""
return (await self.workflow(artifact_id, version=version)).edit()
+237
View File
@@ -0,0 +1,237 @@
"""Editable workflow authoring over the transport-independent builder."""
from __future__ import annotations
from collections.abc import Sequence
from copy import deepcopy
from dataclasses import dataclass, field
from typing import Any, overload
from wf_authoring import WorkflowBuilder
from wf_authoring.builder.mapping import OutputBindingArg, StepInputBindingArg
from wf_authoring.nodes import NodeSpec
from wf_core import (
NodeDef,
NodeUse,
SchemaRef,
SubgraphNode,
ValidationReport,
Workflow,
)
from .capabilities import RemoteCapability
from .codec import decode_validate_artifact_plan, decode_workflow_artifact
from .protocols import WorkflowClientPort
from .workflows import (
ArtifactRef,
WorkflowArtifact,
WorkflowDiagnostic,
WorkflowValidation,
)
@dataclass(slots=True)
class EditableWorkflow(WorkflowBuilder):
"""A mutable ``WorkflowBuilder`` carrying the client port used to save it."""
_port: WorkflowClientPort = field(repr=False, kw_only=True)
based_on: ArtifactRef | None = field(default=None, kw_only=True)
artifact_title: str | None = field(default=None, kw_only=True)
artifact_description: str | None = field(default=None, kw_only=True)
_source_plan: dict[str, Any] | None = field(default=None, repr=False, kw_only=True)
_source_workflow: Workflow | None = field(
default=None, repr=False, kw_only=True
)
@classmethod
def from_artifact(cls, artifact: WorkflowArtifact) -> EditableWorkflow:
"""Copy every canonical graph field from an immutable artifact snapshot."""
builder = WorkflowBuilder.from_workflow(artifact.workflow)
_seed_remote_node_defs(builder, artifact)
return cls(
_port=artifact._port,
based_on=artifact.ref,
artifact_title=artifact.title,
artifact_description=artifact.description,
name=builder.name,
input_schema=builder.input_schema,
state_schema=builder.state_schema,
output_schema=builder.output_schema,
outcomes=builder.outcomes,
start=builder.start,
reducers=builder.reducers,
node_specs=dict(builder.node_specs),
nodes=builder.nodes,
edges=builder.edges,
workflow_output=builder.workflow_output,
seeded_node_defs=builder.seeded_node_defs,
prepared_subgraphs=builder.prepared_subgraphs,
_source_plan=deepcopy(artifact.artifact.plan),
_source_workflow=builder._build_workflow(start=builder.start or ""),
)
@overload
def use(
self,
spec: NodeSpec[Any, Any],
*,
id: str | None = None,
input: Sequence[StepInputBindingArg] | None = None,
output: Sequence[OutputBindingArg] | None = None,
desc: str | None = None,
) -> NodeUse: ...
@overload
def use(
self,
spec: RemoteCapability,
*,
id: str | None = None,
input: Sequence[StepInputBindingArg] | None = None,
output: Sequence[OutputBindingArg] | None = None,
desc: str | None = None,
) -> NodeUse: ...
def use(
self,
spec: NodeSpec[Any, Any] | RemoteCapability,
**kwargs: Any,
) -> NodeUse:
if isinstance(spec, RemoteCapability):
return self.use_contract(spec.node_def(), **kwargs)
return super().use(spec, **kwargs)
def subgraph(
self,
workflow: WorkflowArtifact,
*,
id: str | None = None,
input: Sequence[StepInputBindingArg] | None = None,
output: Sequence[OutputBindingArg] | None = None,
desc: str | None = None,
) -> SubgraphNode:
"""Add a native subgraph pinned to an immutable artifact version."""
return super().subgraph(
workflow=workflow.workflow,
workflow_ref={
"artifact_id": workflow.ref.artifact_id,
"version": workflow.ref.version,
},
id=id,
input=input,
output=output,
desc=desc,
)
def validate_local(self) -> ValidationReport:
"""Validate graph structure locally without touching the transport."""
return self.validate_structure()
def _plan(self) -> tuple[Workflow, dict[str, Any]]:
workflow = self.compile()
if (
self._source_plan is not None
and self._source_workflow is not None
and workflow.model_dump(mode="json", by_alias=True)
== self._source_workflow.model_dump(mode="json", by_alias=True)
):
# Pydantic canonical models add omitted defaults during a round trip
# (for example ``required=[]``). Keep an untouched artifact's raw
# plan byte-for-byte structural equivalent until it is edited.
return workflow, deepcopy(self._source_plan)
plan = workflow.model_dump(mode="json", by_alias=True)
# Node definitions are local execution metadata, not part of the raw
# persisted artifact plan; the server inventories these from node refs.
plan.pop("node_defs", None)
return workflow, plan
async def validate(self) -> WorkflowValidation:
local = self.validate_local()
if not local.ok:
return WorkflowValidation(local, "not_run", ())
_workflow, plan = self._plan()
wire = decode_validate_artifact_plan(
await self._port.validate_artifact_plan(
plan=plan,
outcomes=tuple(self.outcomes),
)
)
diagnostics = tuple(
WorkflowDiagnostic(
severity=item["severity"],
code=item["code"],
path=item["path"],
message=item["message"],
repair_hint=item["repair_hint"],
)
for item in wire["diagnostics"]
)
return WorkflowValidation(local, wire["status"], diagnostics)
async def save(
self,
*,
artifact_id: str | None = None,
version: int,
title: str | None = None,
description: str | None = None,
) -> WorkflowArtifact:
validation = await self.validate()
validation.raise_for_errors()
_workflow, plan = self._plan()
saved_id = artifact_id or (self.based_on.artifact_id if self.based_on else self.name)
saved_title = title if title is not None else self.artifact_title or self.name
saved_description = (
description if description is not None else self.artifact_description
)
await self._port.create_artifact_from_plan(
artifact_id=saved_id,
version=version,
title=saved_title,
plan=plan,
outcomes=tuple(self.outcomes),
description=saved_description,
)
# The acknowledgement is only an identity signal. Inspecting the exact
# requested version ensures server normalization is retained losslessly.
inspected = await self._port.inspect_artifact(
artifact_id=saved_id,
version=version,
)
artifact, workflow = decode_workflow_artifact(inspected)
return WorkflowArtifact(self._port, artifact, workflow)
def _seed_remote_node_defs(
builder: WorkflowBuilder,
artifact: WorkflowArtifact,
) -> None:
"""Restore remote node contracts retained as artifact dependency snapshots."""
node_name_by_step_id = {
node.id: node.node
for node in artifact.workflow.nodes
if isinstance(node, NodeUse)
}
outcomes_by_node: dict[str, list[str]] = {}
for edge in artifact.workflow.edges:
node_name = node_name_by_step_id.get(edge.from_)
if node_name is not None:
outcomes_by_node.setdefault(node_name, []).append(edge.outcome)
for requirement in artifact.required_capabilities:
if requirement.kind != "node_spec":
continue
input_schema = requirement.input_schema_snapshot
output_schema = requirement.output_schema_snapshot
if not isinstance(input_schema, dict) or not isinstance(output_schema, dict):
continue
name = str(requirement.capability_ref())
builder.seeded_node_defs.setdefault(
name,
NodeDef(
name=name,
input_schema=SchemaRef.model_validate(input_schema),
output_schema=SchemaRef.model_validate(output_schema),
outcomes=outcomes_by_node.get(name, ["ok"]),
),
)
+18 -5
View File
@@ -16,6 +16,7 @@ from wf_api.models import (
RawWorkflowPlan,
RunResult,
RunTraceResult,
ValidateArtifactPlanResult,
WorkflowArtifactPayload,
WorkflowDeploymentPayload,
)
@@ -109,6 +110,15 @@ def decode_capabilities_page(payload: object) -> ListCapabilitiesResult:
)
def decode_validate_artifact_plan(payload: object) -> ValidateArtifactPlanResult:
"""Validate a non-persisting artifact-plan response at the client boundary."""
return _validate(
payload,
ValidateArtifactPlanResult,
"workflow.artifacts.validate_plan",
)
_ModelT = TypeVar("_ModelT", bound=BaseModel)
@@ -129,11 +139,14 @@ def decode_workflow_artifact(
wire = _validate(payload, WorkflowArtifactPayload, operation)
artifact = _model_validate(WorkflowArtifact, wire, operation)
raw_plan = _model_validate(RawWorkflowPlan, wire["plan"], operation)
workflow = _model_validate(
Workflow,
raw_plan.model_dump(mode="python", by_alias=True),
operation,
)
workflow_payload = raw_plan.model_dump(mode="python", by_alias=True)
# Raw artifact plans normally omit node definitions because the server
# inventories remote contracts. Preserve them when a caller supplies them
# so an inspect/edit/save round trip remains lossless.
raw_node_defs = wire["plan"].get("node_defs")
if isinstance(raw_node_defs, list):
workflow_payload["node_defs"] = raw_node_defs
workflow = _model_validate(Workflow, workflow_payload, operation)
return artifact, workflow
+116
View File
@@ -0,0 +1,116 @@
"""Immutable workflow artifacts and validation snapshots."""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import TYPE_CHECKING, Literal
from wf_artifacts.models import (
RequiredCapability,
)
from wf_artifacts.models import (
WorkflowArtifact as ArtifactDomainModel,
)
from wf_core import ValidationReport, Workflow
from .errors import ValidationFailed
if TYPE_CHECKING:
from .authoring import EditableWorkflow
from .protocols import WorkflowClientPort
@dataclass(frozen=True, slots=True)
class ArtifactRef:
"""Stable identity of one immutable workflow artifact version."""
artifact_id: str
version: int
@dataclass(frozen=True, slots=True)
class WorkflowDiagnostic:
"""One server-side workflow-plan diagnostic returned by validation."""
severity: str
code: str
path: str
message: str
repair_hint: str | None = None
# Keep the short name used by the public design available without requiring a
# second diagnostic implementation.
Diagnostic = WorkflowDiagnostic
@dataclass(frozen=True, slots=True)
class WorkflowValidation:
"""Combined deterministic local and server-side plan validation result."""
local: ValidationReport
remote_status: Literal["valid", "invalid", "not_run"]
remote_diagnostics: tuple[WorkflowDiagnostic, ...]
@property
def ok(self) -> bool:
return self.local.ok and self.remote_status == "valid"
def raise_for_errors(self) -> None:
"""Raise a useful error for either local or remote validation failures."""
self.local.raise_for_errors()
if self.remote_status != "invalid":
return
rendered = "\n".join(
f"- [{diagnostic.code}] {diagnostic.path}: {diagnostic.message}"
for diagnostic in self.remote_diagnostics
)
raise ValidationFailed(
"Workflow server validation failed"
+ (f":\n{rendered}" if rendered else ".")
)
@dataclass(frozen=True, slots=True)
class WorkflowArtifact:
"""Immutable client snapshot retaining the validated artifact and workflow."""
_port: WorkflowClientPort = field(repr=False, compare=False)
artifact: ArtifactDomainModel
workflow: Workflow
@property
def ref(self) -> ArtifactRef:
return ArtifactRef(self.artifact.id, self.artifact.version)
@property
def title(self) -> str:
return self.artifact.title
@property
def description(self) -> str | None:
return self.artifact.description
@property
def required_capabilities(self) -> tuple[RequiredCapability, ...]:
return tuple(
capability
if isinstance(capability, RequiredCapability)
else RequiredCapability.model_validate(capability)
for capability in self.artifact.required_capabilities
)
@property
def workflow_dependencies(self) -> dict[str, int]:
return dict(self.artifact.workflow_dependencies)
def inspect(self) -> Workflow:
"""Return a deep copy so inspecting an artifact cannot mutate its snapshot."""
return self.workflow.model_copy(deep=True)
def edit(self) -> EditableWorkflow:
"""Seed an editable builder from this exact immutable artifact version."""
# Import lazily to avoid the authoring/workflows module cycle.
from .authoring import EditableWorkflow
return EditableWorkflow.from_artifact(self)