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
required_capabilities:
context7.query-docs:
- ref: context7.query-docs
kind: tool
input_schema_hash
input_schema_snapshot
output_schema_hash
output_schema_snapshot
input_schema_hash: ...
input_schema_snapshot: ...
output_schema_hash: ...
output_schema_snapshot: ...
```
This is similar to import resolution. The artifact stores stable logical
@@ -286,8 +286,7 @@ actually use:
```text
RequiredCapability
logical_source: context7
capability_name: query-docs
ref: context7.query-docs
kind: tool
input_schema
output_schema
@@ -446,8 +445,7 @@ and the artifact should declare a matching required capability:
```text
RequiredCapability(
logical_source="demo",
capability_name="echo_tool",
ref="demo.echo_tool",
kind="node_spec"
)
```
@@ -456,7 +454,8 @@ The deployment then chooses the concrete account or connection profile:
```text
bindings:
demo: demo.personal
- logical_source: demo
concrete_source: demo.personal
```
This keeps reusable artifacts portable across accounts while still letting
@@ -494,7 +493,8 @@ WorkflowDeployment
artifact_version: 1
deployment_id: summarize_docs.context7_default
bindings:
context7: context7.default
- logical_source: context7
concrete_source: context7.default
drift_policy: block
```
@@ -510,12 +510,14 @@ logical sources to different MCP accounts or connection profiles:
deployment: summarize_docs.personal
artifact: summarize_docs@1
bindings:
context7: context7.personal
- logical_source: context7
concrete_source: context7.personal
deployment: summarize_docs.work
artifact: summarize_docs@1
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
@@ -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
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:
`WorkflowCapabilityRef(artifact_id, version)` serializes as
`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",
artifact_id="mcp_echo",
artifact_version=1,
bindings={
"demo": "demo.personal",
"wf.std": "wf.std",
},
bindings=[
{"logical_source": "demo", "concrete_source": "demo.personal"},
{"logical_source": "wf.std", "concrete_source": "wf.std"},
],
)
)
return await handlers.run_deployment(
+2
View File
@@ -28,6 +28,7 @@ from .models import (
DiagnosticSeverity,
DriftPolicy,
RequiredCapability,
SourceBinding,
WorkflowArtifact,
WorkflowDeployment,
)
@@ -48,6 +49,7 @@ __all__ = [
"FileDraftWorkspaceStore",
"FileWorkflowArtifactStore",
"RequiredCapability",
"SourceBinding",
"WorkflowArtifact",
"WorkflowArtifactCatalogEntry",
"WorkflowCapabilityRef",
+1 -1
View File
@@ -41,7 +41,7 @@ def artifact_catalog_entry(
required_sources = sorted(
{
capability.logical_source
for capability in artifact.required_capabilities.values()
for capability in artifact.required_capability_map().values()
}
)
return WorkflowArtifactCatalogEntry(
+7 -7
View File
@@ -30,6 +30,11 @@ def create_workflow_artifact_from_plan(
observed_node_specs,
)
_validate_workflow_plan(normalized_plan)
required = {
**_required_reducers_from_plan(normalized_plan),
**node_requirements,
**dict(required_capabilities or {}),
}
return WorkflowArtifact(
id=artifact_id,
version=version,
@@ -40,11 +45,7 @@ def create_workflow_artifact_from_plan(
output_schema=_required_object_field(normalized_plan, "output_schema"),
outcomes=outcomes,
plan=normalized_plan,
required_capabilities={
**_required_reducers_from_plan(normalized_plan),
**node_requirements,
**dict(required_capabilities or {}),
},
required_capabilities=list(required.values()),
created_from_catalog_version=created_from_catalog_version,
)
@@ -103,8 +104,7 @@ def _required_reducers_from_plan(plan: JsonObject) -> dict[str, RequiredCapabili
except ValueError:
continue
requirements[reducer.name] = RequiredCapability(
logical_source=str(reducer_ref.source),
capability_name=reducer_ref.name,
ref=reducer_ref,
kind="reducer",
)
return requirements
+171 -6
View File
@@ -3,10 +3,14 @@ from __future__ import annotations
from enum import StrEnum
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]
ArtifactKind = Literal["workflow", "wrapper"]
SourceRefInput = SourceRef | str
CapabilityRefInput = CapabilityRef | str
class DriftPolicy(StrEnum):
@@ -27,16 +31,46 @@ class DiagnosticSeverity(StrEnum):
class RequiredCapability(BaseModel):
"""Saved contract for one capability an artifact references."""
logical_source: str
capability_name: str
ref: CapabilityRefInput
kind: Literal["tool", "resource", "prompt", "node_spec", "reducer", "workflow"]
input_schema_hash: str | None = None
input_schema_snapshot: JsonObject | None = None
output_schema_hash: str | 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)
@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):
"""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)
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):
"""Machine-readable reason a deployment is degraded or unrunnable."""
@@ -78,10 +135,66 @@ class WorkflowArtifact(BaseModel):
output_schema: JsonObject
outcomes: tuple[str, ...]
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)
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):
"""One configured way to run an artifact version in an environment."""
@@ -89,5 +202,57 @@ class WorkflowDeployment(BaseModel):
id: str
artifact_id: str
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
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
)
requirements[logical_ref] = RequiredCapability(
logical_source=logical_source,
capability_name=capability_name,
ref=CapabilityRef.parse(logical_ref),
kind="node_spec",
input_schema_hash=(
hash_json_schema(observed.input_schema)
@@ -65,7 +64,7 @@ def normalize_plan_node_refs(
output_schema_snapshot=(
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
+3 -2
View File
@@ -19,10 +19,11 @@ def validate_deployment_dependencies(
) -> list[DependencyDiagnostic]:
"""Validate that a deployment can satisfy an artifact's required contracts."""
sources_by_id = {source.id: source for source in sources}
bindings = deployment.binding_map()
diagnostics: list[DependencyDiagnostic] = []
for logical_ref, required in artifact.required_capabilities.items():
bound_source_id = deployment.bindings.get(required.logical_source)
for logical_ref, required in artifact.required_capability_map().items():
bound_source_id = bindings.get(required.logical_source)
if bound_source_id is None:
diagnostics.append(
_diagnostic(
+6 -8
View File
@@ -234,10 +234,9 @@ class WorkflowSurfaceHandlers:
"is_async": True,
"input_schema": artifact.input_schema,
"output_schema": artifact.output_schema,
"required_capabilities": {
name: capability.model_dump(mode="json")
for name, capability in sorted(artifact.required_capabilities.items())
},
"required_capabilities": _required_capability_payloads(
artifact.required_capability_map()
),
}
async def _call_wrapper_artifact(
@@ -428,7 +427,7 @@ class WorkflowSurfaceHandlers:
required_sources = sorted(
{
capability.logical_source
for capability in dict(workflow_artifact.required_capabilities).values()
for capability in workflow_artifact.required_capability_map().values()
}
)
return {
@@ -892,7 +891,7 @@ def _required_capabilities_for_plan(
source_bindings=source_bindings,
observed_node_specs=_observed_node_specs(service),
)
requirements = dict(artifact.required_capabilities)
requirements = artifact.required_capability_map()
for node in _plan_nodes(artifact):
raw_ref = node.get("node")
if not isinstance(raw_ref, str) or raw_ref in requirements:
@@ -902,8 +901,7 @@ def _required_capabilities_for_plan(
except ValueError:
continue
requirements[raw_ref] = RequiredCapability(
logical_source=str(parsed.source),
capability_name=parsed.name,
ref=parsed,
kind="node_spec",
)
return requirements
@@ -44,7 +44,7 @@ def resolve_runtime_dependencies(
node_name_bindings[node_name] = concrete_name
node_specs[concrete_name] = spec
reducers = _resolve_reducers(
required_capabilities=artifact.required_capabilities,
required_capabilities=artifact.required_capability_map(),
deployment=deployment,
sources=sources,
)
@@ -68,7 +68,7 @@ def _resolve_node_spec(
if deployment is not None:
try:
bound_ref = CapabilityRef.parse(node_name).bind(deployment.bindings)
bound_ref = CapabilityRef.parse(node_name).bind(deployment.binding_map())
except ValueError:
bound_ref = None
if bound_ref is not None:
@@ -104,7 +104,7 @@ def _resolve_reducers(
for logical_ref, required in required_capabilities.items():
if required.kind != "reducer":
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:
continue
source = sources.get(bound_source_id)
+50 -1
View File
@@ -2,6 +2,9 @@ from __future__ import annotations
from collections.abc import Mapping
from dataclasses import dataclass
from typing import Any
from pydantic_core import core_schema
@dataclass(frozen=True, slots=True)
@@ -11,7 +14,7 @@ class SourceRef:
parts: tuple[str, ...]
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")
@classmethod
@@ -22,6 +25,29 @@ class SourceRef:
def __str__(self) -> str:
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)
class CapabilityRef:
@@ -51,3 +77,26 @@ class CapabilityRef:
def __str__(self) -> str:
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"),
plan={"name": "summarize_docs", "nodes": [{"id": "hidden"}]},
required_capabilities={
"context7.query-docs": RequiredCapability(
logical_source="context7",
capability_name="query-docs",
required_capabilities=[
RequiredCapability(
ref="context7.query-docs",
kind="tool",
)
},
],
)
+8 -10
View File
@@ -16,8 +16,7 @@ def test_create_workflow_artifact_from_plan_derives_boundary_schemas() -> None:
outcomes=("done",),
required_capabilities={
"demo.echo_tool": RequiredCapability(
logical_source="demo",
capability_name="echo_tool",
ref="demo.echo_tool",
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.outcomes == ("done",)
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"
@@ -51,7 +50,7 @@ def test_create_workflow_artifact_from_plan_adds_reducer_dependencies() -> None:
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.capability_name == "max"
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]
required = artifact.required_capabilities["demo.echo_tool"]
required = artifact.required_capability_map()["demo.echo_tool"]
assert node["node"] == "demo.echo_tool"
assert required.logical_source == "demo"
assert required.capability_name == "echo_tool"
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:
@@ -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 == {
"type": "object",
"properties": {"text": {"type": "string"}},
@@ -136,15 +135,14 @@ def test_create_workflow_artifact_from_plan_keeps_explicit_capability_metadata()
source_bindings={"demo": "demo.personal"},
required_capabilities={
"demo.echo_tool": RequiredCapability(
logical_source="demo",
capability_name="echo_tool",
ref="demo.echo_tool",
kind="node_spec",
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"
+11 -7
View File
@@ -12,8 +12,7 @@ from wf_artifacts import (
def test_workflow_artifact_serializes_required_capability_contract() -> None:
capability = RequiredCapability(
logical_source="context7",
capability_name="query-docs",
ref="context7.query-docs",
kind="tool",
input_schema_hash="sha256:input",
input_schema_snapshot={"type": "object", "properties": {}},
@@ -31,7 +30,7 @@ def test_workflow_artifact_serializes_required_capability_contract() -> None:
output_schema={"type": "object", "properties": {}},
outcomes=("done", "failed"),
plan={"name": "summarize_docs", "nodes": [], "edges": []},
required_capabilities={"context7.query-docs": capability},
required_capabilities=[capability],
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["version"] == 1
assert dumped["outcomes"] == ["done", "failed"]
required = dumped["required_capabilities"]["context7.query-docs"]
assert required["logical_source"] == "context7"
required = dumped["required_capabilities"][0]
assert required["ref"] == "context7.query-docs"
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:
@@ -68,7 +68,9 @@ def test_workflow_deployment_binds_logical_sources_to_concrete_sources() -> None
id="summarize_docs.personal",
artifact_id="summarize_docs",
artifact_version=1,
bindings={"context7": "context7.personal"},
bindings=[
{"logical_source": "context7", "concrete_source": "context7.personal"}
],
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["artifact_id"] == "summarize_docs"
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 deployment.binding_map()["context7"] == "context7.personal"
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",
artifact_id="summarize_docs",
artifact_version=1,
bindings={"context7": "context7.personal"},
bindings=[
{"logical_source": "context7", "concrete_source": "context7.personal"}
],
)
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.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:
@@ -68,7 +70,9 @@ def test_file_store_lists_deployments_in_id_order(tmp_path) -> None:
id="summarize_docs.work",
artifact_id="summarize_docs",
artifact_version=1,
bindings={"context7": "context7.work"},
bindings=[
{"logical_source": "context7", "concrete_source": "context7.work"}
],
)
)
store.save_deployment(
@@ -76,7 +80,9 @@ def test_file_store_lists_deployments_in_id_order(tmp_path) -> None:
id="summarize_docs.personal",
artifact_id="summarize_docs",
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.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",
) -> RequiredCapability:
return RequiredCapability(
logical_source=logical_source,
capability_name=capability_name,
ref=f"{logical_source}.{capability_name}",
kind="tool",
input_schema_hash=input_hash,
input_schema_snapshot={"type": "object", "properties": {}},
@@ -38,9 +37,7 @@ def artifact_with(capability: RequiredCapability) -> WorkflowArtifact:
output_schema={"type": "object", "properties": {}},
outcomes=("done",),
plan={"name": "summarize_docs", "nodes": [], "edges": []},
required_capabilities={
f"{capability.logical_source}.{capability.capability_name}": capability
},
required_capabilities=[capability],
)
@@ -53,7 +50,14 @@ def deployment(
id="summarize_docs.personal",
artifact_id="summarize_docs",
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,
)
@@ -180,8 +184,7 @@ def test_validate_deployment_allows_changed_schema_when_policy_allows() -> None:
def test_validate_deployment_accepts_reducer_capability() -> None:
reducer = RequiredCapability(
logical_source="wf.std",
capability_name="set_union",
ref="wf.std.set_union",
kind="reducer",
)
diagnostics = validate_deployment_dependencies(
+31
View File
@@ -1,5 +1,7 @@
from __future__ import annotations
from pydantic import BaseModel
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"
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:
try:
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",
artifact_id="summarize_docs",
artifact_version=1,
bindings={"context7": "context7.personal"},
bindings=[
{"logical_source": "context7", "concrete_source": "context7.personal"}
],
)
)
service = WfMcpService(
@@ -290,7 +292,7 @@ def test_broker_creates_workflow_artifact_from_plan() -> None:
assert payload["saved"] is True
assert loaded.input_schema["properties"]["text"]["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:
@@ -311,7 +313,12 @@ def test_broker_saves_and_lists_workflow_deployments() -> None:
id="summarize_docs.personal",
artifact_id="summarize_docs",
artifact_version=1,
bindings={"context7": "context7.personal"},
bindings=[
{
"logical_source": "context7",
"concrete_source": "context7.personal",
}
],
).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 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:
@@ -337,7 +346,7 @@ def test_broker_runs_non_interrupting_workflow_deployment() -> None:
id="echo.personal",
artifact_id="echo",
artifact_version=1,
bindings={"demo": "demo.personal"},
bindings=[{"logical_source": "demo", "concrete_source": "demo.personal"}],
)
)
service = WfMcpService(
@@ -379,7 +388,9 @@ def test_broker_run_deployment_returns_unrunnable_for_dependency_errors() -> Non
id="summarize_docs.personal",
artifact_id="summarize_docs",
artifact_version=1,
bindings={"context7": "context7.personal"},
bindings=[
{"logical_source": "context7", "concrete_source": "context7.personal"}
],
)
)
service = WfMcpService(
@@ -414,7 +425,7 @@ def test_broker_run_deployment_rejects_interrupting_artifacts() -> None:
id="approval.personal",
artifact_id="approval",
artifact_version=1,
bindings={},
bindings=[],
)
)
service = WfMcpService(
@@ -464,8 +475,7 @@ def _artifact() -> WorkflowArtifact:
plan={"name": "summarize_docs", "nodes": [], "edges": []},
required_capabilities={
"context7.query-docs": RequiredCapability(
logical_source="context7",
capability_name="query-docs",
ref="context7.query-docs",
kind="tool",
input_schema_hash="sha256:input",
output_schema_hash="sha256:output",
@@ -518,8 +528,7 @@ def _echo_artifact() -> WorkflowArtifact:
},
required_capabilities={
"demo.echo_tool": RequiredCapability(
logical_source="demo",
capability_name="echo_tool",
ref="demo.echo_tool",
kind="node_spec",
)
},
+19 -19
View File
@@ -189,7 +189,9 @@ def test_workflow_surface_validates_deployment_dependencies() -> None:
id="summarize_docs.personal",
artifact_id="summarize_docs",
artifact_version=1,
bindings={"context7": "context7.personal"},
bindings=[
{"logical_source": "context7", "concrete_source": "context7.personal"}
],
)
)
handlers = _handlers(artifact_store)
@@ -215,7 +217,9 @@ def test_workflow_surface_records_artifact_and_deployment_save_events() -> None:
id="echo.personal",
artifact_id="echo",
artifact_version=1,
bindings={"demo": "demo.personal"},
bindings=[
{"logical_source": "demo", "concrete_source": "demo.personal"}
],
).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)
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:
@@ -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["suggested_bindings"]["wf.std"] == "wf.std"
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:
@@ -373,7 +377,7 @@ def test_workflow_surface_draft_artifact_requires_std_self_binding() -> None:
id="draft_echo_missing_std.personal",
artifact_id="draft_echo_missing_std",
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",
artifact_id="echo",
artifact_version=1,
bindings={"demo": "demo.personal"},
bindings=[{"logical_source": "demo", "concrete_source": "demo.personal"}],
)
)
service = WfMcpService(
@@ -766,7 +770,7 @@ def test_workflow_surface_runs_deployment_with_bound_node_spec_dependency() -> N
id="echo.personal",
artifact_id="logical_echo",
artifact_version=1,
bindings={"demo": "demo.personal"},
bindings=[{"logical_source": "demo", "concrete_source": "demo.personal"}],
)
)
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)
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["output"]["echoed"] == "hello"
assert payload["diagnostics"] == []
@@ -881,7 +885,7 @@ def test_workflow_surface_detects_drift_from_saved_node_spec_snapshot() -> None:
required = artifact_store.get_artifact(
"created_echo_drift",
1,
).required_capabilities["demo.echo_tool"]
).required_capability_map()["demo.echo_tool"]
assert required.input_schema_hash is not None
service.register_connection(
@@ -1034,7 +1038,7 @@ def test_workflow_surface_calls_saved_wrapper_artifact_with_deployment_bindings(
id="logical_echo_wrapper.personal",
artifact_id="logical_echo_wrapper",
artifact_version=1,
bindings={"demo": "demo.personal"},
bindings=[{"logical_source": "demo", "concrete_source": "demo.personal"}],
)
)
service = WfMcpService(
@@ -1084,8 +1088,7 @@ def _artifact() -> WorkflowArtifact:
plan={"name": "summarize_docs", "nodes": [], "edges": []},
required_capabilities={
"context7.query-docs": RequiredCapability(
logical_source="context7",
capability_name="query-docs",
ref="context7.query-docs",
kind="tool",
input_schema_hash="sha256:input",
output_schema_hash="sha256:output",
@@ -1130,8 +1133,7 @@ def _echo_artifact() -> WorkflowArtifact:
plan=plan,
required_capabilities={
"demo.echo_tool": RequiredCapability(
logical_source="demo",
capability_name="echo_tool",
ref="demo.echo_tool",
kind="node_spec",
)
},
@@ -1196,7 +1198,7 @@ def _custom_reducer_artifact() -> WorkflowArtifact:
"type": "integer",
"reducer": "custom.multiply",
}
}
},
},
"output_schema": {
"type": "object",
@@ -1225,13 +1227,11 @@ def _custom_reducer_artifact() -> WorkflowArtifact:
plan=plan,
required_capabilities={
"demo.amount_tool": RequiredCapability(
logical_source="demo",
capability_name="amount_tool",
ref="demo.amount_tool",
kind="node_spec",
),
"custom.multiply": RequiredCapability(
logical_source="custom",
capability_name="multiply",
ref="custom.multiply",
kind="reducer",
),
},