feat: add editable remote workflow lifecycle
This commit is contained in:
@@ -19,12 +19,7 @@ WORKFLOW_OUTPUT = [
|
|||||||
|
|
||||||
|
|
||||||
def build_workflow() -> Workflow:
|
def build_workflow() -> Workflow:
|
||||||
"""Build the demo workflow with the public authoring API.
|
"""Build the demo workflow with the public authoring API."""
|
||||||
|
|
||||||
`WorkflowBuilder` does not yet expose a workflow-output setter, so this
|
|
||||||
module adds the final output projection in `_with_workflow_output()` after
|
|
||||||
compiling the graph. Keep that seam small and validated.
|
|
||||||
"""
|
|
||||||
builder = WorkflowBuilder(
|
builder = WorkflowBuilder(
|
||||||
name="lda_report_case_study",
|
name="lda_report_case_study",
|
||||||
input_schema={
|
input_schema={
|
||||||
@@ -185,13 +180,8 @@ def build_workflow() -> Workflow:
|
|||||||
builder.connect(create_issues, "ok", finalise)
|
builder.connect(create_issues, "ok", finalise)
|
||||||
builder.connect(finalise, "ok", end_completed)
|
builder.connect(finalise, "ok", end_completed)
|
||||||
builder.connect(revision_requested, "ok", end_cancelled)
|
builder.connect(revision_requested, "ok", end_cancelled)
|
||||||
return _with_workflow_output(builder.compile())
|
builder.set_output(WORKFLOW_OUTPUT)
|
||||||
|
return builder.compile()
|
||||||
|
|
||||||
def _with_workflow_output(workflow: Workflow) -> Workflow:
|
|
||||||
payload = workflow.model_dump(mode="json", by_alias=True)
|
|
||||||
payload["output"] = WORKFLOW_OUTPUT
|
|
||||||
return Workflow.model_validate(payload)
|
|
||||||
|
|
||||||
|
|
||||||
def workflow_plan_payload() -> dict[str, Any]:
|
def workflow_plan_payload() -> dict[str, Any]:
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
from wf_platform import CapabilityRef, Page
|
from wf_platform import CapabilityRef, Page
|
||||||
|
|
||||||
from .app import App
|
from .app import App
|
||||||
|
from .authoring import EditableWorkflow
|
||||||
from .capabilities import CapabilityResult, CapabilitySummary, RemoteCapability
|
from .capabilities import CapabilityResult, CapabilitySummary, RemoteCapability
|
||||||
from .codec import (
|
from .codec import (
|
||||||
DecodedRunResult,
|
DecodedRunResult,
|
||||||
@@ -15,6 +16,7 @@ from .codec import (
|
|||||||
decode_deployment,
|
decode_deployment,
|
||||||
decode_run_result,
|
decode_run_result,
|
||||||
decode_trace_result,
|
decode_trace_result,
|
||||||
|
decode_validate_artifact_plan,
|
||||||
decode_workflow_artifact,
|
decode_workflow_artifact,
|
||||||
)
|
)
|
||||||
from .errors import (
|
from .errors import (
|
||||||
@@ -31,17 +33,26 @@ from .errors import (
|
|||||||
WorkflowClientError,
|
WorkflowClientError,
|
||||||
)
|
)
|
||||||
from .protocols import WorkflowClientPort
|
from .protocols import WorkflowClientPort
|
||||||
|
from .workflows import (
|
||||||
|
ArtifactRef,
|
||||||
|
Diagnostic,
|
||||||
|
WorkflowArtifact,
|
||||||
|
WorkflowDiagnostic,
|
||||||
|
WorkflowValidation,
|
||||||
|
)
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"ArtifactNotFound",
|
"ArtifactNotFound",
|
||||||
"ArtifactVersionConflict",
|
"ArtifactVersionConflict",
|
||||||
"App",
|
"App",
|
||||||
|
"ArtifactRef",
|
||||||
"CapabilityNotFound",
|
"CapabilityNotFound",
|
||||||
"CapabilityRef",
|
"CapabilityRef",
|
||||||
"CapabilityResult",
|
"CapabilityResult",
|
||||||
"CapabilitySummary",
|
"CapabilitySummary",
|
||||||
"DecodedRunResult",
|
"DecodedRunResult",
|
||||||
"DecodedTracePage",
|
"DecodedTracePage",
|
||||||
|
"Diagnostic",
|
||||||
"DeploymentNotRunnable",
|
"DeploymentNotRunnable",
|
||||||
"DeploymentRequired",
|
"DeploymentRequired",
|
||||||
"InvalidResponse",
|
"InvalidResponse",
|
||||||
@@ -53,6 +64,10 @@ __all__ = [
|
|||||||
"ValidationFailed",
|
"ValidationFailed",
|
||||||
"WorkflowClientError",
|
"WorkflowClientError",
|
||||||
"WorkflowClientPort",
|
"WorkflowClientPort",
|
||||||
|
"EditableWorkflow",
|
||||||
|
"WorkflowArtifact",
|
||||||
|
"WorkflowDiagnostic",
|
||||||
|
"WorkflowValidation",
|
||||||
"decode_capabilities_page",
|
"decode_capabilities_page",
|
||||||
"decode_capability_call",
|
"decode_capability_call",
|
||||||
"decode_capability_diagnostics",
|
"decode_capability_diagnostics",
|
||||||
@@ -61,5 +76,6 @@ __all__ = [
|
|||||||
"decode_deployment",
|
"decode_deployment",
|
||||||
"decode_run_result",
|
"decode_run_result",
|
||||||
"decode_trace_result",
|
"decode_trace_result",
|
||||||
|
"decode_validate_artifact_plan",
|
||||||
"decode_workflow_artifact",
|
"decode_workflow_artifact",
|
||||||
]
|
]
|
||||||
|
|||||||
+25
-3
@@ -6,17 +6,19 @@ from collections.abc import Sequence
|
|||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from wf_authoring import WorkflowBuilder
|
|
||||||
from wf_platform import CapabilityRef, Page, SourceRef
|
from wf_platform import CapabilityRef, Page, SourceRef
|
||||||
from wf_transport_rpc_http import RpcWorkflowApiClient
|
from wf_transport_rpc_http import RpcWorkflowApiClient
|
||||||
|
|
||||||
|
from .authoring import EditableWorkflow
|
||||||
from .capabilities import CapabilitySummary, RemoteCapability
|
from .capabilities import CapabilitySummary, RemoteCapability
|
||||||
from .codec import (
|
from .codec import (
|
||||||
decode_capabilities_page,
|
decode_capabilities_page,
|
||||||
decode_capability_inspect,
|
decode_capability_inspect,
|
||||||
|
decode_workflow_artifact,
|
||||||
)
|
)
|
||||||
from .errors import InvalidResponse
|
from .errors import InvalidResponse
|
||||||
from .protocols import WorkflowClientPort
|
from .protocols import WorkflowClientPort
|
||||||
|
from .workflows import WorkflowArtifact
|
||||||
|
|
||||||
|
|
||||||
def _capability_ref(qualified_name: str, source_id: str) -> CapabilityRef:
|
def _capability_ref(qualified_name: str, source_id: str) -> CapabilityRef:
|
||||||
@@ -145,12 +147,32 @@ class App:
|
|||||||
state_schema: Any,
|
state_schema: Any,
|
||||||
output_schema: Any,
|
output_schema: Any,
|
||||||
outcomes: Sequence[str] = ("ok",),
|
outcomes: Sequence[str] = ("ok",),
|
||||||
) -> WorkflowBuilder:
|
) -> EditableWorkflow:
|
||||||
"""Construct a local builder; remote operations remain opt-in/async."""
|
"""Construct a local builder; remote operations remain opt-in/async."""
|
||||||
return WorkflowBuilder(
|
return EditableWorkflow(
|
||||||
|
_port=self._port,
|
||||||
name=name,
|
name=name,
|
||||||
input_schema=input_schema,
|
input_schema=input_schema,
|
||||||
state_schema=state_schema,
|
state_schema=state_schema,
|
||||||
output_schema=output_schema,
|
output_schema=output_schema,
|
||||||
outcomes=outcomes,
|
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()
|
||||||
|
|||||||
@@ -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
@@ -16,6 +16,7 @@ from wf_api.models import (
|
|||||||
RawWorkflowPlan,
|
RawWorkflowPlan,
|
||||||
RunResult,
|
RunResult,
|
||||||
RunTraceResult,
|
RunTraceResult,
|
||||||
|
ValidateArtifactPlanResult,
|
||||||
WorkflowArtifactPayload,
|
WorkflowArtifactPayload,
|
||||||
WorkflowDeploymentPayload,
|
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)
|
_ModelT = TypeVar("_ModelT", bound=BaseModel)
|
||||||
|
|
||||||
|
|
||||||
@@ -129,11 +139,14 @@ def decode_workflow_artifact(
|
|||||||
wire = _validate(payload, WorkflowArtifactPayload, operation)
|
wire = _validate(payload, WorkflowArtifactPayload, operation)
|
||||||
artifact = _model_validate(WorkflowArtifact, wire, operation)
|
artifact = _model_validate(WorkflowArtifact, wire, operation)
|
||||||
raw_plan = _model_validate(RawWorkflowPlan, wire["plan"], operation)
|
raw_plan = _model_validate(RawWorkflowPlan, wire["plan"], operation)
|
||||||
workflow = _model_validate(
|
workflow_payload = raw_plan.model_dump(mode="python", by_alias=True)
|
||||||
Workflow,
|
# Raw artifact plans normally omit node definitions because the server
|
||||||
raw_plan.model_dump(mode="python", by_alias=True),
|
# inventories remote contracts. Preserve them when a caller supplies them
|
||||||
operation,
|
# 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
|
return artifact, workflow
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any, cast
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from wf_authoring import WorkflowBuilder
|
||||||
|
from wf_client import App, ArtifactRef, EditableWorkflow
|
||||||
|
from wf_client.protocols import WorkflowClientPort
|
||||||
|
|
||||||
|
|
||||||
|
class FakePort:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.calls: list[tuple[str, dict[str, Any]]] = []
|
||||||
|
self.validate_artifact_plan_result: dict[str, Any] = {
|
||||||
|
"status": "valid",
|
||||||
|
"diagnostics": [],
|
||||||
|
"required_capabilities": [],
|
||||||
|
"workflow_dependencies": {},
|
||||||
|
}
|
||||||
|
self.inspect_artifact_result: dict[str, Any] | None = None
|
||||||
|
|
||||||
|
async def validate_artifact_plan(self, **params: Any) -> object:
|
||||||
|
self.calls.append(("validate_artifact_plan", params))
|
||||||
|
return self.validate_artifact_plan_result
|
||||||
|
|
||||||
|
async def create_artifact_from_plan(self, **params: Any) -> object:
|
||||||
|
self.calls.append(("create_artifact_from_plan", params))
|
||||||
|
return {"artifact_id": params["artifact_id"], "version": params["version"], "saved": True}
|
||||||
|
|
||||||
|
async def inspect_artifact(self, **params: Any) -> object:
|
||||||
|
self.calls.append(("inspect_artifact", params))
|
||||||
|
assert self.inspect_artifact_result is not None
|
||||||
|
return self.inspect_artifact_result
|
||||||
|
|
||||||
|
|
||||||
|
def valid_plan(version: int = 1) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"id": "report",
|
||||||
|
"version": version,
|
||||||
|
"title": "Report",
|
||||||
|
"kind": "workflow",
|
||||||
|
"description": None,
|
||||||
|
"input_schema": {"type": "object", "properties": {}},
|
||||||
|
"output_schema": {"type": "object", "properties": {"value": {"type": "string"}}},
|
||||||
|
"outcomes": ["ok"],
|
||||||
|
"plan": {
|
||||||
|
"name": "report",
|
||||||
|
"input_schema": {"type": "object", "properties": {}},
|
||||||
|
"state_schema": {"type": "object", "properties": {"value": {"type": "string"}}},
|
||||||
|
"output_schema": {"type": "object", "properties": {"value": {"type": "string"}}},
|
||||||
|
"outcomes": ["ok"],
|
||||||
|
"output": [{"path": "state.value", "target": "value"}],
|
||||||
|
"start": "done",
|
||||||
|
"nodes": [{"id": "done", "type": "end", "outcome": "ok"}],
|
||||||
|
"edges": [],
|
||||||
|
},
|
||||||
|
"required_capabilities": [],
|
||||||
|
"workflow_dependencies": {},
|
||||||
|
"created_from_catalog_version": None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_validate_stops_before_remote_call_when_local_graph_is_invalid() -> None:
|
||||||
|
port = FakePort()
|
||||||
|
graph = App._from_port(cast(WorkflowClientPort, port)).new_workflow(
|
||||||
|
"invalid",
|
||||||
|
input_schema={"type": "object", "properties": {}},
|
||||||
|
state_schema={"type": "object", "properties": {}},
|
||||||
|
output_schema={"type": "object", "properties": {}},
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await graph.validate()
|
||||||
|
|
||||||
|
assert result.local.ok is False
|
||||||
|
assert result.remote_status == "not_run"
|
||||||
|
assert port.calls == []
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_validate_runs_local_and_server_validation() -> None:
|
||||||
|
port = FakePort()
|
||||||
|
graph = App._from_port(cast(WorkflowClientPort, port)).new_workflow(
|
||||||
|
"report",
|
||||||
|
input_schema={"type": "object", "properties": {}},
|
||||||
|
state_schema={"type": "object", "properties": {"value": {"type": "string"}}},
|
||||||
|
output_schema={"type": "object", "properties": {"value": {"type": "string"}}},
|
||||||
|
)
|
||||||
|
done = graph.end("ok", id="done")
|
||||||
|
graph.set_entry_point(done)
|
||||||
|
|
||||||
|
result = await graph.validate()
|
||||||
|
|
||||||
|
assert result.ok is True
|
||||||
|
assert result.local.ok is True
|
||||||
|
assert result.remote_status == "valid"
|
||||||
|
assert port.calls[-1][0] == "validate_artifact_plan"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_edit_and_save_inspects_exact_saved_version() -> None:
|
||||||
|
port = FakePort()
|
||||||
|
port.inspect_artifact_result = valid_plan(version=1)
|
||||||
|
app = App._from_port(cast(WorkflowClientPort, port))
|
||||||
|
|
||||||
|
graph = await app.edit_workflow("report", version=1)
|
||||||
|
assert isinstance(graph, WorkflowBuilder)
|
||||||
|
assert isinstance(graph, EditableWorkflow)
|
||||||
|
assert all(hasattr(graph, name) for name in ("when", "choose", "match", "foreach", "interrupt", "end", "connect", "set_entry_point"))
|
||||||
|
|
||||||
|
port.inspect_artifact_result = valid_plan(version=2)
|
||||||
|
saved = await graph.save(version=2)
|
||||||
|
|
||||||
|
create = next(params for operation, params in port.calls if operation == "create_artifact_from_plan")
|
||||||
|
assert create["plan"] == valid_plan(version=1)["plan"]
|
||||||
|
inspect = [params for operation, params in port.calls if operation == "inspect_artifact"][-1]
|
||||||
|
assert inspect == {"artifact_id": "report", "version": 2}
|
||||||
|
assert saved.ref == ArtifactRef("report", 2)
|
||||||
|
assert str(saved.workflow.output[0].target) == "value"
|
||||||
Reference in New Issue
Block a user