Files
SciMesh/scimesh/sdk/workflow.py
T

840 lines
33 KiB
Python

"""Versioned workflow DAG and bounded advanced-stage declarations."""
from __future__ import annotations
from dataclasses import dataclass
from enum import Enum
from types import MappingProxyType
from typing import Mapping
from ._validation import (
enum_value,
require_entry_point,
require_exact_keys,
require_identifier,
require_nonnegative_int,
require_positive_int,
require_schema_version,
)
from .artifacts import PortSpec
from .execution import ExecutionProfile, NetworkPolicy, RetryPolicy
from .identity import ComponentRef, SchemaRef, WORKFLOW_SCHEMA_VERSION
from .resources import ResourceRequirements
class StageKind(str, Enum):
"""The kind of a workflow stage.
``PLAN`` stages expand dynamically, ``MAP`` stages fan out, ``REDUCE``
stages fan in, and the advanced kinds (loops, streams, services, side
effects) are declared but fail negotiation unless the runtime advertises
the corresponding features.
"""
PLAN = "plan"
MAP = "map"
REDUCE = "reduce"
VERIFY = "verify"
LOOP_CONTROLLER = "loop-controller"
STREAM = "stream"
SERVICE = "service"
SIDE_EFFECT = "side-effect"
class WorkflowFailurePolicy(str, Enum):
"""How a workflow behaves when a stage fails.
``FAIL_FAST`` aborts on the first failure; the remaining policies require
coordinator/runtime support and are fail-closed in v1.
"""
FAIL_FAST = "fail_fast"
CONTINUE_INDEPENDENT = "continue_independent"
ALLOW_PARTIAL = "allow_partial"
COMPENSATE = "compensate"
@dataclass(frozen=True, slots=True)
class LoopSpec:
"""Bounded loop declaration for a ``LOOP_CONTROLLER`` stage.
Declared but not executable until a runtime advertises ``bounded-loops``.
"""
state_schema: SchemaRef
max_iterations: int
max_wall_seconds: int
body_workflow: str
continue_when: ComponentRef
checkpoint_every: int
on_limit: str = "fail"
def __post_init__(self) -> None:
if not isinstance(self.state_schema, SchemaRef):
raise ValueError("loop state_schema must be a SchemaRef")
object.__setattr__(
self,
"max_iterations",
require_positive_int(self.max_iterations, "loop.max_iterations"),
)
object.__setattr__(
self,
"max_wall_seconds",
require_positive_int(self.max_wall_seconds, "loop.max_wall_seconds"),
)
object.__setattr__(
self,
"body_workflow",
require_identifier(self.body_workflow, "loop.body_workflow"),
)
if not isinstance(self.continue_when, ComponentRef):
raise ValueError("loop continue_when must be a ComponentRef")
object.__setattr__(
self,
"checkpoint_every",
require_positive_int(self.checkpoint_every, "loop.checkpoint_every"),
)
if self.checkpoint_every > self.max_iterations:
raise ValueError("loop checkpoint_every must not exceed max_iterations")
if self.on_limit not in {"fail", "accept-best", "return-inconclusive"}:
raise ValueError(
"loop on_limit must be fail, accept-best, or return-inconclusive"
)
def to_dict(self) -> dict[str, object]:
return {
"state_schema": self.state_schema.canonical,
"max_iterations": self.max_iterations,
"max_wall_seconds": self.max_wall_seconds,
"body_workflow": self.body_workflow,
"continue_when": self.continue_when.canonical,
"checkpoint_every": self.checkpoint_every,
"on_limit": self.on_limit,
}
@classmethod
def from_dict(cls, value: object) -> "LoopSpec":
if not isinstance(value, Mapping):
raise ValueError("loop specification must be an object")
fields = {
"state_schema",
"max_iterations",
"max_wall_seconds",
"body_workflow",
"continue_when",
"checkpoint_every",
"on_limit",
}
require_exact_keys(value, fields, "loop specification")
return cls(
state_schema=SchemaRef.from_dict(value["state_schema"]),
max_iterations=value["max_iterations"], # type: ignore[arg-type]
max_wall_seconds=value["max_wall_seconds"], # type: ignore[arg-type]
body_workflow=value["body_workflow"], # type: ignore[arg-type]
continue_when=ComponentRef.from_dict(value["continue_when"]),
checkpoint_every=value["checkpoint_every"], # type: ignore[arg-type]
on_limit=value["on_limit"], # type: ignore[arg-type]
)
@dataclass(frozen=True, slots=True)
class StreamSpec:
"""Bounded stream declaration for a ``STREAM`` stage.
Declared but not executable until a runtime advertises
``stream-checkpoints``.
"""
source: str
partitioning: str
checkpoint_schema: SchemaRef
window_seconds: int
watermark_seconds: int
backpressure_limit: int
delivery_guarantee: str
max_windows: int
def __post_init__(self) -> None:
object.__setattr__(
self, "source", require_identifier(self.source, "stream.source")
)
object.__setattr__(
self,
"partitioning",
require_identifier(self.partitioning, "stream.partitioning"),
)
if not isinstance(self.checkpoint_schema, SchemaRef):
raise ValueError("stream checkpoint_schema must be a SchemaRef")
object.__setattr__(
self,
"window_seconds",
require_positive_int(self.window_seconds, "stream.window_seconds"),
)
object.__setattr__(
self,
"watermark_seconds",
require_nonnegative_int(self.watermark_seconds, "stream.watermark_seconds"),
)
object.__setattr__(
self,
"backpressure_limit",
require_positive_int(self.backpressure_limit, "stream.backpressure_limit"),
)
if self.delivery_guarantee not in {"at_least_once", "exactly_once"}:
raise ValueError(
"stream delivery_guarantee must be at_least_once or exactly_once"
)
object.__setattr__(
self,
"max_windows",
require_positive_int(self.max_windows, "stream.max_windows"),
)
def to_dict(self) -> dict[str, object]:
return {
"source": self.source,
"partitioning": self.partitioning,
"checkpoint_schema": self.checkpoint_schema.canonical,
"window_seconds": self.window_seconds,
"watermark_seconds": self.watermark_seconds,
"backpressure_limit": self.backpressure_limit,
"delivery_guarantee": self.delivery_guarantee,
"max_windows": self.max_windows,
}
@classmethod
def from_dict(cls, value: object) -> "StreamSpec":
if not isinstance(value, Mapping):
raise ValueError("stream specification must be an object")
fields = {
"source",
"partitioning",
"checkpoint_schema",
"window_seconds",
"watermark_seconds",
"backpressure_limit",
"delivery_guarantee",
"max_windows",
}
require_exact_keys(value, fields, "stream specification")
return cls(
source=value["source"], # type: ignore[arg-type]
partitioning=value["partitioning"], # type: ignore[arg-type]
checkpoint_schema=SchemaRef.from_dict(value["checkpoint_schema"]),
window_seconds=value["window_seconds"], # type: ignore[arg-type]
watermark_seconds=value["watermark_seconds"], # type: ignore[arg-type]
backpressure_limit=value["backpressure_limit"], # type: ignore[arg-type]
delivery_guarantee=value["delivery_guarantee"], # type: ignore[arg-type]
max_windows=value["max_windows"], # type: ignore[arg-type]
)
@dataclass(frozen=True, slots=True)
class GangSpec:
"""Co-scheduled replica group for one stage.
Declared but not executable until a runtime advertises ``gang-leases``.
"""
replicas: int
per_replica_resources: ResourceRequirements
same_topology_group: bool = False
bandwidth_class: str | None = None
failure_mode: str = "fail_all"
def __post_init__(self) -> None:
object.__setattr__(
self, "replicas", require_positive_int(self.replicas, "gang.replicas")
)
if self.replicas < 2:
raise ValueError("gang execution requires at least two replicas")
if not isinstance(self.per_replica_resources, ResourceRequirements):
raise ValueError("gang per_replica_resources must be ResourceRequirements")
if not isinstance(self.same_topology_group, bool):
raise ValueError("gang same_topology_group must be a boolean")
if self.bandwidth_class is not None:
object.__setattr__(
self,
"bandwidth_class",
require_identifier(self.bandwidth_class, "gang.bandwidth_class"),
)
if self.failure_mode != "fail_all":
raise ValueError("SDK v1 gang failure_mode must be fail_all")
def to_dict(self) -> dict[str, object]:
return {
"replicas": self.replicas,
"per_replica_resources": self.per_replica_resources.to_dict(),
"same_topology_group": self.same_topology_group,
"bandwidth_class": self.bandwidth_class,
"failure_mode": self.failure_mode,
}
@classmethod
def from_dict(cls, value: object) -> "GangSpec":
if not isinstance(value, Mapping):
raise ValueError("gang specification must be an object")
fields = {
"replicas",
"per_replica_resources",
"same_topology_group",
"bandwidth_class",
"failure_mode",
}
require_exact_keys(value, fields, "gang specification")
return cls(
replicas=value["replicas"], # type: ignore[arg-type]
per_replica_resources=ResourceRequirements.from_dict(
value["per_replica_resources"]
),
same_topology_group=value["same_topology_group"], # type: ignore[arg-type]
bandwidth_class=value["bandwidth_class"], # type: ignore[arg-type]
failure_mode=value["failure_mode"], # type: ignore[arg-type]
)
@dataclass(frozen=True, slots=True)
class SideEffectSpec:
"""External side-effect declaration for a ``SIDE_EFFECT`` stage.
Declared but trusted-only and not executable until a runtime advertises
``side-effect``; requires an idempotency key projected into the stage.
"""
target: str
idempotency_key_parameter: str
credential_scope: str
compensation: str
manual_approval: bool = True
def __post_init__(self) -> None:
object.__setattr__(
self, "target", require_identifier(self.target, "side_effect.target")
)
object.__setattr__(
self,
"idempotency_key_parameter",
require_identifier(
self.idempotency_key_parameter, "side_effect.idempotency_key_parameter"
),
)
object.__setattr__(
self,
"credential_scope",
require_identifier(self.credential_scope, "side_effect.credential_scope"),
)
object.__setattr__(
self,
"compensation",
require_identifier(self.compensation, "side_effect.compensation"),
)
if not isinstance(self.manual_approval, bool):
raise ValueError("side_effect.manual_approval must be a boolean")
def to_dict(self) -> dict[str, object]:
return {
"target": self.target,
"idempotency_key_parameter": self.idempotency_key_parameter,
"credential_scope": self.credential_scope,
"compensation": self.compensation,
"manual_approval": self.manual_approval,
}
@classmethod
def from_dict(cls, value: object) -> "SideEffectSpec":
if not isinstance(value, Mapping):
raise ValueError("side-effect specification must be an object")
fields = {
"target",
"idempotency_key_parameter",
"credential_scope",
"compensation",
"manual_approval",
}
require_exact_keys(value, fields, "side-effect specification")
return cls(**value) # type: ignore[arg-type]
@dataclass(frozen=True, slots=True)
class PortRef:
"""A stage port, or an external workflow input when ``stage_id`` is None."""
port: str
stage_id: str | None = None
def __post_init__(self) -> None:
object.__setattr__(
self, "port", require_identifier(self.port, "port reference")
)
if self.stage_id is not None:
object.__setattr__(
self, "stage_id", require_identifier(self.stage_id, "stage reference")
)
def to_dict(self) -> dict[str, object]:
return {"stage_id": self.stage_id, "port": self.port}
@classmethod
def from_dict(cls, value: object) -> "PortRef":
if not isinstance(value, Mapping):
raise ValueError("port reference must be an object")
require_exact_keys(value, {"stage_id", "port"}, "port reference")
return cls(stage_id=value["stage_id"], port=value["port"]) # type: ignore[arg-type]
@dataclass(frozen=True, slots=True)
class ArtifactEdge:
"""A typed data flow from a source port to a stage input port.
Endpoints must declare compatible schemas; every stage input receives
exactly one edge.
"""
source: PortRef
target: PortRef
def __post_init__(self) -> None:
if not isinstance(self.source, PortRef) or not isinstance(self.target, PortRef):
raise ValueError("artifact edge endpoints must be PortRef values")
if self.target.stage_id is None:
raise ValueError("artifact edge target must be a stage input")
def to_dict(self) -> dict[str, object]:
return {"source": self.source.to_dict(), "target": self.target.to_dict()}
@classmethod
def from_dict(cls, value: object) -> "ArtifactEdge":
if not isinstance(value, Mapping):
raise ValueError("artifact edge must be an object")
require_exact_keys(value, {"source", "target"}, "artifact edge")
return cls(
source=PortRef.from_dict(value["source"]),
target=PortRef.from_dict(value["target"]),
)
def _port_mapping(value: Mapping[str, PortSpec], field: str) -> Mapping[str, PortSpec]:
if not isinstance(value, Mapping):
raise ValueError(f"{field} must be an object")
ports: dict[str, PortSpec] = {}
for name, port in value.items():
canonical = require_identifier(name, f"{field} port")
if not isinstance(port, PortSpec):
raise ValueError(f"{field} values must be PortSpec values")
ports[canonical] = port
return MappingProxyType(ports)
@dataclass(frozen=True, slots=True)
class StageSpec:
"""One typed stage: kind, handler entry point, ports, and policy.
``entry_point`` must be an installed handler key (runner for map/verify
stages, reducer for reduce stages); resources, execution profile, retry
policy, verifier, and trust modes are validated at construction.
"""
stage_id: str
kind: StageKind
entry_point: str
needs: tuple[str, ...]
inputs: Mapping[str, PortSpec]
outputs: Mapping[str, PortSpec]
parameter_names: tuple[str, ...]
resources: ResourceRequirements
execution: ExecutionProfile
retry: RetryPolicy
verifier: ComponentRef | None = None
trust_modes: tuple[str, ...] = ("trusted",)
max_fan_out: int = 1
cacheable: bool = False
loop: LoopSpec | None = None
stream: StreamSpec | None = None
gang: GangSpec | None = None
side_effect: SideEffectSpec | None = None
def __post_init__(self) -> None:
object.__setattr__(
self, "stage_id", require_identifier(self.stage_id, "stage_id")
)
object.__setattr__(self, "kind", enum_value(StageKind, self.kind, "stage.kind"))
object.__setattr__(
self,
"entry_point",
require_entry_point(self.entry_point, "stage.entry_point"),
)
needs = tuple(require_identifier(value, "stage.needs") for value in self.needs)
if self.stage_id in needs or len(needs) != len(set(needs)):
raise ValueError("stage.needs must contain unique other stage IDs")
object.__setattr__(self, "needs", needs)
object.__setattr__(self, "inputs", _port_mapping(self.inputs, "stage.inputs"))
object.__setattr__(
self, "outputs", _port_mapping(self.outputs, "stage.outputs")
)
if not self.outputs:
raise ValueError("a stage must declare at least one output port")
names = tuple(
require_identifier(value, "parameter_name")
for value in self.parameter_names
)
if len(names) != len(set(names)):
raise ValueError("parameter_names must be unique")
object.__setattr__(self, "parameter_names", names)
if not isinstance(self.resources, ResourceRequirements):
raise ValueError("stage.resources must be ResourceRequirements")
if not isinstance(self.execution, ExecutionProfile):
raise ValueError("stage.execution must be ExecutionProfile")
self.execution.validate_resources(self.resources)
if not isinstance(self.retry, RetryPolicy):
raise ValueError("stage.retry must be RetryPolicy")
if self.verifier is not None and not isinstance(self.verifier, ComponentRef):
raise ValueError("stage.verifier must be a ComponentRef")
modes = tuple(
require_identifier(value, "trust_mode") for value in self.trust_modes
)
if not modes or len(modes) != len(set(modes)):
raise ValueError("stage.trust_modes must be non-empty and unique")
if not set(modes).issubset({"trusted", "verified", "untrusted_quorum"}):
raise ValueError("stage.trust_modes contains an unsupported trust mode")
object.__setattr__(self, "trust_modes", modes)
object.__setattr__(
self,
"max_fan_out",
require_positive_int(self.max_fan_out, "stage.max_fan_out"),
)
if not isinstance(self.cacheable, bool):
raise ValueError("stage.cacheable must be a boolean")
advanced = {
StageKind.LOOP_CONTROLLER: self.loop,
StageKind.STREAM: self.stream,
StageKind.SIDE_EFFECT: self.side_effect,
}
expected_types = {
StageKind.LOOP_CONTROLLER: LoopSpec,
StageKind.STREAM: StreamSpec,
StageKind.SIDE_EFFECT: SideEffectSpec,
}
for kind, declaration in advanced.items():
if self.kind is kind and declaration is None:
raise ValueError(f"{kind.value} stage requires its bounded declaration")
if self.kind is not kind and declaration is not None:
raise ValueError(
f"{kind.value} declaration is valid only for a {kind.value} stage"
)
if declaration is not None and not isinstance(
declaration, expected_types[kind]
):
raise ValueError(f"{kind.value} declaration has the wrong type")
if self.gang is not None and not isinstance(self.gang, GangSpec):
raise ValueError("stage.gang must be a GangSpec")
if self.gang is not None:
self.execution.validate_resources(self.gang.per_replica_resources)
if self.kind is StageKind.SIDE_EFFECT:
raise ValueError("side-effect stages cannot use gang execution")
if self.kind is StageKind.SIDE_EFFECT:
if self.cacheable:
raise ValueError("side-effect stages cannot be cached")
if self.execution.network not in {
NetworkPolicy.ALLOWLISTED_EGRESS,
NetworkPolicy.TRUSTED,
}:
raise ValueError("side-effect stages require explicit egress")
assert self.side_effect is not None
if self.side_effect.idempotency_key_parameter not in self.parameter_names:
raise ValueError(
"side-effect idempotency key must be projected into the stage"
)
def to_dict(self) -> dict[str, object]:
return {
"stage_id": self.stage_id,
"kind": self.kind.value,
"entry_point": self.entry_point,
"needs": list(self.needs),
"inputs": {name: port.to_dict() for name, port in self.inputs.items()},
"outputs": {name: port.to_dict() for name, port in self.outputs.items()},
"parameter_names": list(self.parameter_names),
"resources": self.resources.to_dict(),
"execution": self.execution.to_dict(),
"retry": self.retry.to_dict(),
"verifier": self.verifier.canonical if self.verifier is not None else None,
"trust_modes": list(self.trust_modes),
"max_fan_out": self.max_fan_out,
"cacheable": self.cacheable,
"loop": self.loop.to_dict() if self.loop is not None else None,
"stream": self.stream.to_dict() if self.stream is not None else None,
"gang": self.gang.to_dict() if self.gang is not None else None,
"side_effect": self.side_effect.to_dict()
if self.side_effect is not None
else None,
}
@classmethod
def from_dict(cls, value: object) -> "StageSpec":
if not isinstance(value, Mapping):
raise ValueError("stage specification must be an object")
fields = {
"stage_id",
"kind",
"entry_point",
"needs",
"inputs",
"outputs",
"parameter_names",
"resources",
"execution",
"retry",
"verifier",
"trust_modes",
"max_fan_out",
"cacheable",
"loop",
"stream",
"gang",
"side_effect",
}
require_exact_keys(value, fields, "stage specification")
arrays = (value["needs"], value["parameter_names"], value["trust_modes"])
if any(not isinstance(item, list) for item in arrays):
raise ValueError(
"stage needs, parameter_names, and trust_modes must be arrays"
)
inputs, outputs = value["inputs"], value["outputs"]
if not isinstance(inputs, Mapping) or not isinstance(outputs, Mapping):
raise ValueError("stage inputs and outputs must be objects")
return cls(
stage_id=value["stage_id"], # type: ignore[arg-type]
kind=value["kind"], # type: ignore[arg-type]
entry_point=value["entry_point"], # type: ignore[arg-type]
needs=tuple(value["needs"]), # type: ignore[arg-type]
inputs={name: PortSpec.from_dict(port) for name, port in inputs.items()},
outputs={name: PortSpec.from_dict(port) for name, port in outputs.items()},
parameter_names=tuple(value["parameter_names"]), # type: ignore[arg-type]
resources=ResourceRequirements.from_dict(value["resources"]),
execution=ExecutionProfile.from_dict(value["execution"]),
retry=RetryPolicy.from_dict(value["retry"]),
verifier=None
if value["verifier"] is None
else ComponentRef.from_dict(value["verifier"]),
trust_modes=tuple(value["trust_modes"]), # type: ignore[arg-type]
max_fan_out=value["max_fan_out"], # type: ignore[arg-type]
cacheable=value["cacheable"], # type: ignore[arg-type]
loop=None if value["loop"] is None else LoopSpec.from_dict(value["loop"]),
stream=None
if value["stream"] is None
else StreamSpec.from_dict(value["stream"]),
gang=None if value["gang"] is None else GangSpec.from_dict(value["gang"]),
side_effect=None
if value["side_effect"] is None
else SideEffectSpec.from_dict(value["side_effect"]),
)
@dataclass(frozen=True, slots=True)
class WorkflowSpec:
"""A versioned acyclic workflow: inputs, stages, edges, and outputs.
Construction validates complete input bindings, matching ``needs``
declarations, edge schema compatibility, acyclicity, and output port
resolution.
"""
workflow_id: str
inputs: Mapping[str, PortSpec]
stages: tuple[StageSpec, ...]
edges: tuple[ArtifactEdge, ...]
outputs: Mapping[str, PortRef]
failure_policy: WorkflowFailurePolicy = WorkflowFailurePolicy.FAIL_FAST
max_tasks: int = 10_000
max_output_bytes: int = 10 * 1024 * 1024 * 1024
schema_version: int = WORKFLOW_SCHEMA_VERSION
def __post_init__(self) -> None:
require_schema_version(
self.schema_version, WORKFLOW_SCHEMA_VERSION, "workflow schema_version"
)
object.__setattr__(
self, "workflow_id", require_identifier(self.workflow_id, "workflow_id")
)
object.__setattr__(
self, "inputs", _port_mapping(self.inputs, "workflow.inputs")
)
stages = tuple(self.stages)
if not stages or any(not isinstance(stage, StageSpec) for stage in stages):
raise ValueError("workflow stages must contain at least one StageSpec")
stage_by_id = {stage.stage_id: stage for stage in stages}
if len(stage_by_id) != len(stages):
raise ValueError("workflow stage IDs must be unique")
object.__setattr__(self, "stages", stages)
edges = tuple(self.edges)
if any(not isinstance(edge, ArtifactEdge) for edge in edges):
raise ValueError("workflow edges must contain ArtifactEdge values")
if len({(edge.source, edge.target) for edge in edges}) != len(edges):
raise ValueError("workflow edges must be unique")
object.__setattr__(self, "edges", edges)
if not isinstance(self.outputs, Mapping) or not self.outputs:
raise ValueError("workflow outputs must be a non-empty object")
outputs: dict[str, PortRef] = {}
for name, reference in self.outputs.items():
canonical = require_identifier(name, "workflow output")
if not isinstance(reference, PortRef) or reference.stage_id is None:
raise ValueError("workflow outputs must reference stage output ports")
outputs[canonical] = reference
object.__setattr__(self, "outputs", MappingProxyType(outputs))
object.__setattr__(
self,
"failure_policy",
enum_value(
WorkflowFailurePolicy, self.failure_policy, "workflow.failure_policy"
),
)
object.__setattr__(
self,
"max_tasks",
require_positive_int(self.max_tasks, "workflow.max_tasks"),
)
object.__setattr__(
self,
"max_output_bytes",
require_positive_int(self.max_output_bytes, "workflow.max_output_bytes"),
)
self._validate_graph(stage_by_id)
def _source_port(
self, reference: PortRef, stages: Mapping[str, StageSpec]
) -> PortSpec:
if reference.stage_id is None:
try:
return self.inputs[reference.port]
except KeyError as error:
raise ValueError(
f"unknown workflow input port: {reference.port}"
) from error
try:
stage = stages[reference.stage_id]
return stage.outputs[reference.port]
except KeyError as error:
raise ValueError(
f"unknown source stage output: {reference.stage_id}.{reference.port}"
) from error
def _validate_graph(self, stages: Mapping[str, StageSpec]) -> None:
incoming: dict[tuple[str, str], ArtifactEdge] = {}
dependencies: dict[str, set[str]] = {stage_id: set() for stage_id in stages}
for edge in self.edges:
source_port = self._source_port(edge.source, stages)
assert edge.target.stage_id is not None
try:
target_stage = stages[edge.target.stage_id]
target_port = target_stage.inputs[edge.target.port]
except KeyError as error:
raise ValueError(
f"unknown target stage input: {edge.target.stage_id}.{edge.target.port}"
) from error
target_key = (edge.target.stage_id, edge.target.port)
if target_key in incoming:
raise ValueError("each stage input must have exactly one artifact edge")
incoming[target_key] = edge
same_schema = source_port.schema == target_port.schema
direct_match = source_port == target_port
map_fan_in = (
source_port.cardinality.value == "one"
and target_port.cardinality.value == "many"
and target_port.collection.value in {"ordered", "keyed", "set"}
)
if not same_schema or not (direct_match or map_fan_in):
raise ValueError(
"artifact edge source and target port declarations are incompatible"
)
if edge.source.stage_id is not None:
dependencies[edge.target.stage_id].add(edge.source.stage_id)
for stage in stages.values():
missing = [
name for name in stage.inputs if (stage.stage_id, name) not in incoming
]
if missing:
raise ValueError(
f"stage {stage.stage_id} has unbound inputs: {', '.join(sorted(missing))}"
)
if dependencies[stage.stage_id] != set(stage.needs):
raise ValueError(
f"stage {stage.stage_id} needs do not match its artifact edges"
)
remaining = {name: set(values) for name, values in dependencies.items()}
ready = sorted(name for name, values in remaining.items() if not values)
visited: list[str] = []
while ready:
current = ready.pop(0)
visited.append(current)
for name, values in remaining.items():
if current in values:
values.remove(current)
if not values and name not in visited and name not in ready:
ready.append(name)
ready.sort()
if len(visited) != len(stages):
raise ValueError("workflow graph must be acyclic")
for reference in self.outputs.values():
self._source_port(reference, stages)
def output_ports(self) -> Mapping[str, PortSpec]:
stages = {stage.stage_id: stage for stage in self.stages}
return MappingProxyType(
{
name: self._source_port(reference, stages)
for name, reference in self.outputs.items()
}
)
def to_dict(self) -> dict[str, object]:
return {
"schema_version": self.schema_version,
"workflow_id": self.workflow_id,
"inputs": {name: port.to_dict() for name, port in self.inputs.items()},
"stages": [stage.to_dict() for stage in self.stages],
"edges": [edge.to_dict() for edge in self.edges],
"outputs": {
name: reference.to_dict() for name, reference in self.outputs.items()
},
"failure_policy": self.failure_policy.value,
"max_tasks": self.max_tasks,
"max_output_bytes": self.max_output_bytes,
}
@classmethod
def from_dict(cls, value: object) -> "WorkflowSpec":
if not isinstance(value, Mapping):
raise ValueError("workflow specification must be an object")
fields = {
"schema_version",
"workflow_id",
"inputs",
"stages",
"edges",
"outputs",
"failure_policy",
"max_tasks",
"max_output_bytes",
}
require_exact_keys(value, fields, "workflow specification")
inputs, outputs = value["inputs"], value["outputs"]
stages, edges = value["stages"], value["edges"]
if not isinstance(inputs, Mapping) or not isinstance(outputs, Mapping):
raise ValueError("workflow inputs and outputs must be objects")
if not isinstance(stages, list) or not isinstance(edges, list):
raise ValueError("workflow stages and edges must be arrays")
return cls(
schema_version=value["schema_version"], # type: ignore[arg-type]
workflow_id=value["workflow_id"], # type: ignore[arg-type]
inputs={name: PortSpec.from_dict(port) for name, port in inputs.items()},
stages=tuple(StageSpec.from_dict(stage) for stage in stages),
edges=tuple(ArtifactEdge.from_dict(edge) for edge in edges),
outputs={
name: PortRef.from_dict(reference)
for name, reference in outputs.items()
},
failure_policy=value["failure_policy"], # type: ignore[arg-type]
max_tasks=value["max_tasks"], # type: ignore[arg-type]
max_output_bytes=value["max_output_bytes"], # type: ignore[arg-type]
)