deployment binds accounts to nodes

This commit is contained in:
lda
2026-05-18 01:11:22 +07:00 Verified
parent 78613b876c
commit 8fcc66c90c
4 changed files with 114 additions and 15 deletions
+13 -4
View File
@@ -510,10 +510,19 @@ saved workflow state schema, such as `custom.multiply`. This keeps artifact
plans stable while allowing deployments to choose concrete accounts or local plans stable while allowing deployments to choose concrete accounts or local
reducer packages. reducer packages.
Node specs are less abstract today: saved raw workflow plans still contain Node specs may also use deployment-bound logical names. A saved plan can refer
concrete node spec names such as `demo.personal.echo_tool`. Rebinding node specs to `demo.echo_tool`, while the deployment binds `demo` to a concrete source such
through deployment aliases is a later migration. Reducers are the first runtime as `demo.personal`. At runtime the compiler builds node definitions from the
dependency family to use deployment-bound logical names end-to-end. concrete source but leaves the saved artifact immutable. Concrete node names
such as `demo.personal.echo_tool` remain supported for raw local plans and older
artifacts.
Implementation note: these references are currently parsed from strings with
dot-separated source and capability names. That keeps the wire format simple but
leaks path logic into runtime code. A future cleanup should introduce typed
reference objects, such as `CapabilityRef(logical_source, capability_name)` and
`BoundCapabilityRef(concrete_source, capability_name)`, and leave dot-joined
strings as presentation and serialization only.
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.
+17 -5
View File
@@ -522,15 +522,27 @@ class WfMcpService:
) )
raise raise
def compile_plan(self, plan: RawWorkflowPlan) -> Workflow: def compile_plan(
self,
plan: RawWorkflowPlan,
node_name_bindings: dict[str, str] | None = None,
) -> Workflow:
node_defs: dict[str, Any] = {} node_defs: dict[str, Any] = {}
bindings = node_name_bindings or {}
for step in plan.nodes: for step in plan.nodes:
if not isinstance(step, NodeUse): if not isinstance(step, NodeUse):
continue continue
qualified_name = step.node qualified_name = bindings.get(step.node, step.node)
spec = self._get_qualified_spec(qualified_name) spec = self._get_qualified_spec(qualified_name)
node_defs[qualified_name] = spec.to_node_def() node_defs[qualified_name] = spec.to_node_def()
nodes = []
for node in plan.nodes:
payload = node.model_dump(by_alias=True)
if isinstance(node, NodeUse):
payload["node"] = bindings.get(node.node, node.node)
nodes.append(payload)
payload = { payload = {
"name": plan.name, "name": plan.name,
"input_schema": plan.input_schema, "input_schema": plan.input_schema,
@@ -538,7 +550,7 @@ class WfMcpService:
"output_schema": plan.output_schema, "output_schema": plan.output_schema,
"start": plan.start, "start": plan.start,
"node_defs": [node.model_dump() for node in node_defs.values()], "node_defs": [node.model_dump() for node in node_defs.values()],
"nodes": [node.model_dump(by_alias=True) for node in plan.nodes], "nodes": nodes,
"edges": [edge.model_dump(by_alias=True) for edge in plan.edges], "edges": [edge.model_dump(by_alias=True) for edge in plan.edges],
} }
return Workflow.model_validate(payload) return Workflow.model_validate(payload)
@@ -557,9 +569,8 @@ class WfMcpService:
payload={"input_keys": sorted(workflow_input.keys())}, payload={"input_keys": sorted(workflow_input.keys())},
) )
) )
workflow = self.compile_plan(plan)
plan_node_names = [ plan_node_names = [
node.node for node in workflow.nodes if isinstance(node, NodeUse) node.node for node in plan.nodes if isinstance(node, NodeUse)
] ]
runtime_artifact = artifact or WorkflowArtifact( runtime_artifact = artifact or WorkflowArtifact(
id=plan.name, id=plan.name,
@@ -576,6 +587,7 @@ class WfMcpService:
sources=self.capability_sources, sources=self.capability_sources,
plan_node_names=plan_node_names, plan_node_names=plan_node_names,
) )
workflow = self.compile_plan(plan, dependencies.node_name_bindings)
run = await execute_workflow_async( run = await execute_workflow_async(
workflow, workflow,
workflow_input, workflow_input,
@@ -15,6 +15,7 @@ class RuntimeDependencies:
"""Executable dependencies resolved for one workflow run.""" """Executable dependencies resolved for one workflow run."""
node_specs: dict[str, NodeSpec[Any, Any]] node_specs: dict[str, NodeSpec[Any, Any]]
node_name_bindings: dict[str, str]
node_registry: dict[str, AsyncRegistryHandler] node_registry: dict[str, AsyncRegistryHandler]
reducers: dict[str, ReducerDefinition] reducers: dict[str, ReducerDefinition]
@@ -33,10 +34,16 @@ def resolve_runtime_dependencies(
they resolve through deployment bindings and are registered under the they resolve through deployment bindings and are registered under the
logical reducer name used by the workflow state schema. logical reducer name used by the workflow state schema.
""" """
node_specs = { node_specs: dict[str, NodeSpec[Any, Any]] = {}
node_name: _find_node_spec(node_name, sources) node_name_bindings: dict[str, str] = {}
for node_name in dict.fromkeys(plan_node_names) 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( reducers = _resolve_reducers(
required_capabilities=artifact.required_capabilities, required_capabilities=artifact.required_capabilities,
deployment=deployment, deployment=deployment,
@@ -44,20 +51,44 @@ def resolve_runtime_dependencies(
) )
return RuntimeDependencies( return RuntimeDependencies(
node_specs=node_specs, node_specs=node_specs,
node_name_bindings=node_name_bindings,
node_registry=build_async_registry(*node_specs.values()), node_registry=build_async_registry(*node_specs.values()),
reducers=reducers, 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:
logical_source, separator, capability_name = node_name.rpartition(".")
if separator:
bound_source_id = deployment.bindings.get(logical_source)
if bound_source_id is not None:
bound_name = f"{bound_source_id}.{capability_name}"
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 _find_node_spec( def _find_node_spec(
node_name: str, node_name: str,
sources: dict[str, CapabilitySource], sources: dict[str, CapabilitySource],
) -> NodeSpec[Any, Any]: ) -> NodeSpec[Any, Any] | None:
for source in sources.values(): for source in sources.values():
spec = source.capabilities.node_specs.get(node_name) spec = source.capabilities.node_specs.get(node_name)
if spec is not None: if spec is not None:
return spec return spec
raise KeyError(f"unknown node spec {node_name!r}") return None
def _resolve_reducers( def _resolve_reducers(
+47
View File
@@ -174,6 +174,39 @@ def test_workflow_surface_runs_non_interrupting_deployment() -> None:
assert payload["diagnostics"] == [] assert payload["diagnostics"] == []
def test_workflow_surface_runs_deployment_with_bound_node_spec_dependency() -> None:
artifact_store = FileWorkflowArtifactStore(local_temp_root() / "surface_bound_node")
artifact_store.save_artifact(_logical_echo_artifact())
artifact_store.save_deployment(
WorkflowDeployment(
id="echo.personal",
artifact_id="logical_echo",
artifact_version=1,
bindings={"demo": "demo.personal"},
)
)
service = WfMcpService(
store=FileStore(local_temp_root() / "surface_bound_node_mcp"),
artifact_store=artifact_store,
)
service.register_connection(
ConnectionConfig(id="demo.personal", server="demo", account="personal")
)
service.register_specs("demo.personal", echo_tool)
handlers = WorkflowSurfaceHandlers(service)
payload = asyncio.run(
handlers.run_deployment(
deployment_id="echo.personal",
workflow_input={"text": "hello"},
)
)
assert payload["status"] == "completed"
assert payload["output"]["echoed"] == "hello"
assert payload["diagnostics"] == []
def test_workflow_surface_runs_deployment_with_bound_reducer_dependency() -> None: def test_workflow_surface_runs_deployment_with_bound_reducer_dependency() -> None:
artifact_store = FileWorkflowArtifactStore(local_temp_root() / "surface_reducer") artifact_store = FileWorkflowArtifactStore(local_temp_root() / "surface_reducer")
artifact_store.save_artifact(_custom_reducer_artifact()) artifact_store.save_artifact(_custom_reducer_artifact())
@@ -328,6 +361,20 @@ def _echo_artifact() -> WorkflowArtifact:
) )
def _logical_echo_artifact() -> WorkflowArtifact:
artifact = _echo_artifact()
plan = dict(artifact.plan)
nodes = [dict(node) for node in plan["nodes"]]
nodes[0]["node"] = "demo.echo_tool"
plan["nodes"] = nodes
return artifact.model_copy(
update={
"id": "logical_echo",
"plan": plan,
}
)
def _custom_reducer_artifact() -> WorkflowArtifact: def _custom_reducer_artifact() -> WorkflowArtifact:
plan: dict[str, Any] = { plan: dict[str, Any] = {
"name": "multiply", "name": "multiply",