capabilityref: less string matching more model

This commit is contained in:
lda
2026-05-18 02:32:02 +07:00 Verified
parent cb8d43a158
commit 90e2141be3
8 changed files with 137 additions and 27 deletions
+5 -6
View File
@@ -571,12 +571,11 @@ 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 such as `demo.personal.echo_tool` remain supported for raw local plans and older
artifacts. artifacts.
Implementation note: these references are currently parsed from strings with Implementation note: source and capability names now have segment-backed
dot-separated source and capability names. That keeps the wire format simple but platform refs: `SourceRef(parts=...)` and `CapabilityRef(source=..., name=...)`.
leaks path logic into runtime code. A future cleanup should introduce typed Dot-joined names remain the wire/presentation format, but new runtime code
reference objects, such as `CapabilityRef(logical_source, capability_name)` and should parse or format through those refs instead of rediscovering source/name
`BoundCapabilityRef(concrete_source, capability_name)`, and leave dot-joined boundaries with ad hoc string splits.
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.
+6 -6
View File
@@ -3,6 +3,7 @@ from __future__ import annotations
from collections.abc import Mapping from collections.abc import Mapping
from wf_core import ReducerRef, Workflow from wf_core import ReducerRef, Workflow
from wf_platform import CapabilityRef
from .models import ArtifactKind, JsonObject, RequiredCapability, WorkflowArtifact from .models import ArtifactKind, JsonObject, RequiredCapability, WorkflowArtifact
from .references import normalize_plan_node_refs from .references import normalize_plan_node_refs
@@ -101,14 +102,13 @@ def _required_reducers_from_plan(plan: JsonObject) -> dict[str, RequiredCapabili
reducer = ReducerRef.model_validate(reducer_payload) reducer = ReducerRef.model_validate(reducer_payload)
except ValueError: except ValueError:
continue continue
if "." not in reducer.name: try:
reducer_ref = CapabilityRef.parse(reducer.name)
except ValueError:
continue continue
if "." not in reducer.name:
continue
logical_source, _, capability_name = reducer.name.rpartition(".")
requirements[reducer.name] = RequiredCapability( requirements[reducer.name] = RequiredCapability(
logical_source=logical_source, logical_source=str(reducer_ref.source),
capability_name=capability_name, capability_name=reducer_ref.name,
kind="reducer", kind="reducer",
) )
return requirements return requirements
+5 -1
View File
@@ -3,6 +3,8 @@ from __future__ import annotations
from collections.abc import Mapping from collections.abc import Mapping
from copy import deepcopy from copy import deepcopy
from wf_platform import CapabilityRef, SourceRef
from .models import JsonObject, RequiredCapability from .models import JsonObject, RequiredCapability
@@ -68,6 +70,8 @@ def logical_ref_for_concrete_ref(
capability_name = concrete_ref[len(prefix) :] capability_name = concrete_ref[len(prefix) :]
if not capability_name: if not capability_name:
continue continue
logical_ref = f"{logical_source}.{capability_name}" logical_ref = str(
CapabilityRef(source=SourceRef.parse(logical_source), name=capability_name)
)
return logical_ref, logical_source, capability_name, concrete_source return logical_ref, logical_source, capability_name, concrete_source
return None return None
+15 -4
View File
@@ -15,6 +15,7 @@ from wf_artifacts import (
create_workflow_artifact_from_plan as build_workflow_artifact_from_plan, create_workflow_artifact_from_plan as build_workflow_artifact_from_plan,
validate_deployment_dependencies, validate_deployment_dependencies,
) )
from wf_platform import CapabilityRef
from wf_authoring import build_async_registry from wf_authoring import build_async_registry
from wf_core import RuntimeContext from wf_core import RuntimeContext
@@ -316,21 +317,23 @@ def _available_sources(service: WfMcpService) -> list[AvailableSource]:
sources: list[AvailableSource] = [] sources: list[AvailableSource] = []
for source in service.capability_sources.values(): for source in service.capability_sources.values():
capabilities = { capabilities = {
spec.name.rsplit(".", maxsplit=1)[-1]: AvailableCapability( capability_name: AvailableCapability(
name=spec.name.rsplit(".", maxsplit=1)[-1], name=capability_name,
kind="node_spec", kind="node_spec",
input_schema_hash=None, input_schema_hash=None,
output_schema_hash=None, output_schema_hash=None,
) )
for spec in source.capabilities.node_specs.values() for spec in source.capabilities.node_specs.values()
if (capability_name := _capability_name(spec.name)) is not None
} }
capabilities.update( capabilities.update(
{ {
reducer.name.rsplit(".", maxsplit=1)[-1]: AvailableCapability( capability_name: AvailableCapability(
name=reducer.name.rsplit(".", maxsplit=1)[-1], name=capability_name,
kind="reducer", kind="reducer",
) )
for reducer in source.capabilities.reducers.values() for reducer in source.capabilities.reducers.values()
if (capability_name := _capability_name(reducer.name)) is not None
} }
) )
sources.append( sources.append(
@@ -343,6 +346,14 @@ def _available_sources(service: WfMcpService) -> list[AvailableSource]:
return sources return sources
def _capability_name(qualified_name: str) -> str | None:
"""Return the local name of one qualified capability ref if it is valid."""
try:
return CapabilityRef.parse(qualified_name).name
except ValueError:
return None
def _artifact_capability_id(artifact: WorkflowArtifact) -> str: def _artifact_capability_id(artifact: WorkflowArtifact) -> str:
"""Use the same stable name shape as workflow artifact catalog entries.""" """Use the same stable name shape as workflow artifact catalog entries."""
return f"workflow.{artifact.id}.v{artifact.version}" return f"workflow.{artifact.id}.v{artifact.version}"
@@ -6,7 +6,7 @@ from typing import Any
from wf_artifacts import RequiredCapability, WorkflowArtifact, WorkflowDeployment from wf_artifacts import RequiredCapability, WorkflowArtifact, WorkflowDeployment
from wf_authoring import AsyncRegistryHandler, NodeSpec, build_async_registry from wf_authoring import AsyncRegistryHandler, NodeSpec, build_async_registry
from wf_core.runtime.ops.merges import ReducerDefinition from wf_core.runtime.ops.merges import ReducerDefinition
from wf_platform import CapabilitySource from wf_platform import CapabilityRef, CapabilitySource
@dataclass(frozen=True, slots=True) @dataclass(frozen=True, slots=True)
@@ -67,11 +67,12 @@ def _resolve_node_spec(
return node_name, concrete return node_name, concrete
if deployment is not None: if deployment is not None:
logical_source, separator, capability_name = node_name.rpartition(".") try:
if separator: bound_ref = CapabilityRef.parse(node_name).bind(deployment.bindings)
bound_source_id = deployment.bindings.get(logical_source) except ValueError:
if bound_source_id is not None: bound_ref = None
bound_name = f"{bound_source_id}.{capability_name}" if bound_ref is not None:
bound_name = str(bound_ref)
concrete = _find_node_spec(bound_name, sources) concrete = _find_node_spec(bound_name, sources)
if concrete is not None: if concrete is not None:
return bound_name, concrete return bound_name, concrete
@@ -124,6 +125,10 @@ def _find_reducer_definition(
capability_name: str, capability_name: str,
) -> ReducerDefinition | None: ) -> ReducerDefinition | None:
for reducer_name, definition in source.capabilities.reducer_definitions.items(): for reducer_name, definition in source.capabilities.reducer_definitions.items():
if reducer_name.rsplit(".", maxsplit=1)[-1] == capability_name: try:
reducer_ref = CapabilityRef.parse(reducer_name)
except ValueError:
continue
if reducer_ref.name == capability_name:
return definition return definition
return None return None
+3
View File
@@ -1,3 +1,4 @@
from .refs import CapabilityRef, SourceRef
from .sources import ( from .sources import (
CapabilityBuckets, CapabilityBuckets,
CapabilitySource, CapabilitySource,
@@ -14,6 +15,7 @@ from .sources import (
__all__ = [ __all__ = [
"CapabilityBuckets", "CapabilityBuckets",
"CapabilitySource", "CapabilitySource",
"CapabilityRef",
"SourceCapabilityInventory", "SourceCapabilityInventory",
"SourceInventory", "SourceInventory",
"SourceKind", "SourceKind",
@@ -22,4 +24,5 @@ __all__ = [
"SourceStatus", "SourceStatus",
"SourceVisibility", "SourceVisibility",
"SourceVisibilitySnapshot", "SourceVisibilitySnapshot",
"SourceRef",
] ]
+53
View File
@@ -0,0 +1,53 @@
from __future__ import annotations
from collections.abc import Mapping
from dataclasses import dataclass
@dataclass(frozen=True, slots=True)
class SourceRef:
"""Segment-backed source identifier with dotted-string wire formatting."""
parts: tuple[str, ...]
def __post_init__(self) -> None:
if not self.parts or any(not part for part in self.parts):
raise ValueError("source ref requires non-empty path segments")
@classmethod
def parse(cls, value: str) -> SourceRef:
"""Parse one dotted source id into first-class path segments."""
return cls(tuple(value.split(".")))
def __str__(self) -> str:
return ".".join(self.parts)
@dataclass(frozen=True, slots=True)
class CapabilityRef:
"""Segment-backed capability reference: one source plus one local name."""
source: SourceRef
name: str
def __post_init__(self) -> None:
if not self.name:
raise ValueError("capability ref requires a non-empty name")
@classmethod
def parse(cls, value: str) -> CapabilityRef:
"""Parse `<source>.<capability>` while preserving source path segments."""
source_text, separator, name = value.rpartition(".")
if not separator or not source_text or not name:
raise ValueError("capability ref requires source and capability segments")
return cls(source=SourceRef.parse(source_text), name=name)
def bind(self, bindings: Mapping[str, str]) -> CapabilityRef:
"""Replace a logical source with its concrete bound source when present."""
bound_source = bindings.get(str(self.source))
if bound_source is None:
return self
return CapabilityRef(source=SourceRef.parse(bound_source), name=self.name)
def __str__(self) -> str:
return f"{self.source}.{self.name}"
+35
View File
@@ -0,0 +1,35 @@
from __future__ import annotations
from wf_platform import CapabilityRef, SourceRef
def test_source_ref_round_trips_segmented_names() -> None:
source = SourceRef.parse("demo.personal")
assert source.parts == ("demo", "personal")
assert str(source) == "demo.personal"
def test_capability_ref_round_trips_segmented_names() -> None:
ref = CapabilityRef.parse("demo.personal.echo_tool")
assert ref.source.parts == ("demo", "personal")
assert ref.name == "echo_tool"
assert str(ref) == "demo.personal.echo_tool"
def test_capability_ref_binds_logical_source_to_concrete_source() -> None:
ref = CapabilityRef.parse("demo.echo_tool")
bound = ref.bind({"demo": "demo.personal"})
assert str(bound) == "demo.personal.echo_tool"
def test_capability_ref_rejects_missing_capability_name() -> None:
try:
CapabilityRef.parse("demo")
except ValueError as exc:
assert "source and capability" in str(exc)
else:
raise AssertionError("expected invalid capability ref to be rejected")