resolve runtime dependencies
This commit is contained in:
@@ -502,6 +502,19 @@ Saved workflow execution eventually needs first-class runtime support for:
|
|||||||
direct artifact dependencies just like node specs or tools; reducer config is
|
direct artifact dependencies just like node specs or tools; reducer config is
|
||||||
part of the state field contract, while the dependency key is the reducer name
|
part of the state field contract, while the dependency key is the reducer name
|
||||||
|
|
||||||
|
Runtime execution must resolve reducer dependencies, not just validate that they
|
||||||
|
exist. A deployment binding maps the artifact's logical reducer source, such as
|
||||||
|
`custom`, to a concrete source, such as `custom.default`. The runtime registers
|
||||||
|
the concrete `ReducerDefinition` under the logical reducer name used in the
|
||||||
|
saved workflow state schema, such as `custom.multiply`. This keeps artifact
|
||||||
|
plans stable while allowing deployments to choose concrete accounts or local
|
||||||
|
reducer packages.
|
||||||
|
|
||||||
|
Node specs are less abstract today: saved raw workflow plans still contain
|
||||||
|
concrete node spec names such as `demo.personal.echo_tool`. Rebinding node specs
|
||||||
|
through deployment aliases is a later migration. Reducers are the first runtime
|
||||||
|
dependency family to use deployment-bound logical names end-to-end.
|
||||||
|
|
||||||
The first implementation should prefer artifact validation and dependency
|
The first implementation should prefer artifact validation and dependency
|
||||||
diagnostics before attempting persistent nested resume.
|
diagnostics before attempting persistent nested resume.
|
||||||
|
|
||||||
|
|||||||
@@ -121,6 +121,11 @@ def builtin_reducers() -> dict[str, ReducerSpec]:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def builtin_reducer_definitions():
|
||||||
|
"""Return executable built-in reducers for trusted runtime dependency wiring."""
|
||||||
|
return dict(DEFAULT_REDUCER_DEFINITIONS)
|
||||||
|
|
||||||
|
|
||||||
def mcp_specs(service: ToolCaller) -> dict[str, NodeSpec[Any, Any]]:
|
def mcp_specs(service: ToolCaller) -> dict[str, NodeSpec[Any, Any]]:
|
||||||
"""Return service-bound MCP utility specs available to raw plans."""
|
"""Return service-bound MCP utility specs available to raw plans."""
|
||||||
|
|
||||||
@@ -153,6 +158,7 @@ def builtin_sources(service: ToolCaller) -> dict[str, CapabilitySource]:
|
|||||||
capabilities=CapabilityBuckets(
|
capabilities=CapabilityBuckets(
|
||||||
node_specs=builtin_specs(),
|
node_specs=builtin_specs(),
|
||||||
reducers=builtin_reducers(),
|
reducers=builtin_reducers(),
|
||||||
|
reducer_definitions=builtin_reducer_definitions(),
|
||||||
),
|
),
|
||||||
visibility=SourceVisibility(
|
visibility=SourceVisibility(
|
||||||
planner=True,
|
planner=True,
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ from typing import Any, Literal
|
|||||||
|
|
||||||
from wf_authoring import NodeSpec
|
from wf_authoring import NodeSpec
|
||||||
from wf_core import ReducerSpec
|
from wf_core import ReducerSpec
|
||||||
|
from wf_core.runtime.ops.merges import ReducerDefinition
|
||||||
|
|
||||||
SourceKind = Literal["system", "connection"]
|
SourceKind = Literal["system", "connection"]
|
||||||
|
|
||||||
@@ -29,6 +30,7 @@ class CapabilityBuckets:
|
|||||||
tools: dict[str, Any] = field(default_factory=dict)
|
tools: dict[str, Any] = field(default_factory=dict)
|
||||||
node_specs: dict[str, NodeSpec[Any, Any]] = field(default_factory=dict)
|
node_specs: dict[str, NodeSpec[Any, Any]] = field(default_factory=dict)
|
||||||
reducers: dict[str, ReducerSpec] = field(default_factory=dict)
|
reducers: dict[str, ReducerSpec] = field(default_factory=dict)
|
||||||
|
reducer_definitions: dict[str, ReducerDefinition] = field(default_factory=dict)
|
||||||
prompts: dict[str, Any] = field(default_factory=dict)
|
prompts: dict[str, Any] = field(default_factory=dict)
|
||||||
resources: dict[str, Any] = field(default_factory=dict)
|
resources: dict[str, Any] = field(default_factory=dict)
|
||||||
|
|
||||||
|
|||||||
@@ -7,11 +7,12 @@ from typing import Any
|
|||||||
|
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
from wf_authoring import NodeReturn, NodeSpec, build_async_registry
|
from wf_authoring import NodeReturn, NodeSpec
|
||||||
from wf_artifacts import (
|
from wf_artifacts import (
|
||||||
FileWorkflowArtifactStore,
|
FileWorkflowArtifactStore,
|
||||||
WorkflowArtifact,
|
WorkflowArtifact,
|
||||||
WorkflowArtifactCatalogEntry,
|
WorkflowArtifactCatalogEntry,
|
||||||
|
WorkflowDeployment,
|
||||||
WorkflowArtifactStore,
|
WorkflowArtifactStore,
|
||||||
artifact_catalog_entry,
|
artifact_catalog_entry,
|
||||||
)
|
)
|
||||||
@@ -33,6 +34,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.runtime_dependencies import resolve_runtime_dependencies
|
||||||
from ..catalog import CombinedCatalog, snapshot_from_specs
|
from ..catalog import CombinedCatalog, snapshot_from_specs
|
||||||
from ..discovery import discover_connection_capabilities, specs_from_discovered_tools
|
from ..discovery import discover_connection_capabilities, specs_from_discovered_tools
|
||||||
from ..admin_capabilities import admin_source
|
from ..admin_capabilities import admin_source
|
||||||
@@ -545,6 +547,8 @@ class WfMcpService:
|
|||||||
self,
|
self,
|
||||||
plan: RawWorkflowPlan,
|
plan: RawWorkflowPlan,
|
||||||
workflow_input: dict[str, Any],
|
workflow_input: dict[str, Any],
|
||||||
|
deployment: WorkflowDeployment | None = None,
|
||||||
|
artifact: WorkflowArtifact | None = None,
|
||||||
):
|
):
|
||||||
self._record_event(
|
self._record_event(
|
||||||
make_event(
|
make_event(
|
||||||
@@ -554,13 +558,30 @@ class WfMcpService:
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
workflow = self.compile_plan(plan)
|
workflow = self.compile_plan(plan)
|
||||||
specs = [
|
plan_node_names = [
|
||||||
self._get_qualified_spec(node.node)
|
node.node for node in workflow.nodes if isinstance(node, NodeUse)
|
||||||
for node in workflow.nodes
|
|
||||||
if isinstance(node, NodeUse)
|
|
||||||
]
|
]
|
||||||
registry = build_async_registry(*specs)
|
runtime_artifact = artifact or WorkflowArtifact(
|
||||||
run = await execute_workflow_async(workflow, workflow_input, registry)
|
id=plan.name,
|
||||||
|
version=1,
|
||||||
|
title=plan.name,
|
||||||
|
input_schema=plan.input_schema,
|
||||||
|
output_schema=plan.output_schema,
|
||||||
|
outcomes=("completed",),
|
||||||
|
plan=plan.model_dump(mode="json", by_alias=True),
|
||||||
|
)
|
||||||
|
dependencies = resolve_runtime_dependencies(
|
||||||
|
artifact=runtime_artifact,
|
||||||
|
deployment=deployment,
|
||||||
|
sources=self.capability_sources,
|
||||||
|
plan_node_names=plan_node_names,
|
||||||
|
)
|
||||||
|
run = await execute_workflow_async(
|
||||||
|
workflow,
|
||||||
|
workflow_input,
|
||||||
|
dependencies.node_registry,
|
||||||
|
reducers=dependencies.reducers,
|
||||||
|
)
|
||||||
self._record_event(
|
self._record_event(
|
||||||
make_event(
|
make_event(
|
||||||
"workflow_run_completed",
|
"workflow_run_completed",
|
||||||
|
|||||||
@@ -251,7 +251,12 @@ class WorkflowSurfaceHandlers:
|
|||||||
)
|
)
|
||||||
|
|
||||||
plan = _raw_plan_from_artifact(artifact)
|
plan = _raw_plan_from_artifact(artifact)
|
||||||
run = await self.service.run_workflow_from_plan(plan, workflow_input)
|
run = await self.service.run_workflow_from_plan(
|
||||||
|
plan,
|
||||||
|
workflow_input,
|
||||||
|
deployment=deployment,
|
||||||
|
artifact=artifact,
|
||||||
|
)
|
||||||
return _run_payload(
|
return _run_payload(
|
||||||
deployment=deployment,
|
deployment=deployment,
|
||||||
artifact=artifact,
|
artifact=artifact,
|
||||||
|
|||||||
@@ -0,0 +1,99 @@
|
|||||||
|
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_mcp.broker.service.capability_sources import CapabilitySource
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class RuntimeDependencies:
|
||||||
|
"""Executable dependencies resolved for one workflow run."""
|
||||||
|
|
||||||
|
node_specs: dict[str, NodeSpec[Any, Any]]
|
||||||
|
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 = {
|
||||||
|
node_name: _find_node_spec(node_name, sources)
|
||||||
|
for node_name in dict.fromkeys(plan_node_names)
|
||||||
|
}
|
||||||
|
reducers = _resolve_reducers(
|
||||||
|
required_capabilities=artifact.required_capabilities,
|
||||||
|
deployment=deployment,
|
||||||
|
sources=sources,
|
||||||
|
)
|
||||||
|
return RuntimeDependencies(
|
||||||
|
node_specs=node_specs,
|
||||||
|
node_registry=build_async_registry(*node_specs.values()),
|
||||||
|
reducers=reducers,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _find_node_spec(
|
||||||
|
node_name: str,
|
||||||
|
sources: dict[str, CapabilitySource],
|
||||||
|
) -> NodeSpec[Any, Any]:
|
||||||
|
for source in sources.values():
|
||||||
|
spec = source.capabilities.node_specs.get(node_name)
|
||||||
|
if spec is not None:
|
||||||
|
return spec
|
||||||
|
raise KeyError(f"unknown node spec {node_name!r}")
|
||||||
|
|
||||||
|
|
||||||
|
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.bindings.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():
|
||||||
|
if reducer_name.rsplit(".", maxsplit=1)[-1] == capability_name:
|
||||||
|
return definition
|
||||||
|
return None
|
||||||
@@ -3,13 +3,22 @@ from __future__ import annotations
|
|||||||
import asyncio
|
import asyncio
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
from wf_artifacts import (
|
from wf_artifacts import (
|
||||||
FileWorkflowArtifactStore,
|
FileWorkflowArtifactStore,
|
||||||
RequiredCapability,
|
RequiredCapability,
|
||||||
WorkflowArtifact,
|
WorkflowArtifact,
|
||||||
WorkflowDeployment,
|
WorkflowDeployment,
|
||||||
)
|
)
|
||||||
|
from wf_authoring import node, reducer
|
||||||
from wf_mcp.broker import WfMcpService
|
from wf_mcp.broker import WfMcpService
|
||||||
|
from wf_mcp.broker.service.capability_sources import (
|
||||||
|
CapabilityBuckets,
|
||||||
|
CapabilitySource,
|
||||||
|
SourcePermissions,
|
||||||
|
SourceVisibility,
|
||||||
|
)
|
||||||
from wf_mcp.models import ConnectionConfig
|
from wf_mcp.models import ConnectionConfig
|
||||||
from wf_mcp.models import RawWorkflowPlan
|
from wf_mcp.models import RawWorkflowPlan
|
||||||
from wf_mcp.storage import FileStore
|
from wf_mcp.storage import FileStore
|
||||||
@@ -18,6 +27,24 @@ from wf_mcp.workflow_surface import WorkflowSurfaceHandlers
|
|||||||
from .test_support import echo_tool, local_temp_root
|
from .test_support import echo_tool, local_temp_root
|
||||||
|
|
||||||
|
|
||||||
|
class AmountInput(BaseModel):
|
||||||
|
amount: int
|
||||||
|
|
||||||
|
|
||||||
|
class AmountOutput(BaseModel):
|
||||||
|
amount: int
|
||||||
|
|
||||||
|
|
||||||
|
@node()
|
||||||
|
async def amount_tool(payload: AmountInput) -> AmountOutput:
|
||||||
|
return AmountOutput(amount=payload.amount)
|
||||||
|
|
||||||
|
|
||||||
|
@reducer(name="custom.default.multiply")
|
||||||
|
def multiply(current: int | None, incoming: int) -> int:
|
||||||
|
return (current or 1) * incoming
|
||||||
|
|
||||||
|
|
||||||
def test_workflow_surface_lists_artifact_catalog_entries() -> None:
|
def test_workflow_surface_lists_artifact_catalog_entries() -> None:
|
||||||
artifact_store = FileWorkflowArtifactStore(local_temp_root() / "surface_artifacts")
|
artifact_store = FileWorkflowArtifactStore(local_temp_root() / "surface_artifacts")
|
||||||
artifact_store.save_artifact(_artifact())
|
artifact_store.save_artifact(_artifact())
|
||||||
@@ -147,6 +174,56 @@ def test_workflow_surface_runs_non_interrupting_deployment() -> None:
|
|||||||
assert payload["diagnostics"] == []
|
assert payload["diagnostics"] == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_workflow_surface_runs_deployment_with_bound_reducer_dependency() -> None:
|
||||||
|
artifact_store = FileWorkflowArtifactStore(local_temp_root() / "surface_reducer")
|
||||||
|
artifact_store.save_artifact(_custom_reducer_artifact())
|
||||||
|
artifact_store.save_deployment(
|
||||||
|
WorkflowDeployment(
|
||||||
|
id="multiply.personal",
|
||||||
|
artifact_id="multiply",
|
||||||
|
artifact_version=1,
|
||||||
|
bindings={
|
||||||
|
"demo": "demo.personal",
|
||||||
|
"custom": "custom.default",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
service = WfMcpService(
|
||||||
|
store=FileStore(local_temp_root() / "surface_reducer_mcp"),
|
||||||
|
artifact_store=artifact_store,
|
||||||
|
)
|
||||||
|
service.register_connection(
|
||||||
|
ConnectionConfig(id="demo.personal", server="demo", account="personal")
|
||||||
|
)
|
||||||
|
service.register_specs("demo.personal", amount_tool)
|
||||||
|
service.register_capability_source(
|
||||||
|
CapabilitySource(
|
||||||
|
id="custom.default",
|
||||||
|
kind="system",
|
||||||
|
capabilities=CapabilityBuckets(
|
||||||
|
reducers={multiply.definition.spec.name: multiply.definition.spec},
|
||||||
|
reducer_definitions={
|
||||||
|
multiply.definition.spec.name: multiply.definition,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
visibility=SourceVisibility(planner=True),
|
||||||
|
permissions=SourcePermissions(safe_for_workflow=True),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
handlers = WorkflowSurfaceHandlers(service)
|
||||||
|
|
||||||
|
payload = asyncio.run(
|
||||||
|
handlers.run_deployment(
|
||||||
|
deployment_id="multiply.personal",
|
||||||
|
workflow_input={"total": 2, "amount": 3},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
assert payload["status"] == "completed"
|
||||||
|
assert payload["output"]["total"] == 6
|
||||||
|
assert payload["diagnostics"] == []
|
||||||
|
|
||||||
|
|
||||||
def test_workflow_surface_calls_saved_wrapper_artifact() -> None:
|
def test_workflow_surface_calls_saved_wrapper_artifact() -> None:
|
||||||
artifact_store = FileWorkflowArtifactStore(
|
artifact_store = FileWorkflowArtifactStore(
|
||||||
local_temp_root() / "surface_wrapper_call"
|
local_temp_root() / "surface_wrapper_call"
|
||||||
@@ -249,3 +326,62 @@ def _echo_artifact() -> WorkflowArtifact:
|
|||||||
)
|
)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _custom_reducer_artifact() -> WorkflowArtifact:
|
||||||
|
plan: dict[str, Any] = {
|
||||||
|
"name": "multiply",
|
||||||
|
"input_schema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"total": {"type": "integer"},
|
||||||
|
"amount": {"type": "integer"},
|
||||||
|
},
|
||||||
|
"required": ["total", "amount"],
|
||||||
|
},
|
||||||
|
"state_schema": {
|
||||||
|
"fields": {
|
||||||
|
"total": {
|
||||||
|
"type": "integer",
|
||||||
|
"reducer": "custom.multiply",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"output_schema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {"total": {"type": "integer"}},
|
||||||
|
"required": ["total"],
|
||||||
|
},
|
||||||
|
"start": "amount",
|
||||||
|
"nodes": [
|
||||||
|
{
|
||||||
|
"id": "amount",
|
||||||
|
"type": "node",
|
||||||
|
"node": "demo.personal.amount_tool",
|
||||||
|
"in_map": {"input.amount": "amount"},
|
||||||
|
"out_map": {"amount": "state.total"},
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"edges": [{"from": "amount", "outcome": "ok", "to": "__end__"}],
|
||||||
|
}
|
||||||
|
return WorkflowArtifact(
|
||||||
|
id="multiply",
|
||||||
|
version=1,
|
||||||
|
title="Multiply",
|
||||||
|
input_schema=plan["input_schema"],
|
||||||
|
output_schema=plan["output_schema"],
|
||||||
|
outcomes=("completed",),
|
||||||
|
plan=plan,
|
||||||
|
required_capabilities={
|
||||||
|
"demo.amount_tool": RequiredCapability(
|
||||||
|
logical_source="demo",
|
||||||
|
capability_name="amount_tool",
|
||||||
|
kind="node_spec",
|
||||||
|
),
|
||||||
|
"custom.multiply": RequiredCapability(
|
||||||
|
logical_source="custom",
|
||||||
|
capability_name="multiply",
|
||||||
|
kind="reducer",
|
||||||
|
),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user