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
|
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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,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
|
||||||
|
|||||||
@@ -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",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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