more map ->list moves: WorkflowDeployment / WorkflowArtifact

This commit is contained in:
lda
2026-05-20 21:13:26 +07:00 Verified
parent 9f265c3f80
commit 9c7c616212
20 changed files with 660 additions and 113 deletions
@@ -0,0 +1,272 @@
# Typed Source Artifact Contracts Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Replace persisted dict-key source/capability contract shapes with explicit list-of-struct models backed by `SourceRef` and `CapabilityRef`.
**Architecture:** Keep dot-joined strings as the JSON wire format for refs, but make Python/Pydantic fields use first-class ref objects. Accept old dict shapes as parse-only compatibility and dump the new list shapes. Runtime code should use helper indexes such as `binding_map()` and `required_capability_map()` instead of depending on serialized dict keys.
**Tech Stack:** Python, Pydantic v2, `wf_platform` refs, `wf_artifacts` models, pytest, ruff, basedpyright.
---
### Task 1: Make Platform Refs Pydantic Boundary Types
**Files:**
- Modify: `src/wf_platform/refs.py`
- Modify: `tests/refs/test_platform_refs.py`
- [ ] **Step 1: Add tests for Pydantic validation and serialization**
Add tests that prove:
```python
class Payload(BaseModel):
source: SourceRef
capability: CapabilityRef
payload = Payload.model_validate(
{"source": "demo.personal", "capability": "demo.personal.echo_tool"}
)
assert payload.source == SourceRef.parse("demo.personal")
assert payload.capability == CapabilityRef.parse("demo.personal.echo_tool")
assert payload.model_dump(mode="json") == {
"source": "demo.personal",
"capability": "demo.personal.echo_tool",
}
```
- [ ] **Step 2: Implement Pydantic core-schema hooks**
Use `__get_pydantic_core_schema__` on `SourceRef` and `CapabilityRef` so both accept existing instances or strings and serialize back to strings.
- [ ] **Step 3: Tighten segment validation modestly**
Reject whitespace-only refs and empty segments. Do not over-restrict valid MCP/source names yet; external systems can use dashes, underscores, and other non-empty string segments.
### Task 2: Convert Deployment Bindings to List-of-Struct
**Files:**
- Modify: `src/wf_artifacts/models.py`
- Modify: `tests/artifacts/test_models.py`
- Modify: `tests/artifacts/test_store.py`
- [ ] **Step 1: Add `SourceBinding`**
```python
class SourceBinding(BaseModel):
logical_source: SourceRef
concrete_source: SourceRef
```
- [ ] **Step 2: Change `WorkflowDeployment.bindings`**
Canonical model field:
```python
bindings: list[SourceBinding] = Field(default_factory=list)
```
Parse-only compatibility:
```json
{"bindings": {"demo": "demo.personal"}}
```
should normalize to:
```json
{"bindings": [{"logical_source": "demo", "concrete_source": "demo.personal"}]}
```
- [ ] **Step 3: Add `binding_map()`**
```python
def binding_map(self) -> dict[str, str]:
return {
str(binding.logical_source): str(binding.concrete_source)
for binding in self.bindings
}
```
Reject duplicate `logical_source` values during validation.
### Task 3: Convert Required Capabilities to List-of-Struct
**Files:**
- Modify: `src/wf_artifacts/models.py`
- Modify: `src/wf_artifacts/factory.py`
- Modify: `src/wf_artifacts/references.py`
- Modify: `tests/artifacts/test_models.py`
- Modify: `tests/artifacts/test_factory.py`
- [ ] **Step 1: Update `RequiredCapability`**
Canonical field:
```python
ref: CapabilityRef
```
Keep compatibility properties:
```python
@property
def logical_source(self) -> str: ...
@property
def capability_name(self) -> str: ...
```
Parse-only compatibility should accept old payloads with `logical_source` and `capability_name` and build `ref`.
- [ ] **Step 2: Change `WorkflowArtifact.required_capabilities`**
Canonical model field:
```python
required_capabilities: list[RequiredCapability] = Field(default_factory=list)
```
Parse-only compatibility:
```json
{
"required_capabilities": {
"demo.echo_tool": {
"logical_source": "demo",
"capability_name": "echo_tool",
"kind": "node_spec"
}
}
}
```
should normalize to a list and dump as:
```json
{
"required_capabilities": [
{
"ref": "demo.echo_tool",
"kind": "node_spec"
}
]
}
```
- [ ] **Step 3: Add `required_capability_map()`**
```python
def required_capability_map(self) -> dict[str, RequiredCapability]:
return {str(capability.ref): capability for capability in self.required_capabilities}
```
Reject duplicate `ref` values during validation.
### Task 4: Update Call Sites to Use Helper Maps
**Files:**
- Modify: `src/wf_artifacts/validation.py`
- Modify: `src/wf_artifacts/catalog.py`
- Modify: `src/wf_mcp/workflow_surface/runtime_dependencies.py`
- Modify: `src/wf_mcp/workflow_surface/handlers.py`
- Modify: `src/wf_mcp/workflow_surface/tools.py`
- Modify: `src/wf_mcp/broker/artifact_tools.py`
- Modify tests that directly index `.bindings` or `.required_capabilities`.
- [ ] **Step 1: Replace `deployment.bindings.get(...)`**
Use:
```python
bindings = deployment.binding_map()
bound_source_id = bindings.get(required.logical_source)
```
- [ ] **Step 2: Replace `artifact.required_capabilities.items()`**
Use:
```python
for logical_ref, required in artifact.required_capability_map().items():
...
```
- [ ] **Step 3: Keep API input compatibility**
MCP tools that accept `required_capabilities` from callers may still accept dict input, but constructed `WorkflowArtifact` should dump the canonical list shape.
### Task 5: Update Docs
**Files:**
- Modify: `docs/workflow_artifacts.md`
- [ ] **Step 1: Replace dict binding examples**
Use:
```json
{
"bindings": [
{"logical_source": "context7", "concrete_source": "context7.default"}
]
}
```
- [ ] **Step 2: Replace dict required capability examples**
Use:
```json
{
"required_capabilities": [
{
"ref": "context7.query-docs",
"kind": "node_spec",
"input_schema_hash": "sha256:..."
}
]
}
```
State that old dict shapes are accepted at parse boundaries but not emitted by model dumps.
### Task 6: Verification
**Files:**
- All touched files
- [ ] **Step 1: Run focused tests**
Run:
```bash
uv run --with pytest pytest tests/refs/test_platform_refs.py tests/artifacts/test_models.py tests/artifacts/test_store.py tests/artifacts/test_factory.py tests/artifacts/test_validation.py -q
```
- [ ] **Step 2: Run full suite**
Run:
```bash
uv run --with pytest pytest -q
```
- [ ] **Step 3: Run static checks**
Run:
```bash
uvx ruff check
uv run basedpyright --level error
```
---
## Self-Review
- Spec coverage: covers typed refs, deployment binding list shape, required capability list shape, compatibility parsing, helper indexes, docs, and tests.
- Placeholder scan: no placeholders remain.
- Type consistency: `SourceRef`, `CapabilityRef`, `SourceBinding`, `RequiredCapability`, `WorkflowArtifact`, and `WorkflowDeployment` names match current code or are introduced in this plan.
+24 -13
View File
@@ -221,12 +221,12 @@ step:
node_ref: context7.query-docs node_ref: context7.query-docs
required_capabilities: required_capabilities:
context7.query-docs: - ref: context7.query-docs
kind: tool kind: tool
input_schema_hash input_schema_hash: ...
input_schema_snapshot input_schema_snapshot: ...
output_schema_hash output_schema_hash: ...
output_schema_snapshot output_schema_snapshot: ...
``` ```
This is similar to import resolution. The artifact stores stable logical This is similar to import resolution. The artifact stores stable logical
@@ -286,8 +286,7 @@ actually use:
```text ```text
RequiredCapability RequiredCapability
logical_source: context7 ref: context7.query-docs
capability_name: query-docs
kind: tool kind: tool
input_schema input_schema
output_schema output_schema
@@ -446,8 +445,7 @@ and the artifact should declare a matching required capability:
```text ```text
RequiredCapability( RequiredCapability(
logical_source="demo", ref="demo.echo_tool",
capability_name="echo_tool",
kind="node_spec" kind="node_spec"
) )
``` ```
@@ -456,7 +454,8 @@ The deployment then chooses the concrete account or connection profile:
```text ```text
bindings: bindings:
demo: demo.personal - logical_source: demo
concrete_source: demo.personal
``` ```
This keeps reusable artifacts portable across accounts while still letting This keeps reusable artifacts portable across accounts while still letting
@@ -494,7 +493,8 @@ WorkflowDeployment
artifact_version: 1 artifact_version: 1
deployment_id: summarize_docs.context7_default deployment_id: summarize_docs.context7_default
bindings: bindings:
context7: context7.default - logical_source: context7
concrete_source: context7.default
drift_policy: block drift_policy: block
``` ```
@@ -510,12 +510,14 @@ logical sources to different MCP accounts or connection profiles:
deployment: summarize_docs.personal deployment: summarize_docs.personal
artifact: summarize_docs@1 artifact: summarize_docs@1
bindings: bindings:
context7: context7.personal - logical_source: context7
concrete_source: context7.personal
deployment: summarize_docs.work deployment: summarize_docs.work
artifact: summarize_docs@1 artifact: summarize_docs@1
bindings: bindings:
context7: context7.work - logical_source: context7
concrete_source: context7.work
``` ```
This gives the stable MCP run tool a concrete target without requiring one MCP This gives the stable MCP run tool a concrete target without requiring one MCP
@@ -599,6 +601,15 @@ Dot-joined names remain the wire/presentation format, but new runtime code
should parse or format through those refs instead of rediscovering source/name should parse or format through those refs instead of rediscovering source/name
boundaries with ad hoc string splits. boundaries with ad hoc string splits.
Persisted artifact/deployment models use explicit list-of-struct shapes for
source and capability contracts. Dict-key shapes such as
`bindings: {"demo": "demo.personal"}` and
`required_capabilities: {"demo.echo_tool": {...}}` are accepted at parse
boundaries for compatibility, but model dumps emit the explicit list shapes.
Runtime code that needs lookup tables should use helper indexes such as
`WorkflowDeployment.binding_map()` and
`WorkflowArtifact.required_capability_map()`.
Saved workflow artifact names use a separate grammar and ref type: Saved workflow artifact names use a separate grammar and ref type:
`WorkflowCapabilityRef(artifact_id, version)` serializes as `WorkflowCapabilityRef(artifact_id, version)` serializes as
`workflow.<artifact_id>.v<version>`. Artifact ids may contain dots, so this `workflow.<artifact_id>.v<version>`. Artifact ids may contain dots, so this
+4 -4
View File
@@ -207,10 +207,10 @@ async def create_and_run_echo_deployment(root: Path, *, text: str) -> dict[str,
id="mcp_echo.personal", id="mcp_echo.personal",
artifact_id="mcp_echo", artifact_id="mcp_echo",
artifact_version=1, artifact_version=1,
bindings={ bindings=[
"demo": "demo.personal", {"logical_source": "demo", "concrete_source": "demo.personal"},
"wf.std": "wf.std", {"logical_source": "wf.std", "concrete_source": "wf.std"},
}, ],
) )
) )
return await handlers.run_deployment( return await handlers.run_deployment(
+2
View File
@@ -28,6 +28,7 @@ from .models import (
DiagnosticSeverity, DiagnosticSeverity,
DriftPolicy, DriftPolicy,
RequiredCapability, RequiredCapability,
SourceBinding,
WorkflowArtifact, WorkflowArtifact,
WorkflowDeployment, WorkflowDeployment,
) )
@@ -48,6 +49,7 @@ __all__ = [
"FileDraftWorkspaceStore", "FileDraftWorkspaceStore",
"FileWorkflowArtifactStore", "FileWorkflowArtifactStore",
"RequiredCapability", "RequiredCapability",
"SourceBinding",
"WorkflowArtifact", "WorkflowArtifact",
"WorkflowArtifactCatalogEntry", "WorkflowArtifactCatalogEntry",
"WorkflowCapabilityRef", "WorkflowCapabilityRef",
+1 -1
View File
@@ -41,7 +41,7 @@ def artifact_catalog_entry(
required_sources = sorted( required_sources = sorted(
{ {
capability.logical_source capability.logical_source
for capability in artifact.required_capabilities.values() for capability in artifact.required_capability_map().values()
} }
) )
return WorkflowArtifactCatalogEntry( return WorkflowArtifactCatalogEntry(
+7 -7
View File
@@ -30,6 +30,11 @@ def create_workflow_artifact_from_plan(
observed_node_specs, observed_node_specs,
) )
_validate_workflow_plan(normalized_plan) _validate_workflow_plan(normalized_plan)
required = {
**_required_reducers_from_plan(normalized_plan),
**node_requirements,
**dict(required_capabilities or {}),
}
return WorkflowArtifact( return WorkflowArtifact(
id=artifact_id, id=artifact_id,
version=version, version=version,
@@ -40,11 +45,7 @@ def create_workflow_artifact_from_plan(
output_schema=_required_object_field(normalized_plan, "output_schema"), output_schema=_required_object_field(normalized_plan, "output_schema"),
outcomes=outcomes, outcomes=outcomes,
plan=normalized_plan, plan=normalized_plan,
required_capabilities={ required_capabilities=list(required.values()),
**_required_reducers_from_plan(normalized_plan),
**node_requirements,
**dict(required_capabilities or {}),
},
created_from_catalog_version=created_from_catalog_version, created_from_catalog_version=created_from_catalog_version,
) )
@@ -103,8 +104,7 @@ def _required_reducers_from_plan(plan: JsonObject) -> dict[str, RequiredCapabili
except ValueError: except ValueError:
continue continue
requirements[reducer.name] = RequiredCapability( requirements[reducer.name] = RequiredCapability(
logical_source=str(reducer_ref.source), ref=reducer_ref,
capability_name=reducer_ref.name,
kind="reducer", kind="reducer",
) )
return requirements return requirements
+171 -6
View File
@@ -3,10 +3,14 @@ from __future__ import annotations
from enum import StrEnum from enum import StrEnum
from typing import Any, Literal from typing import Any, Literal
from pydantic import BaseModel, Field from pydantic import BaseModel, Field, model_validator
from wf_platform import CapabilityRef, SourceRef
JsonObject = dict[str, Any] JsonObject = dict[str, Any]
ArtifactKind = Literal["workflow", "wrapper"] ArtifactKind = Literal["workflow", "wrapper"]
SourceRefInput = SourceRef | str
CapabilityRefInput = CapabilityRef | str
class DriftPolicy(StrEnum): class DriftPolicy(StrEnum):
@@ -27,16 +31,46 @@ class DiagnosticSeverity(StrEnum):
class RequiredCapability(BaseModel): class RequiredCapability(BaseModel):
"""Saved contract for one capability an artifact references.""" """Saved contract for one capability an artifact references."""
logical_source: str ref: CapabilityRefInput
capability_name: str
kind: Literal["tool", "resource", "prompt", "node_spec", "reducer", "workflow"] kind: Literal["tool", "resource", "prompt", "node_spec", "reducer", "workflow"]
input_schema_hash: str | None = None input_schema_hash: str | None = None
input_schema_snapshot: JsonObject | None = None input_schema_snapshot: JsonObject | None = None
output_schema_hash: str | None = None output_schema_hash: str | None = None
output_schema_snapshot: JsonObject | None = None output_schema_snapshot: JsonObject | None = None
observed_concrete_source: str | None = None observed_concrete_source: SourceRefInput | None = None
observed_at_epoch_ms: int | None = Field(default=None, ge=0) observed_at_epoch_ms: int | None = Field(default=None, ge=0)
@property
def logical_source(self) -> str:
"""Compatibility accessor for callers migrating to `ref`."""
return str(self.capability_ref().source)
@property
def capability_name(self) -> str:
"""Compatibility accessor for callers migrating to `ref`."""
return self.capability_ref().name
def capability_ref(self) -> CapabilityRef:
"""Return the typed ref even when constructed from JSON-compatible input."""
return (
self.ref
if isinstance(self.ref, CapabilityRef)
else CapabilityRef.parse(self.ref)
)
@model_validator(mode="before")
@classmethod
def _coerce_legacy_ref_fields(cls, value: object) -> object:
if not isinstance(value, dict):
return value
data = dict(value)
if "ref" not in data:
logical_source = data.pop("logical_source", None)
capability_name = data.pop("capability_name", None)
if isinstance(logical_source, str) and isinstance(capability_name, str):
data["ref"] = f"{logical_source}.{capability_name}"
return data
class AvailableCapability(BaseModel): class AvailableCapability(BaseModel):
"""Current contract for one capability exposed by a bound source.""" """Current contract for one capability exposed by a bound source."""
@@ -55,6 +89,29 @@ class AvailableSource(BaseModel):
capabilities: dict[str, AvailableCapability] = Field(default_factory=dict) capabilities: dict[str, AvailableCapability] = Field(default_factory=dict)
class SourceBinding(BaseModel):
"""Deployment-time mapping from artifact logical source to concrete source."""
logical_source: SourceRefInput
concrete_source: SourceRefInput
def logical_ref(self) -> SourceRef:
"""Return the typed logical source ref for runtime lookup code."""
return (
self.logical_source
if isinstance(self.logical_source, SourceRef)
else SourceRef.parse(self.logical_source)
)
def concrete_ref(self) -> SourceRef:
"""Return the typed concrete source ref for runtime lookup code."""
return (
self.concrete_source
if isinstance(self.concrete_source, SourceRef)
else SourceRef.parse(self.concrete_source)
)
class DependencyDiagnostic(BaseModel): class DependencyDiagnostic(BaseModel):
"""Machine-readable reason a deployment is degraded or unrunnable.""" """Machine-readable reason a deployment is degraded or unrunnable."""
@@ -78,10 +135,66 @@ class WorkflowArtifact(BaseModel):
output_schema: JsonObject output_schema: JsonObject
outcomes: tuple[str, ...] outcomes: tuple[str, ...]
plan: JsonObject plan: JsonObject
required_capabilities: dict[str, RequiredCapability] = Field(default_factory=dict) required_capabilities: (
list[RequiredCapability] | dict[str, RequiredCapability | JsonObject]
) = Field(default_factory=list)
workflow_dependencies: dict[str, int] = Field(default_factory=dict) workflow_dependencies: dict[str, int] = Field(default_factory=dict)
created_from_catalog_version: str | None = None created_from_catalog_version: str | None = None
def required_capability_map(self) -> dict[str, RequiredCapability]:
"""Return required capabilities keyed by dot-joined capability ref."""
return {
str(capability.capability_ref()): capability
for capability in self._required_capability_list()
}
@model_validator(mode="before")
@classmethod
def _coerce_legacy_required_capabilities(cls, value: object) -> object:
if not isinstance(value, dict):
return value
data = dict(value)
required = data.get("required_capabilities")
if not isinstance(required, dict):
return data
normalized: list[object] = []
for raw_ref, raw_capability in required.items():
if not isinstance(raw_capability, dict):
normalized.append(raw_capability)
continue
capability_data = dict(raw_capability)
capability_data.setdefault("ref", str(raw_ref))
normalized.append(capability_data)
data["required_capabilities"] = normalized
return data
@model_validator(mode="after")
def _reject_duplicate_required_capabilities(self) -> WorkflowArtifact:
self.required_capabilities = self._required_capability_list()
refs = [
str(capability.capability_ref())
for capability in self.required_capabilities
]
duplicates = {ref for ref in refs if refs.count(ref) > 1}
if duplicates:
raise ValueError(
"duplicate required capability refs: " + ", ".join(sorted(duplicates))
)
return self
def _required_capability_list(self) -> list[RequiredCapability]:
if isinstance(self.required_capabilities, dict):
return [
RequiredCapability.model_validate(
{"ref": ref, **capability}
if isinstance(capability, dict)
else capability
)
for ref, capability in self.required_capabilities.items()
]
return self.required_capabilities
class WorkflowDeployment(BaseModel): class WorkflowDeployment(BaseModel):
"""One configured way to run an artifact version in an environment.""" """One configured way to run an artifact version in an environment."""
@@ -89,5 +202,57 @@ class WorkflowDeployment(BaseModel):
id: str id: str
artifact_id: str artifact_id: str
artifact_version: int = Field(ge=1) artifact_version: int = Field(ge=1)
bindings: dict[str, str] = Field(default_factory=dict) bindings: list[SourceBinding | dict[str, str]] | dict[str, str] = Field(
default_factory=list
)
drift_policy: DriftPolicy = DriftPolicy.BLOCK drift_policy: DriftPolicy = DriftPolicy.BLOCK
def binding_map(self) -> dict[str, str]:
"""Return bindings keyed by dot-joined logical source ref."""
return {
str(binding.logical_ref()): str(binding.concrete_ref())
for binding in self._binding_list()
}
@model_validator(mode="before")
@classmethod
def _coerce_legacy_binding_map(cls, value: object) -> object:
if not isinstance(value, dict):
return value
data = dict(value)
bindings = data.get("bindings")
if not isinstance(bindings, dict):
return data
data["bindings"] = [
{"logical_source": logical, "concrete_source": concrete}
for logical, concrete in bindings.items()
]
return data
@model_validator(mode="after")
def _reject_duplicate_bindings(self) -> WorkflowDeployment:
normalized = self._binding_list()
self.bindings = [*normalized]
logical_sources = [str(binding.logical_ref()) for binding in normalized]
duplicates = {
source for source in logical_sources if logical_sources.count(source) > 1
}
if duplicates:
raise ValueError(
"duplicate deployment source bindings: " + ", ".join(sorted(duplicates))
)
return self
def _binding_list(self) -> list[SourceBinding]:
if isinstance(self.bindings, dict):
return [
SourceBinding(logical_source=logical, concrete_source=concrete)
for logical, concrete in self.bindings.items()
]
return [
binding
if isinstance(binding, SourceBinding)
else SourceBinding.model_validate(binding)
for binding in self.bindings
]
+2 -3
View File
@@ -46,8 +46,7 @@ def normalize_plan_node_refs(
else None else None
) )
requirements[logical_ref] = RequiredCapability( requirements[logical_ref] = RequiredCapability(
logical_source=logical_source, ref=CapabilityRef.parse(logical_ref),
capability_name=capability_name,
kind="node_spec", kind="node_spec",
input_schema_hash=( input_schema_hash=(
hash_json_schema(observed.input_schema) hash_json_schema(observed.input_schema)
@@ -65,7 +64,7 @@ def normalize_plan_node_refs(
output_schema_snapshot=( output_schema_snapshot=(
observed.output_schema if observed is not None else None observed.output_schema if observed is not None else None
), ),
observed_concrete_source=concrete_source, observed_concrete_source=SourceRef.parse(concrete_source),
) )
return normalized, requirements return normalized, requirements
+3 -2
View File
@@ -19,10 +19,11 @@ def validate_deployment_dependencies(
) -> list[DependencyDiagnostic]: ) -> list[DependencyDiagnostic]:
"""Validate that a deployment can satisfy an artifact's required contracts.""" """Validate that a deployment can satisfy an artifact's required contracts."""
sources_by_id = {source.id: source for source in sources} sources_by_id = {source.id: source for source in sources}
bindings = deployment.binding_map()
diagnostics: list[DependencyDiagnostic] = [] diagnostics: list[DependencyDiagnostic] = []
for logical_ref, required in artifact.required_capabilities.items(): for logical_ref, required in artifact.required_capability_map().items():
bound_source_id = deployment.bindings.get(required.logical_source) bound_source_id = bindings.get(required.logical_source)
if bound_source_id is None: if bound_source_id is None:
diagnostics.append( diagnostics.append(
_diagnostic( _diagnostic(
+6 -8
View File
@@ -234,10 +234,9 @@ class WorkflowSurfaceHandlers:
"is_async": True, "is_async": True,
"input_schema": artifact.input_schema, "input_schema": artifact.input_schema,
"output_schema": artifact.output_schema, "output_schema": artifact.output_schema,
"required_capabilities": { "required_capabilities": _required_capability_payloads(
name: capability.model_dump(mode="json") artifact.required_capability_map()
for name, capability in sorted(artifact.required_capabilities.items()) ),
},
} }
async def _call_wrapper_artifact( async def _call_wrapper_artifact(
@@ -428,7 +427,7 @@ class WorkflowSurfaceHandlers:
required_sources = sorted( required_sources = sorted(
{ {
capability.logical_source capability.logical_source
for capability in dict(workflow_artifact.required_capabilities).values() for capability in workflow_artifact.required_capability_map().values()
} }
) )
return { return {
@@ -892,7 +891,7 @@ def _required_capabilities_for_plan(
source_bindings=source_bindings, source_bindings=source_bindings,
observed_node_specs=_observed_node_specs(service), observed_node_specs=_observed_node_specs(service),
) )
requirements = dict(artifact.required_capabilities) requirements = artifact.required_capability_map()
for node in _plan_nodes(artifact): for node in _plan_nodes(artifact):
raw_ref = node.get("node") raw_ref = node.get("node")
if not isinstance(raw_ref, str) or raw_ref in requirements: if not isinstance(raw_ref, str) or raw_ref in requirements:
@@ -902,8 +901,7 @@ def _required_capabilities_for_plan(
except ValueError: except ValueError:
continue continue
requirements[raw_ref] = RequiredCapability( requirements[raw_ref] = RequiredCapability(
logical_source=str(parsed.source), ref=parsed,
capability_name=parsed.name,
kind="node_spec", kind="node_spec",
) )
return requirements return requirements
@@ -44,7 +44,7 @@ def resolve_runtime_dependencies(
node_name_bindings[node_name] = concrete_name node_name_bindings[node_name] = concrete_name
node_specs[concrete_name] = spec node_specs[concrete_name] = spec
reducers = _resolve_reducers( reducers = _resolve_reducers(
required_capabilities=artifact.required_capabilities, required_capabilities=artifact.required_capability_map(),
deployment=deployment, deployment=deployment,
sources=sources, sources=sources,
) )
@@ -68,7 +68,7 @@ def _resolve_node_spec(
if deployment is not None: if deployment is not None:
try: try:
bound_ref = CapabilityRef.parse(node_name).bind(deployment.bindings) bound_ref = CapabilityRef.parse(node_name).bind(deployment.binding_map())
except ValueError: except ValueError:
bound_ref = None bound_ref = None
if bound_ref is not None: if bound_ref is not None:
@@ -104,7 +104,7 @@ def _resolve_reducers(
for logical_ref, required in required_capabilities.items(): for logical_ref, required in required_capabilities.items():
if required.kind != "reducer": if required.kind != "reducer":
continue continue
bound_source_id = deployment.bindings.get(required.logical_source) bound_source_id = deployment.binding_map().get(required.logical_source)
if bound_source_id is None: if bound_source_id is None:
continue continue
source = sources.get(bound_source_id) source = sources.get(bound_source_id)
+50 -1
View File
@@ -2,6 +2,9 @@ from __future__ import annotations
from collections.abc import Mapping from collections.abc import Mapping
from dataclasses import dataclass from dataclasses import dataclass
from typing import Any
from pydantic_core import core_schema
@dataclass(frozen=True, slots=True) @dataclass(frozen=True, slots=True)
@@ -11,7 +14,7 @@ class SourceRef:
parts: tuple[str, ...] parts: tuple[str, ...]
def __post_init__(self) -> None: def __post_init__(self) -> None:
if not self.parts or any(not part for part in self.parts): if not self.parts or any(not part or not part.strip() for part in self.parts):
raise ValueError("source ref requires non-empty path segments") raise ValueError("source ref requires non-empty path segments")
@classmethod @classmethod
@@ -22,6 +25,29 @@ class SourceRef:
def __str__(self) -> str: def __str__(self) -> str:
return ".".join(self.parts) return ".".join(self.parts)
@classmethod
def __get_pydantic_core_schema__(
cls,
_source_type: object,
_handler: object,
) -> core_schema.CoreSchema:
"""Validate refs from strings while serializing back to wire strings."""
return core_schema.no_info_plain_validator_function(
cls._validate,
serialization=core_schema.plain_serializer_function_ser_schema(
str,
when_used="json",
),
)
@classmethod
def _validate(cls, value: Any) -> SourceRef:
if isinstance(value, SourceRef):
return value
if isinstance(value, str):
return cls.parse(value)
raise TypeError("source ref must be a string")
@dataclass(frozen=True, slots=True) @dataclass(frozen=True, slots=True)
class CapabilityRef: class CapabilityRef:
@@ -51,3 +77,26 @@ class CapabilityRef:
def __str__(self) -> str: def __str__(self) -> str:
return f"{self.source}.{self.name}" return f"{self.source}.{self.name}"
@classmethod
def __get_pydantic_core_schema__(
cls,
_source_type: object,
_handler: object,
) -> core_schema.CoreSchema:
"""Validate refs from strings while serializing back to wire strings."""
return core_schema.no_info_plain_validator_function(
cls._validate,
serialization=core_schema.plain_serializer_function_ser_schema(
str,
when_used="json",
),
)
@classmethod
def _validate(cls, value: Any) -> CapabilityRef:
if isinstance(value, CapabilityRef):
return value
if isinstance(value, str):
return cls.parse(value)
raise TypeError("capability ref must be a string")
+4 -5
View File
@@ -23,13 +23,12 @@ def artifact() -> WorkflowArtifact:
}, },
outcomes=("done", "failed"), outcomes=("done", "failed"),
plan={"name": "summarize_docs", "nodes": [{"id": "hidden"}]}, plan={"name": "summarize_docs", "nodes": [{"id": "hidden"}]},
required_capabilities={ required_capabilities=[
"context7.query-docs": RequiredCapability( RequiredCapability(
logical_source="context7", ref="context7.query-docs",
capability_name="query-docs",
kind="tool", kind="tool",
) )
}, ],
) )
+8 -10
View File
@@ -16,8 +16,7 @@ def test_create_workflow_artifact_from_plan_derives_boundary_schemas() -> None:
outcomes=("done",), outcomes=("done",),
required_capabilities={ required_capabilities={
"demo.echo_tool": RequiredCapability( "demo.echo_tool": RequiredCapability(
logical_source="demo", ref="demo.echo_tool",
capability_name="echo_tool",
kind="node_spec", kind="node_spec",
) )
}, },
@@ -32,7 +31,7 @@ def test_create_workflow_artifact_from_plan_derives_boundary_schemas() -> None:
assert artifact.output_schema["properties"]["echoed"]["type"] == "string" assert artifact.output_schema["properties"]["echoed"]["type"] == "string"
assert artifact.outcomes == ("done",) assert artifact.outcomes == ("done",)
assert artifact.plan["name"] == "echo" assert artifact.plan["name"] == "echo"
assert "demo.echo_tool" in artifact.required_capabilities assert "demo.echo_tool" in artifact.required_capability_map()
assert artifact.created_from_catalog_version == "catalog-1" assert artifact.created_from_catalog_version == "catalog-1"
@@ -51,7 +50,7 @@ def test_create_workflow_artifact_from_plan_adds_reducer_dependencies() -> None:
outcomes=("done",), outcomes=("done",),
) )
reducer = artifact.required_capabilities["wf.std.max"] reducer = artifact.required_capability_map()["wf.std.max"]
assert reducer.logical_source == "wf.std" assert reducer.logical_source == "wf.std"
assert reducer.capability_name == "max" assert reducer.capability_name == "max"
assert reducer.kind == "reducer" assert reducer.kind == "reducer"
@@ -71,12 +70,12 @@ def test_create_workflow_artifact_from_plan_rewrites_bound_node_specs() -> None:
) )
node = artifact.plan["nodes"][0] node = artifact.plan["nodes"][0]
required = artifact.required_capabilities["demo.echo_tool"] required = artifact.required_capability_map()["demo.echo_tool"]
assert node["node"] == "demo.echo_tool" assert node["node"] == "demo.echo_tool"
assert required.logical_source == "demo" assert required.logical_source == "demo"
assert required.capability_name == "echo_tool" assert required.capability_name == "echo_tool"
assert required.kind == "node_spec" assert required.kind == "node_spec"
assert required.observed_concrete_source == "demo.personal" assert str(required.observed_concrete_source) == "demo.personal"
def test_create_workflow_artifact_from_plan_snapshots_observed_node_spec() -> None: def test_create_workflow_artifact_from_plan_snapshots_observed_node_spec() -> None:
@@ -108,7 +107,7 @@ def test_create_workflow_artifact_from_plan_snapshots_observed_node_spec() -> No
}, },
) )
required = artifact.required_capabilities["demo.echo_tool"] required = artifact.required_capability_map()["demo.echo_tool"]
assert required.input_schema_snapshot == { assert required.input_schema_snapshot == {
"type": "object", "type": "object",
"properties": {"text": {"type": "string"}}, "properties": {"text": {"type": "string"}},
@@ -136,15 +135,14 @@ def test_create_workflow_artifact_from_plan_keeps_explicit_capability_metadata()
source_bindings={"demo": "demo.personal"}, source_bindings={"demo": "demo.personal"},
required_capabilities={ required_capabilities={
"demo.echo_tool": RequiredCapability( "demo.echo_tool": RequiredCapability(
logical_source="demo", ref="demo.echo_tool",
capability_name="echo_tool",
kind="node_spec", kind="node_spec",
input_schema_hash="sha256:explicit", input_schema_hash="sha256:explicit",
) )
}, },
) )
required = artifact.required_capabilities["demo.echo_tool"] required = artifact.required_capability_map()["demo.echo_tool"]
assert required.input_schema_hash == "sha256:explicit" assert required.input_schema_hash == "sha256:explicit"
+11 -7
View File
@@ -12,8 +12,7 @@ from wf_artifacts import (
def test_workflow_artifact_serializes_required_capability_contract() -> None: def test_workflow_artifact_serializes_required_capability_contract() -> None:
capability = RequiredCapability( capability = RequiredCapability(
logical_source="context7", ref="context7.query-docs",
capability_name="query-docs",
kind="tool", kind="tool",
input_schema_hash="sha256:input", input_schema_hash="sha256:input",
input_schema_snapshot={"type": "object", "properties": {}}, input_schema_snapshot={"type": "object", "properties": {}},
@@ -31,7 +30,7 @@ def test_workflow_artifact_serializes_required_capability_contract() -> None:
output_schema={"type": "object", "properties": {}}, output_schema={"type": "object", "properties": {}},
outcomes=("done", "failed"), outcomes=("done", "failed"),
plan={"name": "summarize_docs", "nodes": [], "edges": []}, plan={"name": "summarize_docs", "nodes": [], "edges": []},
required_capabilities={"context7.query-docs": capability}, required_capabilities=[capability],
created_from_catalog_version="catalog-1", created_from_catalog_version="catalog-1",
) )
@@ -41,9 +40,10 @@ def test_workflow_artifact_serializes_required_capability_contract() -> None:
assert dumped["kind"] == "workflow" assert dumped["kind"] == "workflow"
assert dumped["version"] == 1 assert dumped["version"] == 1
assert dumped["outcomes"] == ["done", "failed"] assert dumped["outcomes"] == ["done", "failed"]
required = dumped["required_capabilities"]["context7.query-docs"] required = dumped["required_capabilities"][0]
assert required["logical_source"] == "context7" assert required["ref"] == "context7.query-docs"
assert required["input_schema_hash"] == "sha256:input" assert required["input_schema_hash"] == "sha256:input"
assert artifact.required_capability_map()["context7.query-docs"] == capability
def test_workflow_artifact_can_be_marked_as_wrapper_intent() -> None: def test_workflow_artifact_can_be_marked_as_wrapper_intent() -> None:
@@ -68,7 +68,9 @@ def test_workflow_deployment_binds_logical_sources_to_concrete_sources() -> None
id="summarize_docs.personal", id="summarize_docs.personal",
artifact_id="summarize_docs", artifact_id="summarize_docs",
artifact_version=1, artifact_version=1,
bindings={"context7": "context7.personal"}, bindings=[
{"logical_source": "context7", "concrete_source": "context7.personal"}
],
drift_policy=DriftPolicy.BLOCK, drift_policy=DriftPolicy.BLOCK,
) )
@@ -77,8 +79,10 @@ def test_workflow_deployment_binds_logical_sources_to_concrete_sources() -> None
assert dumped["id"] == "summarize_docs.personal" assert dumped["id"] == "summarize_docs.personal"
assert dumped["artifact_id"] == "summarize_docs" assert dumped["artifact_id"] == "summarize_docs"
assert dumped["artifact_version"] == 1 assert dumped["artifact_version"] == 1
assert dumped["bindings"]["context7"] == "context7.personal" assert dumped["bindings"][0]["logical_source"] == "context7"
assert dumped["bindings"][0]["concrete_source"] == "context7.personal"
assert dumped["drift_policy"] == "block" assert dumped["drift_policy"] == "block"
assert deployment.binding_map()["context7"] == "context7.personal"
def test_dependency_diagnostic_is_structured() -> None: def test_dependency_diagnostic_is_structured() -> None:
+11 -5
View File
@@ -50,7 +50,9 @@ def test_file_store_round_trips_deployment(tmp_path) -> None:
id="summarize_docs.personal", id="summarize_docs.personal",
artifact_id="summarize_docs", artifact_id="summarize_docs",
artifact_version=1, artifact_version=1,
bindings={"context7": "context7.personal"}, bindings=[
{"logical_source": "context7", "concrete_source": "context7.personal"}
],
) )
store.save_deployment(deployment) store.save_deployment(deployment)
@@ -58,7 +60,7 @@ def test_file_store_round_trips_deployment(tmp_path) -> None:
assert loaded.id == "summarize_docs.personal" assert loaded.id == "summarize_docs.personal"
assert loaded.artifact_id == "summarize_docs" assert loaded.artifact_id == "summarize_docs"
assert loaded.bindings["context7"] == "context7.personal" assert loaded.binding_map()["context7"] == "context7.personal"
def test_file_store_lists_deployments_in_id_order(tmp_path) -> None: def test_file_store_lists_deployments_in_id_order(tmp_path) -> None:
@@ -68,7 +70,9 @@ def test_file_store_lists_deployments_in_id_order(tmp_path) -> None:
id="summarize_docs.work", id="summarize_docs.work",
artifact_id="summarize_docs", artifact_id="summarize_docs",
artifact_version=1, artifact_version=1,
bindings={"context7": "context7.work"}, bindings=[
{"logical_source": "context7", "concrete_source": "context7.work"}
],
) )
) )
store.save_deployment( store.save_deployment(
@@ -76,7 +80,9 @@ def test_file_store_lists_deployments_in_id_order(tmp_path) -> None:
id="summarize_docs.personal", id="summarize_docs.personal",
artifact_id="summarize_docs", artifact_id="summarize_docs",
artifact_version=1, artifact_version=1,
bindings={"context7": "context7.personal"}, bindings=[
{"logical_source": "context7", "concrete_source": "context7.personal"}
],
) )
) )
@@ -86,4 +92,4 @@ def test_file_store_lists_deployments_in_id_order(tmp_path) -> None:
"summarize_docs.personal", "summarize_docs.personal",
"summarize_docs.work", "summarize_docs.work",
] ]
assert deployments[0].bindings["context7"] == "context7.personal" assert deployments[0].binding_map()["context7"] == "context7.personal"
+11 -8
View File
@@ -19,8 +19,7 @@ def required_capability(
output_hash: str = "sha256:output", output_hash: str = "sha256:output",
) -> RequiredCapability: ) -> RequiredCapability:
return RequiredCapability( return RequiredCapability(
logical_source=logical_source, ref=f"{logical_source}.{capability_name}",
capability_name=capability_name,
kind="tool", kind="tool",
input_schema_hash=input_hash, input_schema_hash=input_hash,
input_schema_snapshot={"type": "object", "properties": {}}, input_schema_snapshot={"type": "object", "properties": {}},
@@ -38,9 +37,7 @@ def artifact_with(capability: RequiredCapability) -> WorkflowArtifact:
output_schema={"type": "object", "properties": {}}, output_schema={"type": "object", "properties": {}},
outcomes=("done",), outcomes=("done",),
plan={"name": "summarize_docs", "nodes": [], "edges": []}, plan={"name": "summarize_docs", "nodes": [], "edges": []},
required_capabilities={ required_capabilities=[capability],
f"{capability.logical_source}.{capability.capability_name}": capability
},
) )
@@ -53,7 +50,14 @@ def deployment(
id="summarize_docs.personal", id="summarize_docs.personal",
artifact_id="summarize_docs", artifact_id="summarize_docs",
artifact_version=1, artifact_version=1,
bindings={"context7": "context7.personal"} if bindings is None else bindings, bindings=(
[{"logical_source": "context7", "concrete_source": "context7.personal"}]
if bindings is None
else [
{"logical_source": logical, "concrete_source": concrete}
for logical, concrete in bindings.items()
]
),
drift_policy=drift_policy, drift_policy=drift_policy,
) )
@@ -180,8 +184,7 @@ def test_validate_deployment_allows_changed_schema_when_policy_allows() -> None:
def test_validate_deployment_accepts_reducer_capability() -> None: def test_validate_deployment_accepts_reducer_capability() -> None:
reducer = RequiredCapability( reducer = RequiredCapability(
logical_source="wf.std", ref="wf.std.set_union",
capability_name="set_union",
kind="reducer", kind="reducer",
) )
diagnostics = validate_deployment_dependencies( diagnostics = validate_deployment_dependencies(
+31
View File
@@ -1,5 +1,7 @@
from __future__ import annotations from __future__ import annotations
from pydantic import BaseModel
from wf_platform import CapabilityRef, SourceRef from wf_platform import CapabilityRef, SourceRef
@@ -26,6 +28,35 @@ def test_capability_ref_binds_logical_source_to_concrete_source() -> None:
assert str(bound) == "demo.personal.echo_tool" assert str(bound) == "demo.personal.echo_tool"
def test_platform_refs_validate_and_serialize_through_pydantic() -> None:
class Payload(BaseModel):
source: SourceRef
capability: CapabilityRef
payload = Payload.model_validate(
{
"source": "demo.personal",
"capability": "demo.personal.echo_tool",
}
)
assert payload.source == SourceRef.parse("demo.personal")
assert payload.capability == CapabilityRef.parse("demo.personal.echo_tool")
assert payload.model_dump(mode="json") == {
"source": "demo.personal",
"capability": "demo.personal.echo_tool",
}
def test_source_ref_rejects_whitespace_segments() -> None:
try:
SourceRef.parse("demo. .personal")
except ValueError as exc:
assert "non-empty path segments" in str(exc)
else:
raise AssertionError("expected whitespace source segment to fail")
def test_capability_ref_rejects_missing_capability_name() -> None: def test_capability_ref_rejects_missing_capability_name() -> None:
try: try:
CapabilityRef.parse("demo") CapabilityRef.parse("demo")
+20 -11
View File
@@ -204,7 +204,9 @@ def test_broker_validates_workflow_deployment_from_artifact_store() -> None:
id="summarize_docs.personal", id="summarize_docs.personal",
artifact_id="summarize_docs", artifact_id="summarize_docs",
artifact_version=1, artifact_version=1,
bindings={"context7": "context7.personal"}, bindings=[
{"logical_source": "context7", "concrete_source": "context7.personal"}
],
) )
) )
service = WfMcpService( service = WfMcpService(
@@ -290,7 +292,7 @@ def test_broker_creates_workflow_artifact_from_plan() -> None:
assert payload["saved"] is True assert payload["saved"] is True
assert loaded.input_schema["properties"]["text"]["type"] == "string" assert loaded.input_schema["properties"]["text"]["type"] == "string"
assert loaded.output_schema["properties"]["echoed"]["type"] == "string" assert loaded.output_schema["properties"]["echoed"]["type"] == "string"
assert loaded.required_capabilities["demo.echo_tool"].logical_source == "demo" assert loaded.required_capability_map()["demo.echo_tool"].logical_source == "demo"
def test_broker_saves_and_lists_workflow_deployments() -> None: def test_broker_saves_and_lists_workflow_deployments() -> None:
@@ -311,7 +313,12 @@ def test_broker_saves_and_lists_workflow_deployments() -> None:
id="summarize_docs.personal", id="summarize_docs.personal",
artifact_id="summarize_docs", artifact_id="summarize_docs",
artifact_version=1, artifact_version=1,
bindings={"context7": "context7.personal"}, bindings=[
{
"logical_source": "context7",
"concrete_source": "context7.personal",
}
],
).model_dump(mode="json") ).model_dump(mode="json")
}, },
) )
@@ -324,7 +331,9 @@ def test_broker_saves_and_lists_workflow_deployments() -> None:
assert save_payload["deployment_id"] == "summarize_docs.personal" assert save_payload["deployment_id"] == "summarize_docs.personal"
assert list_payload["deployments"][0]["id"] == "summarize_docs.personal" assert list_payload["deployments"][0]["id"] == "summarize_docs.personal"
assert list_payload["deployments"][0]["bindings"]["context7"] == "context7.personal" binding = list_payload["deployments"][0]["bindings"][0]
assert binding["logical_source"] == "context7"
assert binding["concrete_source"] == "context7.personal"
def test_broker_runs_non_interrupting_workflow_deployment() -> None: def test_broker_runs_non_interrupting_workflow_deployment() -> None:
@@ -337,7 +346,7 @@ def test_broker_runs_non_interrupting_workflow_deployment() -> None:
id="echo.personal", id="echo.personal",
artifact_id="echo", artifact_id="echo",
artifact_version=1, artifact_version=1,
bindings={"demo": "demo.personal"}, bindings=[{"logical_source": "demo", "concrete_source": "demo.personal"}],
) )
) )
service = WfMcpService( service = WfMcpService(
@@ -379,7 +388,9 @@ def test_broker_run_deployment_returns_unrunnable_for_dependency_errors() -> Non
id="summarize_docs.personal", id="summarize_docs.personal",
artifact_id="summarize_docs", artifact_id="summarize_docs",
artifact_version=1, artifact_version=1,
bindings={"context7": "context7.personal"}, bindings=[
{"logical_source": "context7", "concrete_source": "context7.personal"}
],
) )
) )
service = WfMcpService( service = WfMcpService(
@@ -414,7 +425,7 @@ def test_broker_run_deployment_rejects_interrupting_artifacts() -> None:
id="approval.personal", id="approval.personal",
artifact_id="approval", artifact_id="approval",
artifact_version=1, artifact_version=1,
bindings={}, bindings=[],
) )
) )
service = WfMcpService( service = WfMcpService(
@@ -464,8 +475,7 @@ def _artifact() -> WorkflowArtifact:
plan={"name": "summarize_docs", "nodes": [], "edges": []}, plan={"name": "summarize_docs", "nodes": [], "edges": []},
required_capabilities={ required_capabilities={
"context7.query-docs": RequiredCapability( "context7.query-docs": RequiredCapability(
logical_source="context7", ref="context7.query-docs",
capability_name="query-docs",
kind="tool", kind="tool",
input_schema_hash="sha256:input", input_schema_hash="sha256:input",
output_schema_hash="sha256:output", output_schema_hash="sha256:output",
@@ -518,8 +528,7 @@ def _echo_artifact() -> WorkflowArtifact:
}, },
required_capabilities={ required_capabilities={
"demo.echo_tool": RequiredCapability( "demo.echo_tool": RequiredCapability(
logical_source="demo", ref="demo.echo_tool",
capability_name="echo_tool",
kind="node_spec", kind="node_spec",
) )
}, },
+19 -19
View File
@@ -189,7 +189,9 @@ def test_workflow_surface_validates_deployment_dependencies() -> None:
id="summarize_docs.personal", id="summarize_docs.personal",
artifact_id="summarize_docs", artifact_id="summarize_docs",
artifact_version=1, artifact_version=1,
bindings={"context7": "context7.personal"}, bindings=[
{"logical_source": "context7", "concrete_source": "context7.personal"}
],
) )
) )
handlers = _handlers(artifact_store) handlers = _handlers(artifact_store)
@@ -215,7 +217,9 @@ def test_workflow_surface_records_artifact_and_deployment_save_events() -> None:
id="echo.personal", id="echo.personal",
artifact_id="echo", artifact_id="echo",
artifact_version=1, artifact_version=1,
bindings={"demo": "demo.personal"}, bindings=[
{"logical_source": "demo", "concrete_source": "demo.personal"}
],
).model_dump(mode="json") ).model_dump(mode="json")
) )
) )
@@ -274,7 +278,7 @@ def test_workflow_surface_creates_artifact_with_logical_node_refs() -> None:
artifact = artifact_store.get_artifact("echo_logical", 1) artifact = artifact_store.get_artifact("echo_logical", 1)
assert artifact.plan["nodes"][0]["node"] == "demo.echo_tool" assert artifact.plan["nodes"][0]["node"] == "demo.echo_tool"
assert artifact.required_capabilities["demo.echo_tool"].logical_source == "demo" assert artifact.required_capability_map()["demo.echo_tool"].logical_source == "demo"
def test_workflow_surface_validates_draft_without_saving() -> None: def test_workflow_surface_validates_draft_without_saving() -> None:
@@ -349,7 +353,7 @@ def test_workflow_surface_creates_artifact_from_draft_with_binding_suggestions()
assert payload["required_logical_sources"] == ["demo", "wf.std"] assert payload["required_logical_sources"] == ["demo", "wf.std"]
assert payload["suggested_bindings"]["wf.std"] == "wf.std" assert payload["suggested_bindings"]["wf.std"] == "wf.std"
assert artifact.plan["nodes"][0]["node"] == "demo.echo_tool" assert artifact.plan["nodes"][0]["node"] == "demo.echo_tool"
assert artifact.required_capabilities["demo.echo_tool"].logical_source == "demo" assert artifact.required_capability_map()["demo.echo_tool"].logical_source == "demo"
def test_workflow_surface_draft_artifact_requires_std_self_binding() -> None: def test_workflow_surface_draft_artifact_requires_std_self_binding() -> None:
@@ -373,7 +377,7 @@ def test_workflow_surface_draft_artifact_requires_std_self_binding() -> None:
id="draft_echo_missing_std.personal", id="draft_echo_missing_std.personal",
artifact_id="draft_echo_missing_std", artifact_id="draft_echo_missing_std",
artifact_version=1, artifact_version=1,
bindings={"demo": "demo.personal"}, bindings=[{"logical_source": "demo", "concrete_source": "demo.personal"}],
) )
) )
@@ -733,7 +737,7 @@ def test_workflow_surface_runs_non_interrupting_deployment() -> None:
id="echo.personal", id="echo.personal",
artifact_id="echo", artifact_id="echo",
artifact_version=1, artifact_version=1,
bindings={"demo": "demo.personal"}, bindings=[{"logical_source": "demo", "concrete_source": "demo.personal"}],
) )
) )
service = WfMcpService( service = WfMcpService(
@@ -766,7 +770,7 @@ def test_workflow_surface_runs_deployment_with_bound_node_spec_dependency() -> N
id="echo.personal", id="echo.personal",
artifact_id="logical_echo", artifact_id="logical_echo",
artifact_version=1, artifact_version=1,
bindings={"demo": "demo.personal"}, bindings=[{"logical_source": "demo", "concrete_source": "demo.personal"}],
) )
) )
service = WfMcpService( service = WfMcpService(
@@ -836,7 +840,7 @@ def test_workflow_surface_runs_artifact_created_from_concrete_node_ref() -> None
artifact = artifact_store.get_artifact("created_echo", 1) artifact = artifact_store.get_artifact("created_echo", 1)
assert artifact.plan["nodes"][0]["node"] == "demo.echo_tool" assert artifact.plan["nodes"][0]["node"] == "demo.echo_tool"
assert artifact.required_capabilities["demo.echo_tool"].logical_source == "demo" assert artifact.required_capability_map()["demo.echo_tool"].logical_source == "demo"
assert payload["status"] == "completed" assert payload["status"] == "completed"
assert payload["output"]["echoed"] == "hello" assert payload["output"]["echoed"] == "hello"
assert payload["diagnostics"] == [] assert payload["diagnostics"] == []
@@ -881,7 +885,7 @@ def test_workflow_surface_detects_drift_from_saved_node_spec_snapshot() -> None:
required = artifact_store.get_artifact( required = artifact_store.get_artifact(
"created_echo_drift", "created_echo_drift",
1, 1,
).required_capabilities["demo.echo_tool"] ).required_capability_map()["demo.echo_tool"]
assert required.input_schema_hash is not None assert required.input_schema_hash is not None
service.register_connection( service.register_connection(
@@ -1034,7 +1038,7 @@ def test_workflow_surface_calls_saved_wrapper_artifact_with_deployment_bindings(
id="logical_echo_wrapper.personal", id="logical_echo_wrapper.personal",
artifact_id="logical_echo_wrapper", artifact_id="logical_echo_wrapper",
artifact_version=1, artifact_version=1,
bindings={"demo": "demo.personal"}, bindings=[{"logical_source": "demo", "concrete_source": "demo.personal"}],
) )
) )
service = WfMcpService( service = WfMcpService(
@@ -1084,8 +1088,7 @@ def _artifact() -> WorkflowArtifact:
plan={"name": "summarize_docs", "nodes": [], "edges": []}, plan={"name": "summarize_docs", "nodes": [], "edges": []},
required_capabilities={ required_capabilities={
"context7.query-docs": RequiredCapability( "context7.query-docs": RequiredCapability(
logical_source="context7", ref="context7.query-docs",
capability_name="query-docs",
kind="tool", kind="tool",
input_schema_hash="sha256:input", input_schema_hash="sha256:input",
output_schema_hash="sha256:output", output_schema_hash="sha256:output",
@@ -1130,8 +1133,7 @@ def _echo_artifact() -> WorkflowArtifact:
plan=plan, plan=plan,
required_capabilities={ required_capabilities={
"demo.echo_tool": RequiredCapability( "demo.echo_tool": RequiredCapability(
logical_source="demo", ref="demo.echo_tool",
capability_name="echo_tool",
kind="node_spec", kind="node_spec",
) )
}, },
@@ -1196,7 +1198,7 @@ def _custom_reducer_artifact() -> WorkflowArtifact:
"type": "integer", "type": "integer",
"reducer": "custom.multiply", "reducer": "custom.multiply",
} }
} },
}, },
"output_schema": { "output_schema": {
"type": "object", "type": "object",
@@ -1225,13 +1227,11 @@ def _custom_reducer_artifact() -> WorkflowArtifact:
plan=plan, plan=plan,
required_capabilities={ required_capabilities={
"demo.amount_tool": RequiredCapability( "demo.amount_tool": RequiredCapability(
logical_source="demo", ref="demo.amount_tool",
capability_name="amount_tool",
kind="node_spec", kind="node_spec",
), ),
"custom.multiply": RequiredCapability( "custom.multiply": RequiredCapability(
logical_source="custom", ref="custom.multiply",
capability_name="multiply",
kind="reducer", kind="reducer",
), ),
}, },