paths throughout to serialize as object.

all for clarity! ahh
This commit is contained in:
lda
2026-05-21 02:02:04 +07:00 Verified
parent 2fcff9936f
commit e568db3169
19 changed files with 1003 additions and 232 deletions
+106
View File
@@ -0,0 +1,106 @@
# Structural Refs
Qualified names are display strings. They are not authoritative identifiers.
The platform still accepts old dotted strings at MCP/API boundaries when it can
parse them, but saved workflow artifacts and deployments should store structural
refs from now on. A dotted string may be shown to humans and LLM clients, but
runtime code should not infer source boundaries from it.
## Why
These strings look similar but mean different things:
```text
context7.default.query-docs
workflow.echo_wrapper.v1
demo.foo.bar
```
`context7.default.query-docs` usually means a concrete external source and a
capability key. `workflow.echo_wrapper.v1` means a saved workflow artifact and
artifact version. `demo.foo.bar` is ambiguous: it could be source `demo` with
capability key `foo.bar`, or source `demo.foo` with capability key `bar`.
No first-dot, last-dot, or regex parser can recover a boundary that was not
stored.
## Capability Refs
Use a source plus a local capability key:
```json
{
"source": "demo",
"capability_key": "foo.bar"
}
```
The `capability_key` is local to the known source. It may contain dots. Those
dots do not carry source meaning.
Old input like `"demo.foo.bar"` may still parse at compatibility boundaries, but
that parse is best-effort and should not be used for new saves.
## Workflow Artifact Refs
Saved workflow and wrapper capabilities are a separate domain:
```json
{
"artifact_id": "echo_wrapper",
"version": 1
}
```
The display string `workflow.echo_wrapper.v1` remains useful for lists,
inspection, and old callers, but it is not the canonical saved shape.
## Deployment Bindings
Deployment bindings map an artifact-local logical source to a concrete source:
```json
{
"logical_source": "demo",
"concrete_source": "demo.personal"
}
```
Neither field is a capability name. Runtime code uses this source mapping before
looking up the capability key.
## Graph Paths
Capability refs and graph paths are different domains.
Path strings such as `input.text`, `state.person.name`, and `output.echoed`
describe graph data movement. Do not reuse capability-ref parsing rules for
graph paths.
New canonical graph path JSON uses root/parts objects:
```json
{
"root": "input",
"parts": ["message"]
}
```
```json
{
"root": "state",
"parts": ["person.name", "three and four"]
}
```
```json
{
"root": "local",
"parts": []
}
```
Old strings are accepted at parse boundaries for compatibility. Structural
`parts` are literal field names, so a part may contain dots or spaces without
being split again.
@@ -0,0 +1,220 @@
# Structural Graph Paths 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:** Save canonical graph paths as structural objects while keeping old dotted strings as parse-only compatibility input.
**Architecture:** The core already has first-class path types: `GraphSourcePath`, `StatePath`, and `LocalPath`. Update those Pydantic hooks to accept structural dict input and serialize structurally in JSON mode. Keep `str(path)` for display and legacy fields. Do not redesign `NodeUse.input` / `output`; those structs already replaced deprecated `in_map` / `out_map`.
**Tech Stack:** Python 3.14, Pydantic core schema hooks, `wf_core.paths`, `wf_core.models.steps`, pytest.
---
## Current State
Canonical node bindings already exist:
```json
{
"input": [
{"path": "input.message", "target": "message"}
],
"output": [
{"source": "echoed", "target": "state.echoed"}
]
}
```
Internally these parse to:
- `GraphSourcePath`
- `LocalPath`
- `StatePath`
The remaining problem is serialization. These path objects currently dump as strings, so saved JSON still relies on dot-separated path grammar.
## Canonical Shape
Graph source paths:
```json
{"root": "state", "parts": ["person", "name"]}
```
State write paths:
```json
{"root": "state", "parts": ["person", "name"]}
```
Local node paths:
```json
{"root": "local", "parts": ["payload", "text"]}
```
Local root remains explicit:
```json
{"root": "local", "parts": []}
```
Old strings such as `"state.person.name"` and `"."` remain accepted input.
---
## Task 1: Add Structural Serialization for Path Types
**Files:**
- Modify: `src/wf_core/paths.py`
- Test: `tests/core/test_path_values.py`
- [ ] **Step 1: Update tests first**
Change `test_pydantic_accepts_path_strings_and_serializes_strings` into structural JSON expectations:
```python
dumped = payload.model_dump(mode="json")
assert dumped["source"] == {"root": "input", "parts": ["user"]}
assert dumped["target"] == {"root": "state", "parts": ["person"]}
assert dumped["local"] == {"root": "local", "parts": ["user"]}
```
Keep `model_dump()` expectations if useful for Python-mode compatibility only if the implementation intentionally keeps Python mode as strings. Otherwise assert structural dumps in both modes.
- [ ] **Step 2: Add structural input tests**
Add a test:
```python
payload = Payload.model_validate({
"source": {"root": "input", "parts": ["user.name"]},
"target": {"root": "state", "parts": ["person.name"]},
"local": {"root": "local", "parts": ["payload.text"]},
})
assert payload.source == GraphSourcePath.input("user.name")
assert payload.target == StatePath.of("person.name")
assert payload.local == LocalPath.of("payload.text")
```
This documents that structural `parts` are literal field names. Old string inputs still split on dots for compatibility, but structural parts such as `"user.name"` are not split again.
- [ ] **Step 3: Implement path serializers**
In `src/wf_core/paths.py`, update each path type:
- `LocalPath` accepts string, object instance, and dict `{"root": "local", "parts": list[str]}`
- `GraphSourcePath` accepts string, object instance, and dict `{"root": "input"|"state"|"context", "parts": list[str]}`
- `StatePath` accepts string, object instance, and dict `{"root": "state", "parts": list[str]}`
Serialize as dicts in JSON mode:
```python
{"root": "local", "parts": list(value.parts)}
{"root": value.root, "parts": list(value.parts)}
{"root": "state", "parts": list(value.parts)}
```
- [ ] **Step 4: Run focused path tests**
Run:
```bash
uv run --with pytest pytest tests/core/test_path_values.py -q
```
Expected: all tests pass.
---
## Task 2: Update Canonical Node Binding Dumps
**Files:**
- Test: `tests/core/test_canonical_node_bindings.py`
- Test: `tests/authoring/test_builder.py`
- [ ] **Step 1: Update canonical node dump expectations**
In `tests/core/test_canonical_node_bindings.py`, update JSON-mode expectations:
```python
assert dumped["input"][1]["path"] == {"root": "input", "parts": ["message"]}
assert dumped["input"][1]["target"] == {"root": "local", "parts": ["message"]}
assert dumped["output"][0]["source"] == {"root": "local", "parts": ["echoed"]}
assert dumped["output"][0]["target"] == {"root": "state", "parts": ["echoed"]}
```
Deprecated `in_map` / `out_map` inputs should continue parsing, but dumps must omit those old fields and emit structural paths.
- [ ] **Step 2: Update authoring serialization expectations**
In `tests/authoring/test_builder.py`, update any `model_dump(mode="json")` expectations that currently assert path strings.
- [ ] **Step 3: Run focused binding/authoring tests**
Run:
```bash
uv run --with pytest pytest tests/core/test_canonical_node_bindings.py tests/authoring/test_builder.py -q
```
Expected: all tests pass.
---
## Task 3: Update Docs
**Files:**
- Modify: `docs/structural_refs.md`
- Modify: any path/core docs if directly relevant.
- [ ] **Step 1: Add graph path note**
Extend the path note in `docs/structural_refs.md`:
```text
New canonical graph path JSON uses root/parts objects. Old strings are accepted
at parse boundaries for compatibility.
```
- [ ] **Step 2: Add examples**
Include examples:
```json
{"root": "input", "parts": ["message"]}
{"root": "state", "parts": ["echoed"]}
{"root": "local", "parts": []}
```
---
## Task 4: Verification
- [ ] **Step 1: Run focused tests**
```bash
uv run --with pytest pytest tests/core/test_path_values.py tests/core/test_canonical_node_bindings.py tests/authoring/test_builder.py -q
```
- [ ] **Step 2: Run full tests**
```bash
uv run --with pytest pytest -q
```
- [ ] **Step 3: Run checks**
```bash
uvx ruff check src/wf_core/paths.py tests/core/test_path_values.py tests/core/test_canonical_node_bindings.py tests/authoring/test_builder.py
uv run basedpyright --level error src/wf_core/paths.py tests/core/test_path_values.py tests/core/test_canonical_node_bindings.py tests/authoring/test_builder.py
```
---
## Self-Review Notes
- This plan does not revive `in_map` / `out_map`; those remain deprecated parse-only fields.
- This plan relaxes path segment validation. Structural `parts` preserve literal field names, including dots and spaces. Old dotted string inputs still split on dots for compatibility.
- This plan changes saved JSON shape for canonical path fields, so broad tests are required.
+5
View File
@@ -6,6 +6,11 @@ things a workflow should usually consume.
For the short operator/client workflow using these concepts, see For the short operator/client workflow using these concepts, see
[`wf_mcp_operator_manual.md`](wf_mcp_operator_manual.md). [`wf_mcp_operator_manual.md`](wf_mcp_operator_manual.md).
For the identifier rule behind source/capability names, see
[`structural_refs.md`](structural_refs.md). In short: dotted qualified names
are display strings and compatibility input only; saved refs should be
structural.
The short version: The short version:
```text ```text
+18 -3
View File
@@ -29,7 +29,12 @@ 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.
`ref` is canonical structure. Old dotted strings are accepted as
compatibility input, but new saves should preserve the source/capability
boundary because capability keys may contain dots.
"""
ref: CapabilityRefInput ref: CapabilityRefInput
kind: Literal["tool", "resource", "prompt", "node_spec", "reducer", "workflow"] kind: Literal["tool", "resource", "prompt", "node_spec", "reducer", "workflow"]
@@ -68,7 +73,12 @@ class RequiredCapability(BaseModel):
logical_source = data.pop("logical_source", None) logical_source = data.pop("logical_source", None)
capability_name = data.pop("capability_name", None) capability_name = data.pop("capability_name", None)
if isinstance(logical_source, str) and isinstance(capability_name, str): if isinstance(logical_source, str) and isinstance(capability_name, str):
data["ref"] = f"{logical_source}.{capability_name}" # Preserve the source/capability boundary; capability names may
# contain dots, so joining then reparsing would corrupt the ref.
data["ref"] = {
"source": logical_source,
"capability_key": capability_name,
}
return data return data
@@ -90,7 +100,12 @@ class AvailableSource(BaseModel):
class SourceBinding(BaseModel): class SourceBinding(BaseModel):
"""Deployment-time mapping from artifact logical source to concrete source.""" """Deployment-time mapping from artifact logical source to concrete source.
`logical_source` is the artifact-local alias used by saved refs.
`concrete_source` is the deployment-selected source id. Neither field is a
capability name.
"""
logical_source: SourceRefInput logical_source: SourceRefInput
concrete_source: SourceRefInput concrete_source: SourceRefInput
+39
View File
@@ -1,6 +1,9 @@
from __future__ import annotations from __future__ import annotations
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)
@@ -31,3 +34,39 @@ class WorkflowCapabilityRef:
def __str__(self) -> str: def __str__(self) -> str:
return f"workflow.{self.artifact_id}.v{self.version}" return f"workflow.{self.artifact_id}.v{self.version}"
@classmethod
def __get_pydantic_core_schema__(
cls,
_source_type: object,
_handler: object,
) -> core_schema.CoreSchema:
"""Validate legacy display strings but save workflow refs structurally."""
return core_schema.no_info_plain_validator_function(
cls._validate,
serialization=core_schema.plain_serializer_function_ser_schema(
cls._serialize,
when_used="json",
),
)
@classmethod
def _validate(cls, value: Any) -> WorkflowCapabilityRef:
if isinstance(value, WorkflowCapabilityRef):
return value
if isinstance(value, str):
return cls.parse(value)
if isinstance(value, dict):
artifact_id = value.get("artifact_id")
version = value.get("version")
if isinstance(artifact_id, str) and isinstance(version, int):
return cls(artifact_id=artifact_id, version=version)
raise TypeError(
"workflow capability ref must be a workflow.<artifact>.v<version> "
"string or {'artifact_id': str, 'version': int}"
)
@staticmethod
def _serialize(value: WorkflowCapabilityRef) -> dict[str, int | str]:
"""Serialize canonical saved workflow refs without a display-name parser."""
return {"artifact_id": value.artifact_id, "version": value.version}
+91 -18
View File
@@ -1,8 +1,7 @@
from __future__ import annotations from __future__ import annotations
from dataclasses import dataclass
import re
from collections.abc import Mapping, MutableMapping from collections.abc import Mapping, MutableMapping
from dataclasses import dataclass
from typing import Any, ClassVar, Literal from typing import Any, ClassVar, Literal
from pydantic_core import core_schema from pydantic_core import core_schema
@@ -12,12 +11,11 @@ class PathResolutionError(ValueError):
pass pass
SEGMENT_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
GraphRoot = Literal["input", "state", "context"] GraphRoot = Literal["input", "state", "context"]
def _validate_segment(segment: str, *, path_kind: str) -> str: def _validate_segment(segment: str, *, path_kind: str) -> str:
if not SEGMENT_RE.fullmatch(segment): if not segment or not segment.strip():
raise PathResolutionError(f"invalid {path_kind} segment {segment!r}") raise PathResolutionError(f"invalid {path_kind} segment {segment!r}")
return segment return segment
@@ -36,8 +34,52 @@ def _parse_fragments(*fragments: str, path_kind: str) -> tuple[str, ...]:
return tuple(parts) return tuple(parts)
def _json_schema(pattern: str, description: str) -> dict[str, Any]: def _path_json_schema(root: str | list[str], description: str) -> dict[str, Any]:
return {"type": "string", "pattern": pattern, "description": description} """Return the canonical structural schema for saved path fields."""
root_schema: dict[str, Any]
if isinstance(root, str):
root_schema = {"const": root}
else:
root_schema = {"enum": root}
return {
"type": "object",
"description": description,
"properties": {
"root": root_schema,
"parts": {
"type": "array",
"items": {"type": "string", "minLength": 1},
},
},
"required": ["root", "parts"],
"additionalProperties": False,
}
def _structural_parts(
value: Mapping[object, object], *, path_kind: str
) -> tuple[str, ...]:
"""Validate literal path segments from canonical structural JSON.
Old string paths keep dotted parsing for compatibility. Structural `parts`
are different: each item is already one field name and may contain dots or
spaces, so this helper validates only that segments are non-empty strings.
"""
raw_parts = value.get("parts", [])
if not isinstance(raw_parts, list):
raise PathResolutionError(f"{path_kind} path parts must be a list")
return tuple(
_validate_segment(part, path_kind=path_kind)
for part in raw_parts
if isinstance(part, str)
)
def _reject_non_string_parts(value: Mapping[object, object], *, path_kind: str) -> None:
raw_parts = value.get("parts", [])
if isinstance(raw_parts, list) and all(isinstance(part, str) for part in raw_parts):
return
raise PathResolutionError(f"{path_kind} path parts must be strings")
@dataclass(frozen=True) @dataclass(frozen=True)
@@ -85,22 +127,32 @@ class LocalPath:
return cls(value.parts) return cls(value.parts)
if isinstance(value, str): if isinstance(value, str):
return cls.parse(value) return cls.parse(value)
raise ValueError("expected local path string") if isinstance(value, Mapping):
if value.get("root") != "local":
raise ValueError("expected local path root")
_reject_non_string_parts(value, path_kind="local")
return cls(_structural_parts(value, path_kind="local"))
raise ValueError("expected local path string or structural object")
return core_schema.no_info_plain_validator_function( return core_schema.no_info_plain_validator_function(
validate, validate,
serialization=core_schema.plain_serializer_function_ser_schema( serialization=core_schema.plain_serializer_function_ser_schema(
str, cls._serialize,
), ),
) )
@staticmethod
def _serialize(value: LocalPath) -> dict[str, str | list[str]]:
"""Serialize canonical path JSON without relying on dotted display text."""
return {"root": "local", "parts": list(value.parts)}
@classmethod @classmethod
def __get_pydantic_json_schema__( def __get_pydantic_json_schema__(
cls, _core_schema: core_schema.CoreSchema, _handler: object cls, _core_schema: core_schema.CoreSchema, _handler: object
) -> dict[str, Any]: ) -> dict[str, Any]:
return _json_schema( return _path_json_schema(
cls._JSON_PATTERN, "local",
"Node-local dotted path or root marker `.`.", "Node-local path. Use an empty parts list for the whole payload.",
) )
@@ -159,21 +211,32 @@ class GraphSourcePath:
return cls(value.root, value.parts) return cls(value.root, value.parts)
if isinstance(value, str): if isinstance(value, str):
return cls.parse(value) return cls.parse(value)
raise ValueError("expected graph source path string") if isinstance(value, Mapping):
root = value.get("root")
if root not in cls._ROOTS:
raise ValueError("expected graph source path root")
_reject_non_string_parts(value, path_kind="graph source")
return cls(root, _structural_parts(value, path_kind="graph source")) # type: ignore[arg-type]
raise ValueError("expected graph source path string or structural object")
return core_schema.no_info_plain_validator_function( return core_schema.no_info_plain_validator_function(
validate, validate,
serialization=core_schema.plain_serializer_function_ser_schema( serialization=core_schema.plain_serializer_function_ser_schema(
str, cls._serialize,
), ),
) )
@staticmethod
def _serialize(value: GraphSourcePath) -> dict[str, str | list[str]]:
"""Serialize canonical path JSON without relying on dotted display text."""
return {"root": value.root, "parts": list(value.parts)}
@classmethod @classmethod
def __get_pydantic_json_schema__( def __get_pydantic_json_schema__(
cls, _core_schema: core_schema.CoreSchema, _handler: object cls, _core_schema: core_schema.CoreSchema, _handler: object
) -> dict[str, Any]: ) -> dict[str, Any]:
return _json_schema( return _path_json_schema(
cls._JSON_PATTERN, sorted(cls._ROOTS),
"Readable graph path rooted at input, state, or context.", "Readable graph path rooted at input, state, or context.",
) )
@@ -220,20 +283,30 @@ class StatePath:
return cls(value.parts) return cls(value.parts)
if isinstance(value, str): if isinstance(value, str):
return cls.parse(value) return cls.parse(value)
raise ValueError("expected state path string") if isinstance(value, Mapping):
if value.get("root") != "state":
raise ValueError("expected state path root")
_reject_non_string_parts(value, path_kind="state")
return cls(_structural_parts(value, path_kind="state"))
raise ValueError("expected state path string or structural object")
return core_schema.no_info_plain_validator_function( return core_schema.no_info_plain_validator_function(
validate, validate,
serialization=core_schema.plain_serializer_function_ser_schema( serialization=core_schema.plain_serializer_function_ser_schema(
str, cls._serialize,
), ),
) )
@staticmethod
def _serialize(value: StatePath) -> dict[str, str | list[str]]:
"""Serialize canonical path JSON without relying on dotted display text."""
return {"root": "state", "parts": list(value.parts)}
@classmethod @classmethod
def __get_pydantic_json_schema__( def __get_pydantic_json_schema__(
cls, _core_schema: core_schema.CoreSchema, _handler: object cls, _core_schema: core_schema.CoreSchema, _handler: object
) -> dict[str, Any]: ) -> dict[str, Any]:
return _json_schema(cls._JSON_PATTERN, "Writable state path such as state.foo.") return _path_json_schema("state", "Writable workflow state path.")
def split_graph_path(path: str | GraphSourcePath | StatePath) -> tuple[str, list[str]]: def split_graph_path(path: str | GraphSourcePath | StatePath) -> tuple[str, list[str]]:
+9 -2
View File
@@ -1,6 +1,6 @@
from __future__ import annotations from __future__ import annotations
from typing import TypeAlias from typing import Any, TypeAlias
from wf_artifacts import WorkflowCapabilityRef from wf_artifacts import WorkflowCapabilityRef
from wf_platform import CapabilityRef from wf_platform import CapabilityRef
@@ -8,13 +8,20 @@ from wf_platform import CapabilityRef
WorkflowSurfaceCapabilityId: TypeAlias = CapabilityRef | WorkflowCapabilityRef WorkflowSurfaceCapabilityId: TypeAlias = CapabilityRef | WorkflowCapabilityRef
def parse_workflow_surface_capability_id(value: str) -> WorkflowSurfaceCapabilityId: def parse_workflow_surface_capability_id(
value: str | dict[str, Any],
) -> WorkflowSurfaceCapabilityId:
"""Parse a workflow-surface capability name into its real domain ref. """Parse a workflow-surface capability name into its real domain ref.
MCP tools still accept and return plain strings. Internally, workflow-facing MCP tools still accept and return plain strings. Internally, workflow-facing
capability ids are either live source capabilities or saved wrapper capability ids are either live source capabilities or saved wrapper
artifacts, so this parser avoids inventing a third identifier model. artifacts, so this parser avoids inventing a third identifier model.
""" """
if isinstance(value, dict):
if "artifact_id" in value and "version" in value:
return WorkflowCapabilityRef._validate(value)
return CapabilityRef._validate(value)
try: try:
return WorkflowCapabilityRef.parse(value) return WorkflowCapabilityRef.parse(value)
except ValueError: except ValueError:
+14 -2
View File
@@ -88,7 +88,7 @@ class CapabilityRef:
return core_schema.no_info_plain_validator_function( return core_schema.no_info_plain_validator_function(
cls._validate, cls._validate,
serialization=core_schema.plain_serializer_function_ser_schema( serialization=core_schema.plain_serializer_function_ser_schema(
str, cls._serialize,
when_used="json", when_used="json",
), ),
) )
@@ -99,4 +99,16 @@ class CapabilityRef:
return value return value
if isinstance(value, str): if isinstance(value, str):
return cls.parse(value) return cls.parse(value)
raise TypeError("capability ref must be a string") if isinstance(value, dict):
source = value.get("source")
name = value.get("capability_key")
if isinstance(source, str) and isinstance(name, str):
return cls(source=SourceRef.parse(source), name=name)
raise TypeError(
"capability ref must be a string or {'source': str, 'capability_key': str}"
)
@staticmethod
def _serialize(value: CapabilityRef) -> dict[str, str]:
"""Serialize canonical saved refs structurally; `str(ref)` is display-only."""
return {"source": str(value.source), "capability_key": value.name}
+75 -25
View File
@@ -41,7 +41,10 @@ def test_workflow_artifact_serializes_required_capability_contract() -> None:
assert dumped["version"] == 1 assert dumped["version"] == 1
assert dumped["outcomes"] == ["done", "failed"] assert dumped["outcomes"] == ["done", "failed"]
required = dumped["required_capabilities"][0] required = dumped["required_capabilities"][0]
assert required["ref"] == "context7.query-docs" assert required["ref"] == {
"source": "context7",
"capability_key": "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 assert artifact.required_capability_map()["context7.query-docs"] == capability
@@ -66,35 +69,80 @@ def test_workflow_artifact_can_be_marked_as_wrapper_intent() -> None:
def test_workflow_artifact_accepts_legacy_required_capability_map_and_dumps_list() -> ( def test_workflow_artifact_accepts_legacy_required_capability_map_and_dumps_list() -> (
None None
): ):
artifact = WorkflowArtifact.model_validate({ artifact = WorkflowArtifact.model_validate(
"id": "legacy_capabilities", {
"version": 1, "id": "legacy_capabilities",
"title": "Legacy Capabilities", "version": 1,
"input_schema": {"type": "object", "properties": {}}, "title": "Legacy Capabilities",
"output_schema": {"type": "object", "properties": {}}, "input_schema": {"type": "object", "properties": {}},
"outcomes": ["done"], "output_schema": {"type": "object", "properties": {}},
"plan": {"name": "legacy_capabilities", "nodes": [], "edges": []}, "outcomes": ["done"],
"required_capabilities": { "plan": {"name": "legacy_capabilities", "nodes": [], "edges": []},
"demo.echo": { "required_capabilities": {
"kind": "tool", "demo.echo": {
"input_schema_hash": "sha256:input", "kind": "tool",
"output_schema_hash": "sha256:output", "input_schema_hash": "sha256:input",
"observed_concrete_source": "demo.personal", "output_schema_hash": "sha256:output",
} "observed_concrete_source": "demo.personal",
}, }
}) },
}
)
dumped = artifact.model_dump(mode="json") dumped = artifact.model_dump(mode="json")
required = dumped["required_capabilities"][0] required = dumped["required_capabilities"][0]
assert isinstance(dumped["required_capabilities"], list) assert isinstance(dumped["required_capabilities"], list)
assert required["ref"] == "demo.echo" assert required["ref"] == {"source": "demo", "capability_key": "echo"}
assert required["observed_concrete_source"] == "demo.personal" assert required["observed_concrete_source"] == "demo.personal"
assert "logical_source" not in required assert "logical_source" not in required
assert "capability_name" not in required assert "capability_name" not in required
assert artifact.required_capability_map()["demo.echo"].logical_source == "demo" assert artifact.required_capability_map()["demo.echo"].logical_source == "demo"
def test_required_capability_accepts_legacy_logical_fields_but_dumps_ref_object() -> (
None
):
capability = RequiredCapability.model_validate(
{
"logical_source": "demo",
"capability_name": "foo.bar",
"kind": "node_spec",
}
)
assert capability.logical_source == "demo"
assert capability.capability_name == "foo.bar"
assert capability.model_dump(mode="json")["ref"] == {
"source": "demo",
"capability_key": "foo.bar",
}
def test_workflow_artifact_legacy_required_capability_map_is_best_effort() -> None:
artifact = WorkflowArtifact.model_validate(
{
"id": "legacy_dotted_capability",
"version": 1,
"title": "Legacy Dotted Capability",
"input_schema": {"type": "object", "properties": {}},
"output_schema": {"type": "object", "properties": {}},
"outcomes": ["done"],
"plan": {"name": "legacy_dotted_capability", "nodes": [], "edges": []},
"required_capabilities": {
"demo.foo.bar": {"kind": "node_spec"},
},
}
)
required = artifact.model_dump(mode="json")["required_capabilities"][0]
assert required["ref"] == {
"source": "demo.foo",
"capability_key": "bar",
}
def test_workflow_deployment_binds_logical_sources_to_concrete_sources() -> None: def test_workflow_deployment_binds_logical_sources_to_concrete_sources() -> None:
deployment = WorkflowDeployment( deployment = WorkflowDeployment(
id="summarize_docs.personal", id="summarize_docs.personal",
@@ -118,12 +166,14 @@ def test_workflow_deployment_binds_logical_sources_to_concrete_sources() -> None
def test_workflow_deployment_accepts_legacy_binding_map_and_dumps_list() -> None: def test_workflow_deployment_accepts_legacy_binding_map_and_dumps_list() -> None:
deployment = WorkflowDeployment.model_validate({ deployment = WorkflowDeployment.model_validate(
"id": "legacy_bindings.personal", {
"artifact_id": "legacy_bindings", "id": "legacy_bindings.personal",
"artifact_version": 1, "artifact_id": "legacy_bindings",
"bindings": {"demo": "demo.personal"}, "artifact_version": 1,
}) "bindings": {"demo": "demo.personal"},
}
)
dumped = deployment.model_dump(mode="json") dumped = deployment.model_dump(mode="json")
binding = dumped["bindings"][0] binding = dumped["bindings"][0]
+26 -22
View File
@@ -73,21 +73,23 @@ def test_file_store_loads_legacy_artifact_and_rewrites_canonical_shape(
artifact_dir.mkdir(parents=True) artifact_dir.mkdir(parents=True)
artifact_path = artifact_dir / "1.json" artifact_path = artifact_dir / "1.json"
artifact_path.write_text( artifact_path.write_text(
json.dumps({ json.dumps(
"id": "legacy_capabilities", {
"version": 1, "id": "legacy_capabilities",
"title": "Legacy Capabilities", "version": 1,
"input_schema": {"type": "object", "properties": {}}, "title": "Legacy Capabilities",
"output_schema": {"type": "object", "properties": {}}, "input_schema": {"type": "object", "properties": {}},
"outcomes": ["done"], "output_schema": {"type": "object", "properties": {}},
"plan": {"name": "legacy_capabilities", "nodes": [], "edges": []}, "outcomes": ["done"],
"required_capabilities": { "plan": {"name": "legacy_capabilities", "nodes": [], "edges": []},
"demo.echo": { "required_capabilities": {
"kind": "tool", "demo.echo": {
"input_schema_hash": "sha256:input", "kind": "tool",
} "input_schema_hash": "sha256:input",
}, }
}), },
}
),
encoding="utf-8", encoding="utf-8",
) )
@@ -96,7 +98,7 @@ def test_file_store_loads_legacy_artifact_and_rewrites_canonical_shape(
rewritten = json.loads(artifact_path.read_text(encoding="utf-8")) rewritten = json.loads(artifact_path.read_text(encoding="utf-8"))
required = rewritten["required_capabilities"][0] required = rewritten["required_capabilities"][0]
assert required["ref"] == "demo.echo" assert required["ref"] == {"source": "demo", "capability_key": "echo"}
assert required["kind"] == "tool" assert required["kind"] == "tool"
assert "logical_source" not in required assert "logical_source" not in required
assert "capability_name" not in required assert "capability_name" not in required
@@ -108,12 +110,14 @@ def test_file_store_loads_legacy_deployment_and_rewrites_canonical_shape(
store = FileWorkflowArtifactStore(tmp_path) store = FileWorkflowArtifactStore(tmp_path)
deployment_path = store.deployments_dir / "legacy_bindings.personal.json" deployment_path = store.deployments_dir / "legacy_bindings.personal.json"
deployment_path.write_text( deployment_path.write_text(
json.dumps({ json.dumps(
"id": "legacy_bindings.personal", {
"artifact_id": "legacy_bindings", "id": "legacy_bindings.personal",
"artifact_version": 1, "artifact_id": "legacy_bindings",
"bindings": {"demo": "demo.personal"}, "artifact_version": 1,
}), "bindings": {"demo": "demo.personal"},
}
),
encoding="utf-8", encoding="utf-8",
) )
+4 -4
View File
@@ -105,10 +105,10 @@ def test_builder_emits_canonical_node_bindings() -> None:
dumped_node = builder.compile().model_dump(mode="json")["nodes"][0] dumped_node = builder.compile().model_dump(mode="json")["nodes"][0]
assert dumped_node["input"][0]["path"] == "input.text" assert dumped_node["input"][0]["path"] == {"root": "input", "parts": ["text"]}
assert dumped_node["input"][0]["target"] == "text" assert dumped_node["input"][0]["target"] == {"root": "local", "parts": ["text"]}
assert dumped_node["output"][0]["source"] == "text" assert dumped_node["output"][0]["source"] == {"root": "local", "parts": ["text"]}
assert dumped_node["output"][0]["target"] == "state.text" assert dumped_node["output"][0]["target"] == {"root": "state", "parts": ["text"]}
assert "in_map" not in dumped_node assert "in_map" not in dumped_node
assert "input_values" not in dumped_node assert "input_values" not in dumped_node
assert "out_map" not in dumped_node assert "out_map" not in dumped_node
+13 -4
View File
@@ -14,9 +14,12 @@ def test_condition_dsl_compiles_to_core_condition() -> None:
dumped = condition.to_condition().model_dump(mode="json") dumped = condition.to_condition().model_dump(mode="json")
assert dumped["op"] == "and" assert dumped["op"] == "and"
assert dumped["args"][0]["left"]["path"] == "state.should_email" assert dumped["args"][0]["left"]["path"] == {
"root": "state",
"parts": ["should_email"],
}
assert dumped["args"][0]["right"]["value"] is True assert dumped["args"][0]["right"]["value"] is True
assert dumped["args"][1]["path"] == "state.summary" assert dumped["args"][1]["path"] == {"root": "state", "parts": ["summary"]}
def test_condition_dsl_compiles_authoring_paths_to_typed_core_paths() -> None: def test_condition_dsl_compiles_authoring_paths_to_typed_core_paths() -> None:
@@ -28,10 +31,16 @@ def test_condition_dsl_compiles_authoring_paths_to_typed_core_paths() -> None:
assert isinstance(comparison.right, PathOperand) assert isinstance(comparison.right, PathOperand)
assert comparison.left.path == GraphSourcePath.state("score") assert comparison.left.path == GraphSourcePath.state("score")
assert comparison.right.path == GraphSourcePath.state("threshold") assert comparison.right.path == GraphSourcePath.state("threshold")
assert comparison.model_dump(mode="json")["left"]["path"] == "state.score" assert comparison.model_dump(mode="json")["left"]["path"] == {
"root": "state",
"parts": ["score"],
}
assert isinstance(existence, ExistsCondition) assert isinstance(existence, ExistsCondition)
assert existence.path == GraphSourcePath.state("summary") assert existence.path == GraphSourcePath.state("summary")
assert existence.model_dump(mode="json")["path"] == "state.summary" assert existence.model_dump(mode="json")["path"] == {
"root": "state",
"parts": ["summary"],
}
def test_condition_dsl_supports_not_ge_and_ne() -> None: def test_condition_dsl_supports_not_ge_and_ne() -> None:
+138 -94
View File
@@ -6,16 +6,18 @@ from wf_core.paths import GraphSourcePath, LocalPath, StatePath
def test_node_use_accepts_canonical_input_and_output_bindings(): def test_node_use_accepts_canonical_input_and_output_bindings():
node = NodeUse.model_validate({ node = NodeUse.model_validate(
"id": "echo", {
"type": "node", "id": "echo",
"node": "echo", "type": "node",
"input": [ "node": "echo",
{"target": "message", "path": "input.message"}, "input": [
{"target": "mode", "value": None}, {"target": "message", "path": "input.message"},
], {"target": "mode", "value": None},
"output": [{"source": "echoed", "target": "state.echoed"}], ],
}) "output": [{"source": "echoed", "target": "state.echoed"}],
}
)
path_binding = node.input[0] path_binding = node.input[0]
assert isinstance(path_binding, InputPathBinding) assert isinstance(path_binding, InputPathBinding)
@@ -32,78 +34,112 @@ def test_node_use_accepts_canonical_input_and_output_bindings():
def test_node_use_converts_old_maps_to_canonical_bindings(): def test_node_use_converts_old_maps_to_canonical_bindings():
node = NodeUse.model_validate({ node = NodeUse.model_validate(
"id": "echo", {
"type": "node", "id": "echo",
"node": "echo", "type": "node",
"in_map": {"input.message": "message"}, "node": "echo",
"input_values": {"mode": "fast"}, "in_map": {"input.message": "message"},
"out_map": {"echoed": "state.echoed"}, "input_values": {"mode": "fast"},
}) "out_map": {"echoed": "state.echoed"},
}
)
dumped = node.model_dump(mode="json") dumped = node.model_dump(mode="json")
assert "in_map" not in dumped assert "in_map" not in dumped
assert "input_values" not in dumped assert "input_values" not in dumped
assert "out_map" not in dumped assert "out_map" not in dumped
assert dumped["input"][0]["value"] == "fast" assert dumped["input"][0]["value"] == "fast"
assert dumped["input"][0]["target"] == "mode" assert dumped["input"][0]["target"] == {"root": "local", "parts": ["mode"]}
assert dumped["input"][1]["path"] == "input.message" assert dumped["input"][1]["path"] == {"root": "input", "parts": ["message"]}
assert dumped["input"][1]["target"] == "message" assert dumped["input"][1]["target"] == {"root": "local", "parts": ["message"]}
assert dumped["output"][0]["source"] == "echoed" assert dumped["output"][0]["source"] == {"root": "local", "parts": ["echoed"]}
assert dumped["output"][0]["target"] == "state.echoed" assert dumped["output"][0]["target"] == {"root": "state", "parts": ["echoed"]}
def test_node_use_serializes_canonical_binding_paths_as_strings_in_all_dump_modes(): def test_node_use_serializes_canonical_binding_paths_as_structural_json():
node = NodeUse.model_validate({ node = NodeUse.model_validate(
"id": "echo", {
"type": "node",
"node": "echo",
"input": [{"target": "message", "path": "input.message"}],
"output": [{"source": "echoed", "target": "state.echoed"}],
})
python_dumped = node.model_dump()
json_dumped = node.model_dump(mode="json")
assert python_dumped["input"][0]["target"] == "message"
assert python_dumped["input"][0]["path"] == "input.message"
assert python_dumped["output"][0]["source"] == "echoed"
assert python_dumped["output"][0]["target"] == "state.echoed"
assert json_dumped["input"][0]["target"] == "message"
assert json_dumped["input"][0]["path"] == "input.message"
assert json_dumped["output"][0]["source"] == "echoed"
assert json_dumped["output"][0]["target"] == "state.echoed"
def test_node_use_rejects_mixed_old_and_new_binding_styles():
with pytest.raises(ValidationError):
NodeUse.model_validate({
"id": "echo", "id": "echo",
"type": "node", "type": "node",
"node": "echo", "node": "echo",
"input": [{"target": "message", "path": "input.message"}], "input": [{"target": "message", "path": "input.message"}],
"in_map": {"input.other": "other"}, "output": [{"source": "echoed", "target": "state.echoed"}],
}) }
)
python_dumped = node.model_dump()
json_dumped = node.model_dump(mode="json")
assert python_dumped["input"][0]["target"] == {
"root": "local",
"parts": ["message"],
}
assert python_dumped["input"][0]["path"] == {
"root": "input",
"parts": ["message"],
}
assert python_dumped["output"][0]["source"] == {
"root": "local",
"parts": ["echoed"],
}
assert python_dumped["output"][0]["target"] == {
"root": "state",
"parts": ["echoed"],
}
assert json_dumped["input"][0]["target"] == {
"root": "local",
"parts": ["message"],
}
assert json_dumped["input"][0]["path"] == {
"root": "input",
"parts": ["message"],
}
assert json_dumped["output"][0]["source"] == {
"root": "local",
"parts": ["echoed"],
}
assert json_dumped["output"][0]["target"] == {
"root": "state",
"parts": ["echoed"],
}
def test_node_use_rejects_mixed_old_and_new_binding_styles():
with pytest.raises(ValidationError):
NodeUse.model_validate(
{
"id": "echo",
"type": "node",
"node": "echo",
"input": [{"target": "message", "path": "input.message"}],
"in_map": {"input.other": "other"},
}
)
def test_input_binding_rejects_path_and_value_together(): def test_input_binding_rejects_path_and_value_together():
with pytest.raises(ValidationError): with pytest.raises(ValidationError):
NodeUse.model_validate({ NodeUse.model_validate(
"id": "bad", {
"type": "node", "id": "bad",
"node": "bad", "type": "node",
"input": [{"target": "message", "path": "input.message", "value": "x"}], "node": "bad",
}) "input": [{"target": "message", "path": "input.message", "value": "x"}],
}
)
def test_input_binding_rejects_neither_path_nor_value(): def test_input_binding_rejects_neither_path_nor_value():
with pytest.raises(ValidationError): with pytest.raises(ValidationError):
NodeUse.model_validate({ NodeUse.model_validate(
"id": "bad", {
"type": "node", "id": "bad",
"node": "bad", "type": "node",
"input": [{"target": "message"}], "node": "bad",
}) "input": [{"target": "message"}],
}
)
@pytest.mark.parametrize( @pytest.mark.parametrize(
@@ -115,12 +151,14 @@ def test_input_binding_rejects_neither_path_nor_value():
) )
def test_bindings_reject_extra_fields(field: str, binding: dict[str, object]): def test_bindings_reject_extra_fields(field: str, binding: dict[str, object]):
with pytest.raises(ValidationError): with pytest.raises(ValidationError):
NodeUse.model_validate({ NodeUse.model_validate(
"id": "bad", {
"type": "node", "id": "bad",
"node": "bad", "type": "node",
field: [binding], "node": "bad",
}) field: [binding],
}
)
@pytest.mark.parametrize( @pytest.mark.parametrize(
@@ -133,46 +171,52 @@ def test_bindings_reject_extra_fields(field: str, binding: dict[str, object]):
) )
def test_deprecated_maps_reject_non_mapping_values(field: str, value: object): def test_deprecated_maps_reject_non_mapping_values(field: str, value: object):
with pytest.raises(ValidationError): with pytest.raises(ValidationError):
NodeUse.model_validate({ NodeUse.model_validate(
"id": "bad", {
"type": "node", "id": "bad",
"node": "bad", "type": "node",
field: value, "node": "bad",
}) field: value,
}
)
def test_deprecated_conversion_preserves_input_value_then_in_map_order(): def test_deprecated_conversion_preserves_input_value_then_in_map_order():
node = NodeUse.model_validate({ node = NodeUse.model_validate(
"id": "ordered", {
"type": "node", "id": "ordered",
"node": "ordered", "type": "node",
"input_values": {"first": 1, "second": 2}, "node": "ordered",
"in_map": {"input.third": "third", "state.fourth": "fourth"}, "input_values": {"first": 1, "second": 2},
}) "in_map": {"input.third": "third", "state.fourth": "fourth"},
}
)
dumped_input = node.model_dump(mode="json")["input"] dumped_input = node.model_dump(mode="json")["input"]
assert dumped_input[0]["target"] == "first" assert dumped_input[0]["target"] == {"root": "local", "parts": ["first"]}
assert dumped_input[0]["value"] == 1 assert dumped_input[0]["value"] == 1
assert dumped_input[1]["target"] == "second" assert dumped_input[1]["target"] == {"root": "local", "parts": ["second"]}
assert dumped_input[1]["value"] == 2 assert dumped_input[1]["value"] == 2
assert dumped_input[2]["target"] == "third" assert dumped_input[2]["target"] == {"root": "local", "parts": ["third"]}
assert dumped_input[2]["path"] == "input.third" assert dumped_input[2]["path"] == {"root": "input", "parts": ["third"]}
assert dumped_input[3]["target"] == "fourth" assert dumped_input[3]["target"] == {"root": "local", "parts": ["fourth"]}
assert dumped_input[3]["path"] == "state.fourth" assert dumped_input[3]["path"] == {"root": "state", "parts": ["fourth"]}
def test_deprecated_input_value_preserves_explicit_null(): def test_deprecated_input_value_preserves_explicit_null():
node = NodeUse.model_validate({ node = NodeUse.model_validate(
"id": "null", {
"type": "node", "id": "null",
"node": "null", "type": "node",
"input_values": {"maybe": None}, "node": "null",
}) "input_values": {"maybe": None},
}
)
value_binding = node.input[0] value_binding = node.input[0]
assert isinstance(value_binding, InputValueBinding) assert isinstance(value_binding, InputValueBinding)
assert value_binding.value is None assert value_binding.value is None
dumped_input = node.model_dump(mode="json")["input"] dumped_input = node.model_dump(mode="json")["input"]
assert dumped_input[0]["target"] == "maybe" assert dumped_input[0]["target"] == {"root": "local", "parts": ["maybe"]}
assert dumped_input[0]["value"] is None assert dumped_input[0]["value"] is None
+92 -48
View File
@@ -23,6 +23,25 @@ def test_graph_source_path_accepts_root_and_nested_paths() -> None:
assert str(GraphSourcePath.context("loop_item")) == "context.loop_item" assert str(GraphSourcePath.context("loop_item")) == "context.loop_item"
def test_structural_path_parts_preserve_literal_field_names() -> None:
class Payload(BaseModel):
source: GraphSourcePath
target: StatePath
local: LocalPath
payload = Payload.model_validate(
{
"source": {"root": "input", "parts": ["user.name"]},
"target": {"root": "state", "parts": ["person name"]},
"local": {"root": "local", "parts": ["payload.text"]},
}
)
assert payload.source == GraphSourcePath("input", ("user.name",))
assert payload.target == StatePath(("person name",))
assert payload.local == LocalPath(("payload.text",))
def test_state_path_serializes_with_state_prefix() -> None: def test_state_path_serializes_with_state_prefix() -> None:
assert str(StatePath.of("person.name")) == "state.person.name" assert str(StatePath.of("person.name")) == "state.person.name"
assert str(StatePath.parse("state.person.name")) == "state.person.name" assert str(StatePath.parse("state.person.name")) == "state.person.name"
@@ -47,9 +66,6 @@ def test_local_path_supports_root_marker_and_fragments() -> None:
".", ".",
"state.", "state.",
"state..name", "state..name",
"state.items.0",
"state.user-name",
"state.items[0]",
"output.foo", "output.foo",
], ],
) )
@@ -71,9 +87,6 @@ def test_graph_source_paths_reject_invalid_segments(raw: str) -> None:
[ [
"state.", "state.",
"state..name", "state..name",
"state.items.0",
"state.user-name",
"state.items[0]",
], ],
) )
def test_all_path_types_reject_invalid_segments(factory, raw: str) -> None: def test_all_path_types_reject_invalid_segments(factory, raw: str) -> None:
@@ -92,9 +105,9 @@ def test_path_objects_are_immutable_and_hashable() -> None:
@pytest.mark.parametrize( @pytest.mark.parametrize(
("factory", "args"), ("factory", "args"),
[ [
(GraphSourcePath, ("output", ("user-name",))), (GraphSourcePath, ("output", ("user",))),
(StatePath, (("0",),)), (StatePath, (("",),)),
(LocalPath, (("items[0]",),)), (LocalPath, ((" ",),)),
], ],
) )
def test_direct_constructors_enforce_path_invariants( def test_direct_constructors_enforce_path_invariants(
@@ -114,64 +127,93 @@ def test_pydantic_revalidates_existing_path_objects() -> None:
# constructor validation. Pydantic must not blindly trust existing instances. # constructor validation. Pydantic must not blindly trust existing instances.
source = object.__new__(GraphSourcePath) source = object.__new__(GraphSourcePath)
object.__setattr__(source, "root", "output") object.__setattr__(source, "root", "output")
object.__setattr__(source, "parts", ("user-name",)) object.__setattr__(source, "parts", ("user",))
target = object.__new__(StatePath) target = object.__new__(StatePath)
object.__setattr__(target, "parts", ("0",)) object.__setattr__(target, "parts", ("",))
local = object.__new__(LocalPath) local = object.__new__(LocalPath)
object.__setattr__(local, "parts", ("items[0]",)) object.__setattr__(local, "parts", (" ",))
with pytest.raises(ValidationError): with pytest.raises(ValidationError):
Payload.model_validate({ Payload.model_validate(
"source": source, {
"target": StatePath.of("person"), "source": source,
"local": LocalPath.root(), "target": StatePath.of("person"),
}) "local": LocalPath.root(),
}
)
with pytest.raises(ValidationError): with pytest.raises(ValidationError):
Payload.model_validate({ Payload.model_validate(
"source": GraphSourcePath.input("user"), {
"target": target, "source": GraphSourcePath.input("user"),
"local": LocalPath.root(), "target": target,
}) "local": LocalPath.root(),
}
)
with pytest.raises(ValidationError): with pytest.raises(ValidationError):
Payload.model_validate({ Payload.model_validate(
"source": GraphSourcePath.input("user"), {
"target": StatePath.of("person"), "source": GraphSourcePath.input("user"),
"local": local, "target": StatePath.of("person"),
}) "local": local,
}
)
def test_pydantic_accepts_path_strings_and_serializes_strings() -> None: def test_pydantic_accepts_path_strings_and_serializes_structural_json() -> None:
class Payload(BaseModel): class Payload(BaseModel):
source: GraphSourcePath source: GraphSourcePath
target: StatePath target: StatePath
local: LocalPath local: LocalPath
payload = Payload.model_validate({ payload = Payload.model_validate(
"source": "input.user", {
"target": "state.person", "source": "input.user",
"local": "user", "target": "state.person",
}) "local": "user",
}
)
assert payload.source == GraphSourcePath.input("user") assert payload.source == GraphSourcePath.input("user")
assert payload.target == StatePath.of("person") assert payload.target == StatePath.of("person")
assert payload.local == LocalPath.of("user") assert payload.local == LocalPath.of("user")
dumped = payload.model_dump(mode="json") dumped = payload.model_dump(mode="json")
assert dumped["source"] == "input.user" assert dumped["source"] == {"root": "input", "parts": ["user"]}
assert dumped["target"] == "state.person" assert dumped["target"] == {"root": "state", "parts": ["person"]}
assert dumped["local"] == "user" assert dumped["local"] == {"root": "local", "parts": ["user"]}
python_dumped = payload.model_dump() python_dumped = payload.model_dump()
assert python_dumped["source"] == "input.user" assert python_dumped["source"] == {"root": "input", "parts": ["user"]}
assert python_dumped["target"] == "state.person" assert python_dumped["target"] == {"root": "state", "parts": ["person"]}
assert python_dumped["local"] == "user" assert python_dumped["local"] == {"root": "local", "parts": ["user"]}
def test_condition_path_operand_serializes_path_as_string_in_all_dump_modes() -> None: def test_path_json_schema_advertises_structural_shape() -> None:
class Payload(BaseModel):
source: GraphSourcePath
target: StatePath
local: LocalPath
schema = Payload.model_json_schema()
assert schema["properties"]["source"]["type"] == "object"
assert schema["properties"]["source"]["properties"]["root"]["enum"] == [
"context",
"input",
"state",
]
assert schema["properties"]["target"]["properties"]["root"]["const"] == "state"
assert schema["properties"]["local"]["properties"]["root"]["const"] == "local"
def test_condition_path_operand_serializes_path_as_structural_json() -> None:
operand = PathOperand.model_validate({"path": "state.x"}) operand = PathOperand.model_validate({"path": "state.x"})
assert operand.model_dump()["path"] == "state.x" assert operand.model_dump()["path"] == {"root": "state", "parts": ["x"]}
assert operand.model_dump(mode="json")["path"] == "state.x" assert operand.model_dump(mode="json")["path"] == {
"root": "state",
"parts": ["x"],
}
def test_pydantic_accepts_existing_path_objects() -> None: def test_pydantic_accepts_existing_path_objects() -> None:
@@ -180,11 +222,13 @@ def test_pydantic_accepts_existing_path_objects() -> None:
target: StatePath target: StatePath
local: LocalPath local: LocalPath
payload = Payload.model_validate({ payload = Payload.model_validate(
"source": GraphSourcePath.state("person"), {
"target": StatePath.of("person.name"), "source": GraphSourcePath.state("person"),
"local": LocalPath.root(), "target": StatePath.of("person.name"),
}) "local": LocalPath.root(),
}
)
assert str(payload.source) == "state.person" assert str(payload.source) == "state.person"
assert str(payload.target) == "state.person.name" assert str(payload.target) == "state.person.name"
@@ -205,7 +249,7 @@ def test_existing_source_and_destination_validation_helpers_use_new_parsers() ->
assert is_valid_source_path("context", set(), set(), allow_context=True) is True assert is_valid_source_path("context", set(), set(), allow_context=True) is True
assert is_valid_source_path("state.person", {"person"}, set()) is True assert is_valid_source_path("state.person", {"person"}, set()) is True
assert is_valid_source_path("input.person", set(), {"person"}) is True assert is_valid_source_path("input.person", set(), {"person"}) is True
assert is_valid_source_path("state.person-name", {"person-name"}, set()) is False assert is_valid_source_path("state.person-name", {"person-name"}, set()) is True
assert is_valid_destination_path("state") is False assert is_valid_destination_path("state") is False
assert is_valid_destination_path("state.person") is True assert is_valid_destination_path("state.person") is True
@@ -29,10 +29,10 @@ def test_raw_canonical_workflow_serializes_new_shape() -> None:
assert "in_map" not in node assert "in_map" not in node
assert "input_values" not in node assert "input_values" not in node
assert "out_map" not in node assert "out_map" not in node
assert node["input"][0]["path"] == "input.text" assert node["input"][0]["path"] == {"root": "input", "parts": ["text"]}
assert node["input"][0]["target"] == "text" assert node["input"][0]["target"] == {"root": "local", "parts": ["text"]}
assert node["input"][1]["value"] == "raw:" assert node["input"][1]["value"] == "raw:"
assert node["output"][0]["source"] == "message" assert node["output"][0]["source"] == {"root": "local", "parts": ["message"]}
assert node["output"][0]["target"] == "state.message" assert node["output"][0]["target"] == {"root": "state", "parts": ["message"]}
assert message_schema["type"] == "string" assert message_schema["type"] == "string"
assert message_schema["reducer"] == "wf.std.replace" assert message_schema["reducer"] == "wf.std.replace"
+36 -5
View File
@@ -33,16 +33,47 @@ def test_platform_refs_validate_and_serialize_through_pydantic() -> None:
source: SourceRef source: SourceRef
capability: CapabilityRef capability: CapabilityRef
payload = Payload.model_validate({ payload = Payload.model_validate(
"source": "demo.personal", {
"capability": "demo.personal.echo_tool", "source": "demo.personal",
}) "capability": "demo.personal.echo_tool",
}
)
assert payload.source == SourceRef.parse("demo.personal") assert payload.source == SourceRef.parse("demo.personal")
assert payload.capability == CapabilityRef.parse("demo.personal.echo_tool") assert payload.capability == CapabilityRef.parse("demo.personal.echo_tool")
assert payload.model_dump(mode="json") == { assert payload.model_dump(mode="json") == {
"source": "demo.personal", "source": "demo.personal",
"capability": "demo.personal.echo_tool", "capability": {
"source": "demo.personal",
"capability_key": "echo_tool",
},
}
def test_capability_ref_accepts_structural_input() -> None:
class Payload(BaseModel):
capability: CapabilityRef
payload = Payload.model_validate(
{"capability": {"source": "demo", "capability_key": "foo.bar"}}
)
assert payload.capability.source == SourceRef.parse("demo")
assert payload.capability.name == "foo.bar"
def test_capability_ref_serializes_structurally() -> None:
class Payload(BaseModel):
capability: CapabilityRef
payload = Payload(
capability=CapabilityRef(source=SourceRef.parse("demo"), name="foo.bar")
)
assert payload.model_dump(mode="json")["capability"] == {
"source": "demo",
"capability_key": "foo.bar",
} }
+36
View File
@@ -1,5 +1,7 @@
from __future__ import annotations from __future__ import annotations
from pydantic import BaseModel
from wf_artifacts import WorkflowCapabilityRef from wf_artifacts import WorkflowCapabilityRef
@@ -17,6 +19,40 @@ def test_workflow_capability_ref_preserves_dotted_artifact_ids() -> None:
assert ref.version == 3 assert ref.version == 3
def test_workflow_capability_ref_validates_legacy_string_input() -> None:
class Payload(BaseModel):
ref: WorkflowCapabilityRef
payload = Payload.model_validate({"ref": "workflow.echo_wrapper.v1"})
assert payload.ref.artifact_id == "echo_wrapper"
assert payload.ref.version == 1
def test_workflow_capability_ref_validates_structural_input() -> None:
class Payload(BaseModel):
ref: WorkflowCapabilityRef
payload = Payload.model_validate(
{"ref": {"artifact_id": "echo_wrapper", "version": 1}}
)
assert payload.ref.artifact_id == "echo_wrapper"
assert payload.ref.version == 1
def test_workflow_capability_ref_serializes_structurally() -> None:
class Payload(BaseModel):
ref: WorkflowCapabilityRef
payload = Payload(ref=WorkflowCapabilityRef("echo_wrapper", 1))
assert payload.model_dump(mode="json")["ref"] == {
"artifact_id": "echo_wrapper",
"version": 1,
}
def test_workflow_capability_ref_rejects_other_namespaces() -> None: def test_workflow_capability_ref_rejects_other_namespaces() -> None:
try: try:
WorkflowCapabilityRef.parse("demo.echo.v1") WorkflowCapabilityRef.parse("demo.echo.v1")
+52 -1
View File
@@ -5,7 +5,7 @@ import shutil
from typing import Any, cast from typing import Any, cast
from wf_artifacts import FileDraftWorkspaceStore, WorkflowDeployment from wf_artifacts import FileDraftWorkspaceStore, WorkflowDeployment
from wf_authoring import NodeSpec, build_async_registry from wf_authoring import NodeSpec, build_async_registry, node
from wf_core import END, NodeUse, RunStatus, RuntimeContext from wf_core import END, NodeUse, RunStatus, RuntimeContext
from wf_mcp.broker import WfMcpService from wf_mcp.broker import WfMcpService
from wf_mcp.models import AuthRecord, ConnectionConfig, RawWorkflowPlan from wf_mcp.models import AuthRecord, ConnectionConfig, RawWorkflowPlan
@@ -20,6 +20,8 @@ from wf_platform import (
) )
from .test_support import ( from .test_support import (
EchoInput,
EchoOutput,
FailingDiscoveryAdapter, FailingDiscoveryAdapter,
FakeAdapter, FakeAdapter,
echo_tool, echo_tool,
@@ -28,6 +30,11 @@ from .test_support import (
) )
@node(name="foo.bar")
def pro_dotted_echo_tool(payload: EchoInput) -> EchoOutput:
return EchoOutput(echoed=f"pro:{payload.text}")
def _single_echo_plan(plan_name: str, node_name: str) -> RawWorkflowPlan: def _single_echo_plan(plan_name: str, node_name: str) -> RawWorkflowPlan:
return _raw_plan( return _raw_plan(
name=plan_name, name=plan_name,
@@ -476,6 +483,50 @@ def test_service_runs_logical_source_plan_with_dotted_local_name() -> None:
assert run.output["echoed"] == "hello" assert run.output["echoed"] == "hello"
def test_service_binds_longest_logical_source_prefix_first() -> None:
service = WfMcpService(
store=FileStore(local_temp_root() / "longest_logical_source_prefix")
)
service.register_connection(
ConnectionConfig(id="demo.personal", server="demo", account="personal")
)
service.register_connection(
ConnectionConfig(
id="demo.pro.personal",
server="demo",
account="pro.personal",
)
)
service.register_specs("demo.personal", echo_tool)
service.register_specs("demo.pro.personal", pro_dotted_echo_tool)
plan = _single_echo_plan(
"longest_logical_source_prefix_plan",
"demo.pro.foo.bar",
)
run = asyncio.run(
service.run_workflow_from_plan(
plan,
{"text": "hello"},
deployment=WorkflowDeployment(
id="longest_logical_source_prefix.personal",
artifact_id="longest_logical_source_prefix",
artifact_version=1,
bindings=[
{"logical_source": "demo", "concrete_source": "demo.personal"},
{
"logical_source": "demo.pro",
"concrete_source": "demo.pro.personal",
},
],
),
)
)
assert run.status == RunStatus.COMPLETED
assert run.output["echoed"] == "pro:hello"
def test_service_does_not_resolve_specs_hidden_from_planner() -> None: def test_service_does_not_resolve_specs_hidden_from_planner() -> None:
service = WfMcpService(store=FileStore(local_temp_root() / "hidden_spec_store")) service = WfMcpService(store=FileStore(local_temp_root() / "hidden_spec_store"))
hidden_echo_tool = NodeSpec( hidden_echo_tool = NodeSpec(
@@ -14,6 +14,19 @@ def test_workflow_surface_capability_id_parses_live_capability_ref() -> None:
assert capability.name == "echo_tool" assert capability.name == "echo_tool"
def test_workflow_surface_capability_id_parses_structural_live_capability_ref() -> None:
capability = parse_workflow_surface_capability_id(
{
"source": "demo",
"capability_key": "foo.bar",
}
)
assert isinstance(capability, CapabilityRef)
assert str(capability.source) == "demo"
assert capability.name == "foo.bar"
def test_workflow_surface_capability_id_parses_saved_wrapper_ref() -> None: def test_workflow_surface_capability_id_parses_saved_wrapper_ref() -> None:
capability = parse_workflow_surface_capability_id("workflow.echo_wrapper.v2") capability = parse_workflow_surface_capability_id("workflow.echo_wrapper.v2")
@@ -21,3 +34,15 @@ def test_workflow_surface_capability_id_parses_saved_wrapper_ref() -> None:
assert str(capability) == "workflow.echo_wrapper.v2" assert str(capability) == "workflow.echo_wrapper.v2"
assert capability.artifact_id == "echo_wrapper" assert capability.artifact_id == "echo_wrapper"
assert capability.version == 2 assert capability.version == 2
def test_workflow_surface_capability_id_parses_structural_saved_wrapper_ref() -> None:
capability = parse_workflow_surface_capability_id(
{
"artifact_id": "echo_wrapper",
"version": 2,
}
)
assert isinstance(capability, WorkflowCapabilityRef)
assert str(capability) == "workflow.echo_wrapper.v2"