move to wf_api: Runtime dependencies

This commit is contained in:
lda
2026-06-01 20:50:56 +07:00 Verified
parent 232525b078
commit 7c0daeb928
6 changed files with 210 additions and 152 deletions
+4
View File
@@ -23,6 +23,8 @@ from .wrapper_hints import (
wrapper_hints_for_capability,
)
from .runtime_dependencies import RuntimeDependencies, resolve_runtime_dependencies
__all__ = [
"DEFAULT_CALL_STEP_ID",
"DEFAULT_ERROR_OUTCOME",
@@ -36,6 +38,7 @@ __all__ = [
"OutcomeCandidate",
"OutcomeCandidateKind",
"RUNTIME_ERROR_CAPABILITY",
"RuntimeDependencies",
"TraceRange",
"WorkflowApi",
"WorkflowApiBackend",
@@ -46,4 +49,5 @@ __all__ = [
"parse_workflow_surface_capability_id",
"workflow_output_schema_for_authoring",
"wrapper_hints_for_capability",
"resolve_runtime_dependencies",
]
+153
View File
@@ -0,0 +1,153 @@
from __future__ import annotations
from dataclasses import dataclass
from typing import Any
from wf_artifacts import RequiredCapability, WorkflowArtifact, WorkflowDeployment
from wf_authoring import AsyncRegistryHandler, NodeSpec, build_async_registry
from wf_core.runtime.ops.merges import ReducerDefinition
from wf_platform import CapabilityRef, CapabilitySource
@dataclass(frozen=True, slots=True)
class RuntimeDependencies:
"""Executable dependencies resolved for one workflow run."""
node_specs: dict[str, NodeSpec[Any, Any]]
node_name_bindings: dict[str, str]
node_registry: dict[str, AsyncRegistryHandler]
reducers: dict[str, ReducerDefinition]
def resolve_runtime_dependencies(
*,
artifact: WorkflowArtifact,
deployment: WorkflowDeployment | None,
sources: dict[str, CapabilitySource],
plan_node_names: list[str],
) -> RuntimeDependencies:
"""Resolve source-owned specs and reducers into runtime callables.
Node names in stored plans are still concrete today, so node specs resolve
by exact plan names. Reducers are already logical artifact dependencies, so
they resolve through deployment bindings and are registered under the
logical reducer name used by the workflow state schema.
"""
node_specs: dict[str, NodeSpec[Any, Any]] = {}
node_name_bindings: dict[str, str] = {}
for node_name in dict.fromkeys(plan_node_names):
concrete_name, spec = _resolve_node_spec(
node_name=node_name,
deployment=deployment,
sources=sources,
)
node_name_bindings[node_name] = concrete_name
node_specs[concrete_name] = spec
reducers = _resolve_reducers(
required_capabilities=artifact.required_capability_map(),
deployment=deployment,
sources=sources,
)
return RuntimeDependencies(
node_specs=node_specs,
node_name_bindings=node_name_bindings,
node_registry=build_async_registry(*node_specs.values()),
reducers=reducers,
)
def _resolve_node_spec(
*,
node_name: str,
deployment: WorkflowDeployment | None,
sources: dict[str, CapabilitySource],
) -> tuple[str, NodeSpec[Any, Any]]:
concrete = _find_node_spec(node_name, sources)
if concrete is not None:
return node_name, concrete
if deployment is not None:
for bound_name in _bound_node_names(node_name, deployment.binding_map()):
concrete = _find_node_spec(bound_name, sources)
if concrete is not None:
return bound_name, concrete
raise KeyError(f"unknown node spec {node_name!r}")
def _bound_node_names(
node_name: str,
bindings: dict[str, str],
) -> tuple[str, ...]:
"""Return concrete names made by replacing known logical source prefixes.
CapabilityRef parses by splitting on the last dot, which is correct for
simple tool names but wrong for local names that also contain dots
(`demo.foo.bar` with logical source `demo`). Runtime binding is source-aware:
deployment bindings define the allowed source prefixes, and everything after
the matched prefix remains the local capability name.
"""
candidates: list[str] = []
for logical_source, concrete_source in sorted(
bindings.items(),
key=lambda item: len(item[0]),
reverse=True,
):
prefix = f"{logical_source}."
if node_name.startswith(prefix):
candidates.append(f"{concrete_source}.{node_name[len(prefix) :]}")
return tuple(candidates)
def _find_node_spec(
node_name: str,
sources: dict[str, CapabilitySource],
) -> NodeSpec[Any, Any] | None:
for source in sources.values():
spec = source.capabilities.node_specs.get(node_name)
if spec is not None:
return spec
return None
def _resolve_reducers(
*,
required_capabilities: dict[str, RequiredCapability],
deployment: WorkflowDeployment | None,
sources: dict[str, CapabilitySource],
) -> dict[str, ReducerDefinition]:
reducers: dict[str, ReducerDefinition] = {}
if deployment is None:
return reducers
for logical_ref, required in required_capabilities.items():
if required.kind != "reducer":
continue
bound_source_id = deployment.binding_map().get(required.logical_source)
if bound_source_id is None:
continue
source = sources.get(bound_source_id)
if source is None:
continue
definition = _find_reducer_definition(
source=source,
capability_name=required.capability_name,
)
if definition is not None:
reducers[logical_ref] = definition
return reducers
def _find_reducer_definition(
*,
source: CapabilitySource,
capability_name: str,
) -> ReducerDefinition | None:
for reducer_name, definition in source.capabilities.reducer_definitions.items():
try:
reducer_ref = CapabilityRef.parse(reducer_name)
except ValueError:
continue
if reducer_ref.name == capability_name:
return definition
return None
+1 -1
View File
@@ -55,7 +55,7 @@ from ...shared.errors import error_payload
from ...shared.names import RESERVED_CONNECTION_IDS
from ...storage import Store
from ...workflow.wrappers import _model_from_schema
from ...workflow_surface.runtime_dependencies import resolve_runtime_dependencies
from wf_api.runtime_dependencies import resolve_runtime_dependencies
from ...workflow_surface.saved_subgraphs import (
SavedSubgraphTree,
prepare_saved_subgraphs,
@@ -1,153 +1,17 @@
"""Compatibility shim for workflow runtime dependency resolution.
New code should import from `wf_api.runtime_dependencies`. This module stays so
older MCP workflow-surface imports keep working during extraction.
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Any
from wf_artifacts import RequiredCapability, WorkflowArtifact, WorkflowDeployment
from wf_authoring import AsyncRegistryHandler, NodeSpec, build_async_registry
from wf_core.runtime.ops.merges import ReducerDefinition
from wf_platform import CapabilityRef, CapabilitySource
@dataclass(frozen=True, slots=True)
class RuntimeDependencies:
"""Executable dependencies resolved for one workflow run."""
node_specs: dict[str, NodeSpec[Any, Any]]
node_name_bindings: dict[str, str]
node_registry: dict[str, AsyncRegistryHandler]
reducers: dict[str, ReducerDefinition]
def resolve_runtime_dependencies(
*,
artifact: WorkflowArtifact,
deployment: WorkflowDeployment | None,
sources: dict[str, CapabilitySource],
plan_node_names: list[str],
) -> RuntimeDependencies:
"""Resolve source-owned specs and reducers into runtime callables.
Node names in stored plans are still concrete today, so node specs resolve
by exact plan names. Reducers are already logical artifact dependencies, so
they resolve through deployment bindings and are registered under the
logical reducer name used by the workflow state schema.
"""
node_specs: dict[str, NodeSpec[Any, Any]] = {}
node_name_bindings: dict[str, str] = {}
for node_name in dict.fromkeys(plan_node_names):
concrete_name, spec = _resolve_node_spec(
node_name=node_name,
deployment=deployment,
sources=sources,
)
node_name_bindings[node_name] = concrete_name
node_specs[concrete_name] = spec
reducers = _resolve_reducers(
required_capabilities=artifact.required_capability_map(),
deployment=deployment,
sources=sources,
)
return RuntimeDependencies(
node_specs=node_specs,
node_name_bindings=node_name_bindings,
node_registry=build_async_registry(*node_specs.values()),
reducers=reducers,
from wf_api.runtime_dependencies import ( # noqa: F401
RuntimeDependencies,
resolve_runtime_dependencies,
)
def _resolve_node_spec(
*,
node_name: str,
deployment: WorkflowDeployment | None,
sources: dict[str, CapabilitySource],
) -> tuple[str, NodeSpec[Any, Any]]:
concrete = _find_node_spec(node_name, sources)
if concrete is not None:
return node_name, concrete
if deployment is not None:
for bound_name in _bound_node_names(node_name, deployment.binding_map()):
concrete = _find_node_spec(bound_name, sources)
if concrete is not None:
return bound_name, concrete
raise KeyError(f"unknown node spec {node_name!r}")
def _bound_node_names(
node_name: str,
bindings: dict[str, str],
) -> tuple[str, ...]:
"""Return concrete names made by replacing known logical source prefixes.
CapabilityRef parses by splitting on the last dot, which is correct for
simple tool names but wrong for local names that also contain dots
(`demo.foo.bar` with logical source `demo`). Runtime binding is source-aware:
deployment bindings define the allowed source prefixes, and everything after
the matched prefix remains the local capability name.
"""
candidates: list[str] = []
for logical_source, concrete_source in sorted(
bindings.items(),
key=lambda item: len(item[0]),
reverse=True,
):
prefix = f"{logical_source}."
if node_name.startswith(prefix):
candidates.append(f"{concrete_source}.{node_name[len(prefix) :]}")
return tuple(candidates)
def _find_node_spec(
node_name: str,
sources: dict[str, CapabilitySource],
) -> NodeSpec[Any, Any] | None:
for source in sources.values():
spec = source.capabilities.node_specs.get(node_name)
if spec is not None:
return spec
return None
def _resolve_reducers(
*,
required_capabilities: dict[str, RequiredCapability],
deployment: WorkflowDeployment | None,
sources: dict[str, CapabilitySource],
) -> dict[str, ReducerDefinition]:
reducers: dict[str, ReducerDefinition] = {}
if deployment is None:
return reducers
for logical_ref, required in required_capabilities.items():
if required.kind != "reducer":
continue
bound_source_id = deployment.binding_map().get(required.logical_source)
if bound_source_id is None:
continue
source = sources.get(bound_source_id)
if source is None:
continue
definition = _find_reducer_definition(
source=source,
capability_name=required.capability_name,
)
if definition is not None:
reducers[logical_ref] = definition
return reducers
def _find_reducer_definition(
*,
source: CapabilitySource,
capability_name: str,
) -> ReducerDefinition | None:
for reducer_name, definition in source.capabilities.reducer_definitions.items():
try:
reducer_ref = CapabilityRef.parse(reducer_name)
except ValueError:
continue
if reducer_ref.name == capability_name:
return definition
return None
__all__ = [
"RuntimeDependencies",
"resolve_runtime_dependencies",
]
@@ -27,7 +27,7 @@ from wf_core.models.workflow_refs import WorkflowRef
from wf_platform import CapabilitySource
from ..models import RawWorkflowPlan
from .runtime_dependencies import resolve_runtime_dependencies
from wf_api.runtime_dependencies import resolve_runtime_dependencies
_STEPS_ADAPTER = TypeAdapter(list[Step])
@@ -0,0 +1,37 @@
"""Tests for runtime_dependencies extraction: canonical location in wf_api, shim in wf_mcp."""
from __future__ import annotations
def test_canonical_import_from_wf_api() -> None:
from wf_api.runtime_dependencies import (
RuntimeDependencies,
resolve_runtime_dependencies,
)
assert RuntimeDependencies.__name__ == "RuntimeDependencies"
assert callable(resolve_runtime_dependencies)
def test_compat_shim_import_from_wf_mcp() -> None:
from wf_mcp.workflow_surface.runtime_dependencies import (
RuntimeDependencies,
resolve_runtime_dependencies,
)
assert RuntimeDependencies.__name__ == "RuntimeDependencies"
assert callable(resolve_runtime_dependencies)
def test_shim_symbols_are_identical_to_canonical() -> None:
from wf_api.runtime_dependencies import (
RuntimeDependencies as CanonicalRuntimeDependencies,
resolve_runtime_dependencies as canonical_resolve,
)
from wf_mcp.workflow_surface.runtime_dependencies import (
RuntimeDependencies as ShimRuntimeDependencies,
resolve_runtime_dependencies as shim_resolve,
)
assert CanonicalRuntimeDependencies is ShimRuntimeDependencies
assert canonical_resolve is shim_resolve