more map ->list moves: WorkflowDeployment / WorkflowArtifact
This commit is contained in:
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
]
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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
@@ -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")
|
||||||
|
|||||||
@@ -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",
|
||||||
)
|
)
|
||||||
},
|
],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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",
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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",
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user