move subgraph operations to wf_api

ok these are some composition stuff so tgis is probably the right place to live
This commit is contained in:
lda
2026-06-01 21:53:16 +07:00 Verified
parent 963f77fcb2
commit 0d43b15b5c
7 changed files with 309 additions and 239 deletions
+239
View File
@@ -0,0 +1,239 @@
from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass
from pydantic import TypeAdapter
from wf_artifacts import (
AvailableSource,
DependencyDiagnostic,
DiagnosticSeverity,
WorkflowArtifact,
WorkflowArtifactStore,
WorkflowDeployment,
validate_deployment_dependencies,
)
from wf_core import (
AsyncNodeHandler,
InterruptNode,
NodeUse,
PreparedSubgraph,
SubgraphNode,
Workflow,
)
from wf_core.models.steps import Step
from wf_core.models.workflow_refs import WorkflowRef
from wf_platform import CapabilitySource
from .models import RawWorkflowPlan
from .runtime_dependencies import resolve_runtime_dependencies
__all__ = [
"SavedSubgraphTree",
"direct_wrapper_interrupt_diagnostic",
"prepare_saved_subgraphs",
"resolve_saved_subgraph_tree",
"saved_subgraph_tree_from_snapshots",
"validate_saved_subgraph_tree",
]
_STEPS_ADAPTER = TypeAdapter(list[Step])
@dataclass(frozen=True, slots=True)
class SavedSubgraphTree:
"""Saved descendant artifacts prepared from one root artifact boundary."""
artifacts_by_ref: dict[str, WorkflowArtifact]
diagnostics: list[DependencyDiagnostic]
def saved_subgraph_tree_from_snapshots(
child_artifacts: list[WorkflowArtifact],
) -> SavedSubgraphTree:
"""Restore the exact saved-child definitions pinned by a durable run."""
return SavedSubgraphTree(
artifacts_by_ref={
f"workflow.{artifact.id}.v{artifact.version}": artifact
for artifact in child_artifacts
},
diagnostics=[],
)
def resolve_saved_subgraph_tree(
*,
root_artifact: WorkflowArtifact,
artifact_store: WorkflowArtifactStore,
) -> SavedSubgraphTree:
"""Load exact saved descendants and report missing refs or recursion cycles.
A saved subgraph ref identifies an immutable artifact version. This loader
intentionally does not resolve capabilities or deployment bindings; it
identifies the artifact tree that later platform validation/preparation
will operate on.
"""
artifacts_by_ref: dict[str, WorkflowArtifact] = {}
diagnostics: list[DependencyDiagnostic] = []
_visit_saved_children(
artifact=root_artifact,
artifact_store=artifact_store,
active={(root_artifact.id, root_artifact.version)},
artifacts_by_ref=artifacts_by_ref,
diagnostics=diagnostics,
)
return SavedSubgraphTree(
artifacts_by_ref=artifacts_by_ref,
diagnostics=diagnostics,
)
def validate_saved_subgraph_tree(
*,
tree: SavedSubgraphTree,
deployment: WorkflowDeployment,
sources: list[AvailableSource],
) -> list[DependencyDiagnostic]:
"""Validate saved descendants under the parent deployment environment."""
diagnostics = list(tree.diagnostics)
for child in tree.artifacts_by_ref.values():
diagnostics.extend(
validate_deployment_dependencies(
artifact=child,
deployment=deployment,
sources=sources,
)
)
return diagnostics
def prepare_saved_subgraphs(
*,
tree: SavedSubgraphTree,
deployment: WorkflowDeployment | None,
sources: dict[str, CapabilitySource],
compile_plan: Callable[[RawWorkflowPlan, dict[str, str] | None], Workflow],
) -> dict[str, PreparedSubgraph[AsyncNodeHandler]]:
"""Compile saved descendants using one inherited deployment environment.
The tree has already fixed exact artifact versions. Binding resolution is
deliberately shared with the root deployment; per-use-site deployment
overrides are a future platform feature rather than an implicit fallback.
"""
if tree.diagnostics:
messages = "; ".join(diagnostic.message for diagnostic in tree.diagnostics)
raise ValueError(f"cannot prepare invalid saved subgraph tree: {messages}")
prepared: dict[str, PreparedSubgraph[AsyncNodeHandler]] = {}
for ref_display, child in tree.artifacts_by_ref.items():
plan = RawWorkflowPlan.model_validate(child.plan)
dependencies = resolve_runtime_dependencies(
artifact=child,
deployment=deployment,
sources=sources,
plan_node_names=[
node.node for node in plan.nodes if isinstance(node, NodeUse)
],
)
prepared[ref_display] = PreparedSubgraph(
workflow=compile_plan(plan, dependencies.node_name_bindings),
registry=dependencies.node_registry,
reducers=dependencies.reducers,
)
return prepared
def direct_wrapper_interrupt_diagnostic(
artifact: WorkflowArtifact,
) -> DependencyDiagnostic | None:
"""Reject direct wrapper calls that cannot return a resumable run handle.
Deployment execution supports interrupt/resume through a durable `run_id`;
`call_capability` remains a single-call authoring probe.
"""
if not any(isinstance(node, InterruptNode) for node in _artifact_steps(artifact)):
return None
return DependencyDiagnostic(
severity=DiagnosticSeverity.ERROR,
code="interrupting_wrapper_call_unsupported",
logical_ref=f"workflow.{artifact.id}.v{artifact.version}",
message=(
"Direct wrapper calls cannot pause for interrupt input; run the "
"artifact through a deployment to receive a resumable run_id."
),
repair_hint="Save a deployment and call wf.workflow.run_deployment instead.",
)
def _visit_saved_children(
*,
artifact: WorkflowArtifact,
artifact_store: WorkflowArtifactStore,
active: set[tuple[str, int]],
artifacts_by_ref: dict[str, WorkflowArtifact],
diagnostics: list[DependencyDiagnostic],
) -> None:
for ref in _saved_child_refs(artifact):
identity = _saved_identity(ref)
if identity in active:
diagnostics.append(_cycle_diagnostic(ref))
continue
if ref.display in artifacts_by_ref:
continue
try:
child = artifact_store.get_artifact(*identity)
except KeyError:
diagnostics.append(_missing_diagnostic(ref))
continue
artifacts_by_ref[ref.display] = child
_visit_saved_children(
artifact=child,
artifact_store=artifact_store,
active=active | {identity},
artifacts_by_ref=artifacts_by_ref,
diagnostics=diagnostics,
)
def _saved_child_refs(artifact: WorkflowArtifact) -> list[WorkflowRef]:
return [
node.workflow
for node in _artifact_steps(artifact)
if isinstance(node, SubgraphNode) and node.workflow.artifact_id is not None
]
def _artifact_steps(artifact: WorkflowArtifact) -> list[Step]:
"""Validate only step payloads needed for dependency discovery."""
raw_nodes = artifact.plan.get("nodes", [])
return _STEPS_ADAPTER.validate_python(raw_nodes)
def _saved_identity(ref: WorkflowRef) -> tuple[str, int]:
"""Return the required saved-ref fields after structural model validation."""
if ref.artifact_id is None or ref.version is None:
raise ValueError(f"workflow ref {ref.display!r} is not a saved artifact ref")
return ref.artifact_id, ref.version
def _missing_diagnostic(ref: WorkflowRef) -> DependencyDiagnostic:
return DependencyDiagnostic(
severity=DiagnosticSeverity.ERROR,
code="workflow_dependency_missing",
logical_ref=ref.display,
message=f"Saved child workflow {ref.display!r} is unavailable.",
repair_hint=(
"Save the referenced artifact version or update the parent graph."
),
)
def _cycle_diagnostic(ref: WorkflowRef) -> DependencyDiagnostic:
return DependencyDiagnostic(
severity=DiagnosticSeverity.ERROR,
code="workflow_dependency_cycle",
logical_ref=ref.display,
message=f"Saved child workflow {ref.display!r} creates a dependency cycle.",
repair_hint="Remove the recursive saved subgraph reference.",
)
+1 -1
View File
@@ -55,7 +55,7 @@ from ...shared.errors import error_payload
from ...shared.names import RESERVED_CONNECTION_IDS from ...shared.names import RESERVED_CONNECTION_IDS
from ...storage import Store from ...storage import Store
from ...workflow.wrappers import _model_from_schema from ...workflow.wrappers import _model_from_schema
from ...workflow_surface.saved_subgraphs import ( from wf_api.saved_subgraphs import (
SavedSubgraphTree, SavedSubgraphTree,
prepare_saved_subgraphs, prepare_saved_subgraphs,
resolve_saved_subgraph_tree, resolve_saved_subgraph_tree,
+10 -10
View File
@@ -57,18 +57,22 @@ from wf_api.constants import (
from wf_api.models import RawWorkflowPlan from wf_api.models import RawWorkflowPlan
from wf_api.next_actions import NextActions from wf_api.next_actions import NextActions
from wf_api.refs import parse_workflow_surface_capability_id from wf_api.refs import parse_workflow_surface_capability_id
from wf_api.saved_subgraphs import (
from ..broker.service.adapters import require_adapter
from ..events import make_event
from ..shared import matches_query, paged_list_payload
from .models import TraceRange
from .saved_subgraphs import (
SavedSubgraphTree, SavedSubgraphTree,
direct_wrapper_interrupt_diagnostic, direct_wrapper_interrupt_diagnostic,
resolve_saved_subgraph_tree, resolve_saved_subgraph_tree,
saved_subgraph_tree_from_snapshots, saved_subgraph_tree_from_snapshots,
validate_saved_subgraph_tree, validate_saved_subgraph_tree,
) )
from wf_api.wrapper_hints import (
workflow_output_schema_for_authoring,
wrapper_hints_for_capability,
)
from ..broker.service.adapters import require_adapter
from ..events import make_event
from ..shared import matches_query, paged_list_payload
from .models import TraceRange
from .run_lifecycle import ( from .run_lifecycle import (
create_pinned_environment, create_pinned_environment,
has_blocking_diagnostics, has_blocking_diagnostics,
@@ -78,10 +82,6 @@ from .run_lifecycle import (
restore_interrupted_run, restore_interrupted_run,
validate_pinned_resume_environment, validate_pinned_resume_environment,
) )
from wf_api.wrapper_hints import (
workflow_output_schema_for_authoring,
wrapper_hints_for_capability,
)
LIVE_SOURCE_CHECK_TIMEOUT_SECONDS = 8.0 LIVE_SOURCE_CHECK_TIMEOUT_SECONDS = 8.0
_LIVE_SOURCE_CHECK_FAILURES = ( _LIVE_SOURCE_CHECK_FAILURES = (
+1 -1
View File
@@ -26,7 +26,7 @@ from wf_core import (
load_run_state, load_run_state,
) )
from .saved_subgraphs import SavedSubgraphTree from wf_api.saved_subgraphs import SavedSubgraphTree
def create_pinned_environment( def create_pinned_environment(
+23 -226
View File
@@ -1,230 +1,27 @@
"""Compatibility shim — canonical implementation moved to wf_api.saved_subgraphs.
This module re-exports every public symbol so that existing
``from wf_mcp.workflow_surface.saved_subgraphs import ...`` continues to work
without changes. New code should import from ``wf_api.saved_subgraphs``
directly.
"""
from __future__ import annotations from __future__ import annotations
from collections.abc import Callable from wf_api.saved_subgraphs import (
from dataclasses import dataclass SavedSubgraphTree,
direct_wrapper_interrupt_diagnostic,
from pydantic import TypeAdapter prepare_saved_subgraphs,
resolve_saved_subgraph_tree,
from wf_artifacts import ( saved_subgraph_tree_from_snapshots,
AvailableSource, validate_saved_subgraph_tree,
DependencyDiagnostic,
DiagnosticSeverity,
WorkflowArtifact,
WorkflowArtifactStore,
WorkflowDeployment,
validate_deployment_dependencies,
) )
from wf_core import (
AsyncNodeHandler,
InterruptNode,
NodeUse,
PreparedSubgraph,
SubgraphNode,
Workflow,
)
from wf_core.models.steps import Step
from wf_core.models.workflow_refs import WorkflowRef
from wf_platform import CapabilitySource
from wf_api.runtime_dependencies import resolve_runtime_dependencies __all__ = [
from wf_api.models import RawWorkflowPlan "SavedSubgraphTree",
"direct_wrapper_interrupt_diagnostic",
_STEPS_ADAPTER = TypeAdapter(list[Step]) "prepare_saved_subgraphs",
"resolve_saved_subgraph_tree",
"saved_subgraph_tree_from_snapshots",
@dataclass(frozen=True, slots=True) "validate_saved_subgraph_tree",
class SavedSubgraphTree: ]
"""Saved descendant artifacts prepared from one root artifact boundary."""
artifacts_by_ref: dict[str, WorkflowArtifact]
diagnostics: list[DependencyDiagnostic]
def saved_subgraph_tree_from_snapshots(
child_artifacts: list[WorkflowArtifact],
) -> SavedSubgraphTree:
"""Restore the exact saved-child definitions pinned by a durable run."""
return SavedSubgraphTree(
artifacts_by_ref={
f"workflow.{artifact.id}.v{artifact.version}": artifact
for artifact in child_artifacts
},
diagnostics=[],
)
def resolve_saved_subgraph_tree(
*,
root_artifact: WorkflowArtifact,
artifact_store: WorkflowArtifactStore,
) -> SavedSubgraphTree:
"""Load exact saved descendants and report missing refs or recursion cycles.
A saved subgraph ref identifies an immutable artifact version. This loader
intentionally does not resolve capabilities or deployment bindings; it
identifies the artifact tree that later platform validation/preparation
will operate on.
"""
artifacts_by_ref: dict[str, WorkflowArtifact] = {}
diagnostics: list[DependencyDiagnostic] = []
_visit_saved_children(
artifact=root_artifact,
artifact_store=artifact_store,
active={(root_artifact.id, root_artifact.version)},
artifacts_by_ref=artifacts_by_ref,
diagnostics=diagnostics,
)
return SavedSubgraphTree(
artifacts_by_ref=artifacts_by_ref,
diagnostics=diagnostics,
)
def validate_saved_subgraph_tree(
*,
tree: SavedSubgraphTree,
deployment: WorkflowDeployment,
sources: list[AvailableSource],
) -> list[DependencyDiagnostic]:
"""Validate saved descendants under the parent deployment environment."""
diagnostics = list(tree.diagnostics)
for child in tree.artifacts_by_ref.values():
diagnostics.extend(
validate_deployment_dependencies(
artifact=child,
deployment=deployment,
sources=sources,
)
)
return diagnostics
def prepare_saved_subgraphs(
*,
tree: SavedSubgraphTree,
deployment: WorkflowDeployment | None,
sources: dict[str, CapabilitySource],
compile_plan: Callable[[RawWorkflowPlan, dict[str, str] | None], Workflow],
) -> dict[str, PreparedSubgraph[AsyncNodeHandler]]:
"""Compile saved descendants using one inherited deployment environment.
The tree has already fixed exact artifact versions. Binding resolution is
deliberately shared with the root deployment; per-use-site deployment
overrides are a future platform feature rather than an implicit fallback.
"""
if tree.diagnostics:
messages = "; ".join(diagnostic.message for diagnostic in tree.diagnostics)
raise ValueError(f"cannot prepare invalid saved subgraph tree: {messages}")
prepared: dict[str, PreparedSubgraph[AsyncNodeHandler]] = {}
for ref_display, child in tree.artifacts_by_ref.items():
plan = RawWorkflowPlan.model_validate(child.plan)
dependencies = resolve_runtime_dependencies(
artifact=child,
deployment=deployment,
sources=sources,
plan_node_names=[
node.node for node in plan.nodes if isinstance(node, NodeUse)
],
)
prepared[ref_display] = PreparedSubgraph(
workflow=compile_plan(plan, dependencies.node_name_bindings),
registry=dependencies.node_registry,
reducers=dependencies.reducers,
)
return prepared
def direct_wrapper_interrupt_diagnostic(
artifact: WorkflowArtifact,
) -> DependencyDiagnostic | None:
"""Reject direct wrapper calls that cannot return a resumable run handle.
Deployment execution supports interrupt/resume through a durable `run_id`;
`call_capability` remains a single-call authoring probe.
"""
if not any(isinstance(node, InterruptNode) for node in _artifact_steps(artifact)):
return None
return DependencyDiagnostic(
severity=DiagnosticSeverity.ERROR,
code="interrupting_wrapper_call_unsupported",
logical_ref=f"workflow.{artifact.id}.v{artifact.version}",
message=(
"Direct wrapper calls cannot pause for interrupt input; run the "
"artifact through a deployment to receive a resumable run_id."
),
repair_hint="Save a deployment and call wf.workflow.run_deployment instead.",
)
def _visit_saved_children(
*,
artifact: WorkflowArtifact,
artifact_store: WorkflowArtifactStore,
active: set[tuple[str, int]],
artifacts_by_ref: dict[str, WorkflowArtifact],
diagnostics: list[DependencyDiagnostic],
) -> None:
for ref in _saved_child_refs(artifact):
identity = _saved_identity(ref)
if identity in active:
diagnostics.append(_cycle_diagnostic(ref))
continue
if ref.display in artifacts_by_ref:
continue
try:
child = artifact_store.get_artifact(*identity)
except KeyError:
diagnostics.append(_missing_diagnostic(ref))
continue
artifacts_by_ref[ref.display] = child
_visit_saved_children(
artifact=child,
artifact_store=artifact_store,
active=active | {identity},
artifacts_by_ref=artifacts_by_ref,
diagnostics=diagnostics,
)
def _saved_child_refs(artifact: WorkflowArtifact) -> list[WorkflowRef]:
return [
node.workflow
for node in _artifact_steps(artifact)
if isinstance(node, SubgraphNode) and node.workflow.artifact_id is not None
]
def _artifact_steps(artifact: WorkflowArtifact) -> list[Step]:
"""Validate only step payloads needed for dependency discovery."""
raw_nodes = artifact.plan.get("nodes", [])
return _STEPS_ADAPTER.validate_python(raw_nodes)
def _saved_identity(ref: WorkflowRef) -> tuple[str, int]:
"""Return the required saved-ref fields after structural model validation."""
if ref.artifact_id is None or ref.version is None:
raise ValueError(f"workflow ref {ref.display!r} is not a saved artifact ref")
return ref.artifact_id, ref.version
def _missing_diagnostic(ref: WorkflowRef) -> DependencyDiagnostic:
return DependencyDiagnostic(
severity=DiagnosticSeverity.ERROR,
code="workflow_dependency_missing",
logical_ref=ref.display,
message=f"Saved child workflow {ref.display!r} is unavailable.",
repair_hint=(
"Save the referenced artifact version or update the parent graph."
),
)
def _cycle_diagnostic(ref: WorkflowRef) -> DependencyDiagnostic:
return DependencyDiagnostic(
severity=DiagnosticSeverity.ERROR,
code="workflow_dependency_cycle",
logical_ref=ref.display,
message=f"Saved child workflow {ref.display!r} creates a dependency cycle.",
repair_hint="Remove the recursive saved subgraph reference.",
)
@@ -0,0 +1,34 @@
from __future__ import annotations
import wf_api.saved_subgraphs as canonical
import wf_mcp.workflow_surface.saved_subgraphs as shim
def test_canonical_import_exports_expected_symbols() -> None:
assert hasattr(canonical, "SavedSubgraphTree")
assert hasattr(canonical, "resolve_saved_subgraph_tree")
assert hasattr(canonical, "prepare_saved_subgraphs")
assert hasattr(canonical, "validate_saved_subgraph_tree")
assert hasattr(canonical, "saved_subgraph_tree_from_snapshots")
assert hasattr(canonical, "direct_wrapper_interrupt_diagnostic")
def test_shim_import_still_works() -> None:
assert hasattr(shim, "SavedSubgraphTree")
assert hasattr(shim, "resolve_saved_subgraph_tree")
assert hasattr(shim, "prepare_saved_subgraphs")
def test_shim_symbols_are_identical_to_canonical() -> None:
assert shim.SavedSubgraphTree is canonical.SavedSubgraphTree
assert shim.resolve_saved_subgraph_tree is canonical.resolve_saved_subgraph_tree
assert shim.prepare_saved_subgraphs is canonical.prepare_saved_subgraphs
assert shim.validate_saved_subgraph_tree is canonical.validate_saved_subgraph_tree
assert (
shim.saved_subgraph_tree_from_snapshots
is canonical.saved_subgraph_tree_from_snapshots
)
assert (
shim.direct_wrapper_interrupt_diagnostic
is canonical.direct_wrapper_interrupt_diagnostic
)
+1 -1
View File
@@ -14,7 +14,7 @@ from wf_mcp.broker import WfMcpService
from wf_mcp.models import ConnectionConfig from wf_mcp.models import ConnectionConfig
from wf_mcp.storage import FileStore from wf_mcp.storage import FileStore
from wf_mcp.workflow_surface import WorkflowSurfaceHandlers from wf_mcp.workflow_surface import WorkflowSurfaceHandlers
from wf_mcp.workflow_surface.saved_subgraphs import resolve_saved_subgraph_tree from wf_api.saved_subgraphs import resolve_saved_subgraph_tree
from .test_support import echo_tool, input_binding, local_temp_root, output_binding from .test_support import echo_tool, input_binding, local_temp_root, output_binding