paths throughout to serialize as object.
all for clarity! ahh
This commit is contained in:
@@ -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.
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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]]:
|
||||||
|
|||||||
@@ -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
@@ -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}
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
@@ -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",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
Reference in New Issue
Block a user