workflow ref

This commit is contained in:
lda
2026-05-25 03:27:30 +07:00 Verified
parent 16d5204fbc
commit 84b931f821
11 changed files with 227 additions and 20 deletions
+4 -2
View File
@@ -254,8 +254,10 @@ Today there are two different authoring paths:
native parent state. native parent state.
- `subgraph_ref` builds a native `SubgraphNode` contract from a compiled - `subgraph_ref` builds a native `SubgraphNode` contract from a compiled
`Workflow`: child input schema, output schema, and workflow outcomes are `Workflow`: child input schema, output schema, and workflow outcomes are
copied into the boundary. Runtime execution still raises until native copied into the boundary. The workflow reference is structural: local
subgraph scopes are implemented. workflows use `{"name": "child"}`, saved artifacts can use
`{"artifact_id": "child", "version": 1}`. Runtime execution still raises
until native subgraph scopes are implemented.
- `WorkflowBuilder.subgraph(...)` is the builder-facing version of - `WorkflowBuilder.subgraph(...)` is the builder-facing version of
`subgraph_ref`: it appends the native boundary step and returns it as a `subgraph_ref`: it appends the native boundary step and returns it as a
`StepRef` for `connect()` / `set_entry_point()`. `StepRef` for `connect()` / `set_entry_point()`.
@@ -64,8 +64,11 @@ class SubgraphNode(BaseModel):
``` ```
Current implementation status: `wf_core` has a first placeholder Current implementation status: `wf_core` has a first placeholder
`SubgraphNode`, but `workflow` is still a plain string reference. The placeholder `SubgraphNode`. Its `workflow` field is a structural `WorkflowRef`: local
also carries `input_schema` and `output_schema` so validation can check parent compiled workflows use `{"name": "child"}`, while saved artifacts can use
`{"artifact_id": "child", "version": 1}`. Legacy strings still parse as input,
but saved graphs should persist the structural shape. The placeholder also
carries `input_schema` and `output_schema` so validation can check parent
bindings before native execution exists. Runtime execution intentionally raises bindings before native execution exists. Runtime execution intentionally raises
until a later slice adds child scope/frame execution. until a later slice adds child scope/frame execution.
@@ -316,10 +319,10 @@ child = parent.subgraph(
This copies the compiled child workflow contract into a core `SubgraphNode`, This copies the compiled child workflow contract into a core `SubgraphNode`,
appends it to the builder, and returns the step for normal routing. It does not appends it to the builder, and returns the step for normal routing. It does not
make the child executable yet. `workflow` is still a string reference inside the make the child executable yet. The core `workflow` field is structural, but
core model; higher layers need a structural workflow reference before higher layers still need dependency resolution before saved/deployed workflow
saved/deployed workflow dependencies become stable. The lower-level refs can run. The lower-level `subgraph_ref(...)` helper exists for code that
`subgraph_ref(...)` helper exists for code that wants only the core step object. wants only the core step object.
Possible API: Possible API:
+34 -7
View File
@@ -19,6 +19,7 @@ from wf_core import (
StateSchema, StateSchema,
SubgraphNode, SubgraphNode,
Workflow, Workflow,
WorkflowRef,
RunState, RunState,
execute_workflow, execute_workflow,
) )
@@ -34,6 +35,7 @@ from wf_core.models.steps import (
) )
from wf_core.paths import GraphSourcePath, LocalPath, StatePath from wf_core.paths import GraphSourcePath, LocalPath, StatePath
from wf_core.runtime.ops.merges import ReducerDefinition from wf_core.runtime.ops.merges import ReducerDefinition
from wf_platform import CapabilityRef
from ..dsl import Expr, GraphPath, PathArg, PathExpr, compile_condition from ..dsl import Expr, GraphPath, PathArg, PathExpr, compile_condition
from ..nodes.callables import SyncRegistryHandler from ..nodes.callables import SyncRegistryHandler
@@ -77,6 +79,30 @@ def _condition_base(condition: CoreCondition) -> str:
return "condition" return "condition"
def _workflow_ref_base(
workflow_ref: WorkflowRef | str | Mapping[str, object] | None,
workflow: Workflow,
) -> str:
"""Return an auto-id base for a subgraph ref without parsing display names."""
if isinstance(workflow_ref, WorkflowRef):
return slug_id(workflow_ref.name or workflow_ref.artifact_id or workflow.name)
if isinstance(workflow_ref, Mapping):
raw_name = workflow_ref.get("name") or workflow_ref.get("artifact_id")
return slug_id(str(raw_name or workflow.name))
return slug_id(workflow_ref or workflow.name)
def _capability_ref_name(name: str | CapabilityRef) -> str:
"""Return the current core NodeUse string for an authored capability ref.
`NodeUse.node` is still a string boundary because node definitions and
registries are keyed by string today. Accepting `CapabilityRef` here keeps
authoring code structural while confining display-name conversion to this
compatibility seam.
"""
return str(name)
def _canonical_input_bindings( def _canonical_input_bindings(
in_map: Mapping[GraphSourcePath, LocalPath], in_map: Mapping[GraphSourcePath, LocalPath],
input_values: Mapping[LocalPath, Any], input_values: Mapping[LocalPath, Any],
@@ -281,7 +307,7 @@ class WorkflowBuilder:
@overload @overload
def use_ref( def use_ref(
self, self,
name: str, name: str | CapabilityRef,
*, *,
id: str | None = None, id: str | None = None,
input: Sequence[InputBindingArg] | None = None, input: Sequence[InputBindingArg] | None = None,
@@ -293,7 +319,7 @@ class WorkflowBuilder:
@deprecated("use input/output canonical binding lists instead") @deprecated("use input/output canonical binding lists instead")
def use_ref( def use_ref(
self, self,
name: str, name: str | CapabilityRef,
*, *,
id: str | None = None, id: str | None = None,
in_map: MapArg | None = None, in_map: MapArg | None = None,
@@ -304,7 +330,7 @@ class WorkflowBuilder:
def use_ref( def use_ref(
self, self,
name: str, name: str | CapabilityRef,
*, *,
id: str | None = None, id: str | None = None,
input: Sequence[InputBindingArg] | None = None, input: Sequence[InputBindingArg] | None = None,
@@ -346,10 +372,11 @@ class WorkflowBuilder:
if output is not None if output is not None
else _canonical_output_bindings(normalize_output_mapping(out_map)) else _canonical_output_bindings(normalize_output_mapping(out_map))
) )
node_name = _capability_ref_name(name)
node = NodeUse( node = NodeUse(
id=id or self._next_step_id(slug_id(name)), id=id or self._next_step_id(slug_id(node_name)),
type="node", type="node",
node=name, node=node_name,
desc=desc, desc=desc,
input=node_input, input=node_input,
output=node_output, output=node_output,
@@ -364,7 +391,7 @@ class WorkflowBuilder:
id: str | None = None, id: str | None = None,
input: Sequence[InputBindingArg] | None = None, input: Sequence[InputBindingArg] | None = None,
output: Sequence[OutputBindingArg] | None = None, output: Sequence[OutputBindingArg] | None = None,
workflow_ref: str | None = None, workflow_ref: WorkflowRef | str | Mapping[str, object] | None = None,
desc: str | None = None, desc: str | None = None,
) -> SubgraphNode: ) -> SubgraphNode:
"""Add a native subgraph boundary using a compiled child workflow contract. """Add a native subgraph boundary using a compiled child workflow contract.
@@ -373,7 +400,7 @@ class WorkflowBuilder:
until wf_core grows child workflow scope/frame execution. until wf_core grows child workflow scope/frame execution.
""" """
node = subgraph_ref( node = subgraph_ref(
id=id or self._next_step_id(slug_id(workflow_ref or workflow.name)), id=id or self._next_step_id(_workflow_ref_base(workflow_ref, workflow)),
workflow=workflow, workflow=workflow,
input=normalize_input_bindings(input), input=normalize_input_bindings(input),
output=normalize_output_bindings(output), output=normalize_output_bindings(output),
+4 -2
View File
@@ -9,8 +9,10 @@ from wf_core import (
RuntimeContext, RuntimeContext,
SubgraphNode, SubgraphNode,
Workflow, Workflow,
WorkflowRef,
execute_workflow, execute_workflow,
execute_workflow_async, execute_workflow_async,
workflow_ref_from,
) )
from wf_core.models.steps import InputBinding, OutputBinding from wf_core.models.steps import InputBinding, OutputBinding
@@ -26,7 +28,7 @@ def subgraph_ref(
workflow: Workflow, workflow: Workflow,
input: list[InputBinding] | None = None, input: list[InputBinding] | None = None,
output: list[OutputBinding] | None = None, output: list[OutputBinding] | None = None,
workflow_ref: str | None = None, workflow_ref: WorkflowRef | str | Mapping[str, object] | None = None,
desc: str | None = None, desc: str | None = None,
) -> SubgraphNode: ) -> SubgraphNode:
"""Create a native subgraph boundary from a compiled child workflow contract. """Create a native subgraph boundary from a compiled child workflow contract.
@@ -39,7 +41,7 @@ def subgraph_ref(
return SubgraphNode( return SubgraphNode(
id=id, id=id,
type="subgraph", type="subgraph",
workflow=workflow_ref or workflow.name, workflow=workflow_ref_from(workflow_ref or {"name": workflow.name}),
desc=desc, desc=desc,
input_schema=workflow.input_schema, input_schema=workflow.input_schema,
output_schema=workflow.output_schema, output_schema=workflow.output_schema,
+4
View File
@@ -18,6 +18,8 @@ from .models import (
StateSchema, StateSchema,
SubgraphNode, SubgraphNode,
Workflow, Workflow,
WorkflowRef,
workflow_ref_from,
) )
from .runtime import ( from .runtime import (
AsyncNodeHandler, AsyncNodeHandler,
@@ -84,6 +86,7 @@ __all__ = [
"ValidationIssueCode", "ValidationIssueCode",
"ValidationReport", "ValidationReport",
"Workflow", "Workflow",
"WorkflowRef",
"WorkflowExecutionError", "WorkflowExecutionError",
"coerce_node_result", "coerce_node_result",
"execute_workflow_async", "execute_workflow_async",
@@ -93,4 +96,5 @@ __all__ = [
"step_workflow_async", "step_workflow_async",
"step_workflow", "step_workflow",
"validate_workflow", "validate_workflow",
"workflow_ref_from",
] ]
+3
View File
@@ -24,6 +24,7 @@ from wf_core.models.steps import (
SubgraphNode, SubgraphNode,
) )
from wf_core.models.workflow import Edge, Workflow from wf_core.models.workflow import Edge, Workflow
from wf_core.models.workflow_refs import WorkflowRef, workflow_ref_from
__all__ = [ __all__ = [
"BinaryCondition", "BinaryCondition",
@@ -54,4 +55,6 @@ __all__ = [
"SubgraphNode", "SubgraphNode",
"VariadicCondition", "VariadicCondition",
"Workflow", "Workflow",
"WorkflowRef",
"workflow_ref_from",
] ]
+2 -1
View File
@@ -7,6 +7,7 @@ from pydantic import BaseModel, ConfigDict, Field, model_validator
from wf_core.models.conditions import Condition from wf_core.models.conditions import Condition
from wf_core.models.schemas import SchemaRef from wf_core.models.schemas import SchemaRef
from wf_core.models.workflow_refs import WorkflowRef
from wf_core.paths import GraphSourcePath, LocalPath, StatePath from wf_core.paths import GraphSourcePath, LocalPath, StatePath
@@ -167,7 +168,7 @@ class SubgraphNode(BaseModel):
id: str id: str
type: Literal["subgraph"] type: Literal["subgraph"]
workflow: str = Field( workflow: WorkflowRef = Field(
description=( description=(
"Reference to the child workflow artifact or registry key. The core " "Reference to the child workflow artifact or registry key. The core "
"does not resolve this reference until native subgraph runtime " "does not resolve this reference until native subgraph runtime "
+91
View File
@@ -0,0 +1,91 @@
from __future__ import annotations
from collections.abc import Mapping
from typing import Self
from pydantic import BaseModel, ConfigDict, Field, model_serializer, model_validator
class WorkflowRef(BaseModel):
"""Structural reference to a child workflow contract.
Core needs a workflow reference for graph shape and validation, but it must
not depend on artifact stores or MCP capability naming. Saved workflow
artifacts can use ``artifact_id`` plus ``version``; local compiled workflows
can use ``name``. Legacy strings parse as ``name`` unless they use the old
``workflow.<artifact>.v<version>`` display format.
"""
model_config = ConfigDict(extra="forbid")
name: str | None = Field(default=None, description="Local workflow registry name.")
artifact_id: str | None = Field(
default=None,
description="Saved workflow artifact id, when referencing an immutable artifact.",
)
version: int | None = Field(
default=None,
ge=1,
description="Saved workflow artifact version. Requires artifact_id.",
)
@model_validator(mode="before")
@classmethod
def _coerce_legacy_strings(cls, value: object) -> object:
if not isinstance(value, str):
return value
try:
artifact_id, version = _parse_legacy_workflow_capability(value)
except ValueError:
return {"name": value}
return {"artifact_id": artifact_id, "version": version}
@model_validator(mode="after")
def _validate_one_ref_kind(self) -> Self:
has_name = self.name is not None
has_artifact = self.artifact_id is not None or self.version is not None
if has_name == has_artifact:
raise ValueError(
"workflow ref requires exactly one of name or artifact_id/version"
)
if has_artifact and (self.artifact_id is None or self.version is None):
raise ValueError(
"workflow artifact ref requires both artifact_id and version"
)
if self.name is not None and not self.name.strip():
raise ValueError("workflow ref name must not be empty")
if self.artifact_id is not None and not self.artifact_id.strip():
raise ValueError("workflow artifact id must not be empty")
return self
@property
def display(self) -> str:
"""Return a human-readable compatibility name; do not parse this for meaning."""
if self.name is not None:
return self.name
return f"workflow.{self.artifact_id}.v{self.version}"
@model_serializer(mode="wrap")
def _serialize_without_none_fields(self, handler: object) -> dict[str, object]:
"""Persist only the active ref shape."""
if not callable(handler):
raise TypeError("workflow ref serializer handler must be callable")
data = handler(self)
if not isinstance(data, dict):
raise TypeError("workflow ref serializer expected a dict")
return {key: value for key, value in data.items() if value is not None}
def workflow_ref_from(value: WorkflowRef | str | Mapping[str, object]) -> WorkflowRef:
"""Normalize public helper inputs into the core workflow ref model."""
return WorkflowRef.model_validate(value)
def _parse_legacy_workflow_capability(value: str) -> tuple[str, int]:
prefix = "workflow."
if not value.startswith(prefix):
raise ValueError("not a workflow artifact display ref")
artifact_part, separator, version_part = value[len(prefix) :].rpartition(".v")
if not separator or not artifact_part or not version_part.isdecimal():
raise ValueError("not a workflow artifact display ref")
return artifact_part, int(version_part)
+18
View File
@@ -17,6 +17,7 @@ from wf_authoring import (
from wf_core import END, RunStatus, WorkflowExecutionError from wf_core import END, RunStatus, WorkflowExecutionError
from wf_core.models.steps import InputPathBinding, InputValueBinding from wf_core.models.steps import InputPathBinding, InputValueBinding
from wf_core.paths import GraphSourcePath, LocalPath, StatePath from wf_core.paths import GraphSourcePath, LocalPath, StatePath
from wf_platform import CapabilityRef
from tests.authoring.helpers import ( from tests.authoring.helpers import (
AutoBindInput, AutoBindInput,
@@ -376,6 +377,23 @@ def test_builder_use_ref_accepts_canonical_binding_dicts() -> None:
assert step.output[0].target == StatePath.of("echoed") assert step.output[0].target == StatePath.of("echoed")
def test_builder_use_ref_accepts_structural_capability_ref() -> None:
builder = WorkflowBuilder(
name="external_ref_structural",
input_schema={},
state_schema={"fields": {}},
output_schema={},
)
step = builder.use_ref(
CapabilityRef.parse("demo.personal.echo"),
input=[input_from(input_path("text"), "text")],
)
assert step.node == "demo.personal.echo"
assert step.id == "demo_personal_echo"
def test_builder_warns_when_explicit_deprecated_maps_are_used() -> None: def test_builder_warns_when_explicit_deprecated_maps_are_used() -> None:
builder = WorkflowBuilder( builder = WorkflowBuilder(
name="deprecated_maps", name="deprecated_maps",
+16 -1
View File
@@ -163,7 +163,7 @@ def test_subgraph_ref_copies_child_workflow_contract() -> None:
) )
assert step.type == "subgraph" assert step.type == "subgraph"
assert step.workflow == child.name assert step.workflow.name == child.name
assert step.input_schema == child.input_schema assert step.input_schema == child.input_schema
assert step.output_schema == child.output_schema assert step.output_schema == child.output_schema
assert step.outcomes == child.outcomes assert step.outcomes == child.outcomes
@@ -202,6 +202,21 @@ def test_subgraph_ref_contract_validates_in_parent_workflow() -> None:
assert parent.validate_structure().errors == [] assert parent.validate_structure().errors == []
def test_subgraph_ref_accepts_structural_saved_workflow_reference() -> None:
child = build_demo_workflow()
step = subgraph_ref(
id="run_child",
workflow=child,
workflow_ref={"artifact_id": "demo_child", "version": 2},
)
dumped = step.model_dump(mode="json")
assert dumped["workflow"]["artifact_id"] == "demo_child"
assert dumped["workflow"]["version"] == 2
assert "name" not in dumped["workflow"]
def test_workflow_builder_subgraph_adds_native_subgraph_node() -> None: def test_workflow_builder_subgraph_adds_native_subgraph_node() -> None:
class ParentInput(BaseModel): class ParentInput(BaseModel):
folder_id: str folder_id: str
+42 -1
View File
@@ -1,8 +1,14 @@
from __future__ import annotations from __future__ import annotations
from pydantic import BaseModel import pytest
from pydantic import BaseModel, ValidationError
from wf_artifacts import WorkflowCapabilityRef from wf_artifacts import WorkflowCapabilityRef
from wf_core import WorkflowRef
class CoreWorkflowRefPayload(BaseModel):
ref: WorkflowRef
def test_workflow_capability_ref_round_trips() -> None: def test_workflow_capability_ref_round_trips() -> None:
@@ -60,3 +66,38 @@ def test_workflow_capability_ref_rejects_other_namespaces() -> None:
assert "workflow" in str(exc) assert "workflow" in str(exc)
else: else:
raise AssertionError("expected non-workflow ref to be rejected") raise AssertionError("expected non-workflow ref to be rejected")
def test_core_workflow_ref_accepts_local_name_string() -> None:
payload = CoreWorkflowRefPayload.model_validate({"ref": "child_workflow"})
assert payload.ref.name == "child_workflow"
assert payload.ref.artifact_id is None
assert payload.model_dump(mode="json")["ref"]["name"] == "child_workflow"
def test_core_workflow_ref_accepts_legacy_saved_artifact_display_string() -> None:
payload = CoreWorkflowRefPayload.model_validate({"ref": "workflow.echo.wrapper.v2"})
assert payload.ref.name is None
assert payload.ref.artifact_id == "echo.wrapper"
assert payload.ref.version == 2
assert payload.model_dump(mode="json")["ref"]["artifact_id"] == "echo.wrapper"
assert payload.model_dump(mode="json")["ref"]["version"] == 2
def test_core_workflow_ref_accepts_structural_saved_artifact_ref() -> None:
payload = CoreWorkflowRefPayload.model_validate(
{"ref": {"artifact_id": "echo_wrapper", "version": 2}}
)
assert payload.ref.artifact_id == "echo_wrapper"
assert payload.ref.version == 2
assert payload.ref.display == "workflow.echo_wrapper.v2"
def test_core_workflow_ref_rejects_mixed_name_and_artifact_ref() -> None:
with pytest.raises(ValidationError, match="exactly one"):
CoreWorkflowRefPayload.model_validate(
{"ref": {"name": "child", "artifact_id": "echo", "version": 1}}
)