capabilityref: less string matching more model
This commit is contained in:
@@ -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
|
||||
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.
|
||||
Implementation note: source and capability names now have segment-backed
|
||||
platform refs: `SourceRef(parts=...)` and `CapabilityRef(source=..., name=...)`.
|
||||
Dot-joined names remain the wire/presentation format, but new runtime code
|
||||
should parse or format through those refs instead of rediscovering source/name
|
||||
boundaries with ad hoc string splits.
|
||||
|
||||
The first implementation should prefer artifact validation and dependency
|
||||
diagnostics before attempting persistent nested resume.
|
||||
|
||||
@@ -3,6 +3,7 @@ from __future__ import annotations
|
||||
from collections.abc import Mapping
|
||||
|
||||
from wf_core import ReducerRef, Workflow
|
||||
from wf_platform import CapabilityRef
|
||||
|
||||
from .models import ArtifactKind, JsonObject, RequiredCapability, WorkflowArtifact
|
||||
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)
|
||||
except ValueError:
|
||||
continue
|
||||
if "." not in reducer.name:
|
||||
try:
|
||||
reducer_ref = CapabilityRef.parse(reducer.name)
|
||||
except ValueError:
|
||||
continue
|
||||
if "." not in reducer.name:
|
||||
continue
|
||||
logical_source, _, capability_name = reducer.name.rpartition(".")
|
||||
requirements[reducer.name] = RequiredCapability(
|
||||
logical_source=logical_source,
|
||||
capability_name=capability_name,
|
||||
logical_source=str(reducer_ref.source),
|
||||
capability_name=reducer_ref.name,
|
||||
kind="reducer",
|
||||
)
|
||||
return requirements
|
||||
|
||||
@@ -3,6 +3,8 @@ from __future__ import annotations
|
||||
from collections.abc import Mapping
|
||||
from copy import deepcopy
|
||||
|
||||
from wf_platform import CapabilityRef, SourceRef
|
||||
|
||||
from .models import JsonObject, RequiredCapability
|
||||
|
||||
|
||||
@@ -68,6 +70,8 @@ def logical_ref_for_concrete_ref(
|
||||
capability_name = concrete_ref[len(prefix) :]
|
||||
if not capability_name:
|
||||
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 None
|
||||
|
||||
@@ -15,6 +15,7 @@ from wf_artifacts import (
|
||||
create_workflow_artifact_from_plan as build_workflow_artifact_from_plan,
|
||||
validate_deployment_dependencies,
|
||||
)
|
||||
from wf_platform import CapabilityRef
|
||||
from wf_authoring import build_async_registry
|
||||
from wf_core import RuntimeContext
|
||||
|
||||
@@ -316,21 +317,23 @@ def _available_sources(service: WfMcpService) -> list[AvailableSource]:
|
||||
sources: list[AvailableSource] = []
|
||||
for source in service.capability_sources.values():
|
||||
capabilities = {
|
||||
spec.name.rsplit(".", maxsplit=1)[-1]: AvailableCapability(
|
||||
name=spec.name.rsplit(".", maxsplit=1)[-1],
|
||||
capability_name: AvailableCapability(
|
||||
name=capability_name,
|
||||
kind="node_spec",
|
||||
input_schema_hash=None,
|
||||
output_schema_hash=None,
|
||||
)
|
||||
for spec in source.capabilities.node_specs.values()
|
||||
if (capability_name := _capability_name(spec.name)) is not None
|
||||
}
|
||||
capabilities.update(
|
||||
{
|
||||
reducer.name.rsplit(".", maxsplit=1)[-1]: AvailableCapability(
|
||||
name=reducer.name.rsplit(".", maxsplit=1)[-1],
|
||||
capability_name: AvailableCapability(
|
||||
name=capability_name,
|
||||
kind="reducer",
|
||||
)
|
||||
for reducer in source.capabilities.reducers.values()
|
||||
if (capability_name := _capability_name(reducer.name)) is not None
|
||||
}
|
||||
)
|
||||
sources.append(
|
||||
@@ -343,6 +346,14 @@ def _available_sources(service: WfMcpService) -> list[AvailableSource]:
|
||||
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:
|
||||
"""Use the same stable name shape as workflow artifact catalog entries."""
|
||||
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_authoring import AsyncRegistryHandler, NodeSpec, build_async_registry
|
||||
from wf_core.runtime.ops.merges import ReducerDefinition
|
||||
from wf_platform import CapabilitySource
|
||||
from wf_platform import CapabilityRef, CapabilitySource
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
@@ -67,14 +67,15 @@ def _resolve_node_spec(
|
||||
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
|
||||
try:
|
||||
bound_ref = CapabilityRef.parse(node_name).bind(deployment.bindings)
|
||||
except ValueError:
|
||||
bound_ref = None
|
||||
if bound_ref is not None:
|
||||
bound_name = str(bound_ref)
|
||||
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}")
|
||||
|
||||
@@ -124,6 +125,10 @@ def _find_reducer_definition(
|
||||
capability_name: str,
|
||||
) -> ReducerDefinition | None:
|
||||
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 None
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
from .refs import CapabilityRef, SourceRef
|
||||
from .sources import (
|
||||
CapabilityBuckets,
|
||||
CapabilitySource,
|
||||
@@ -14,6 +15,7 @@ from .sources import (
|
||||
__all__ = [
|
||||
"CapabilityBuckets",
|
||||
"CapabilitySource",
|
||||
"CapabilityRef",
|
||||
"SourceCapabilityInventory",
|
||||
"SourceInventory",
|
||||
"SourceKind",
|
||||
@@ -22,4 +24,5 @@ __all__ = [
|
||||
"SourceStatus",
|
||||
"SourceVisibility",
|
||||
"SourceVisibilitySnapshot",
|
||||
"SourceRef",
|
||||
]
|
||||
|
||||
@@ -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}"
|
||||
@@ -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")
|
||||
Reference in New Issue
Block a user