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
[`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:
```text
+18 -3
View File
@@ -29,7 +29,12 @@ class DiagnosticSeverity(StrEnum):
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
kind: Literal["tool", "resource", "prompt", "node_spec", "reducer", "workflow"]
@@ -68,7 +73,12 @@ class RequiredCapability(BaseModel):
logical_source = data.pop("logical_source", None)
capability_name = data.pop("capability_name", None)
if isinstance(logical_source, str) and isinstance(capability_name, str):
data["ref"] = f"{logical_source}.{capability_name}"
# 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
@@ -90,7 +100,12 @@ class AvailableSource(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
concrete_source: SourceRefInput
+39
View File
@@ -1,6 +1,9 @@
from __future__ import annotations
from dataclasses import dataclass
from typing import Any
from pydantic_core import core_schema
@dataclass(frozen=True, slots=True)
@@ -31,3 +34,39 @@ class WorkflowCapabilityRef:
def __str__(self) -> str:
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 dataclasses import dataclass
import re
from collections.abc import Mapping, MutableMapping
from dataclasses import dataclass
from typing import Any, ClassVar, Literal
from pydantic_core import core_schema
@@ -12,12 +11,11 @@ class PathResolutionError(ValueError):
pass
SEGMENT_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
GraphRoot = Literal["input", "state", "context"]
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}")
return segment
@@ -36,8 +34,52 @@ def _parse_fragments(*fragments: str, path_kind: str) -> tuple[str, ...]:
return tuple(parts)
def _json_schema(pattern: str, description: str) -> dict[str, Any]:
return {"type": "string", "pattern": pattern, "description": description}
def _path_json_schema(root: str | list[str], description: str) -> dict[str, Any]:
"""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)
@@ -85,22 +127,32 @@ class LocalPath:
return cls(value.parts)
if isinstance(value, str):
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(
validate,
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
def __get_pydantic_json_schema__(
cls, _core_schema: core_schema.CoreSchema, _handler: object
) -> dict[str, Any]:
return _json_schema(
cls._JSON_PATTERN,
"Node-local dotted path or root marker `.`.",
return _path_json_schema(
"local",
"Node-local path. Use an empty parts list for the whole payload.",
)
@@ -159,21 +211,32 @@ class GraphSourcePath:
return cls(value.root, value.parts)
if isinstance(value, str):
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(
validate,
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
def __get_pydantic_json_schema__(
cls, _core_schema: core_schema.CoreSchema, _handler: object
) -> dict[str, Any]:
return _json_schema(
cls._JSON_PATTERN,
return _path_json_schema(
sorted(cls._ROOTS),
"Readable graph path rooted at input, state, or context.",
)
@@ -220,20 +283,30 @@ class StatePath:
return cls(value.parts)
if isinstance(value, str):
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(
validate,
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
def __get_pydantic_json_schema__(
cls, _core_schema: core_schema.CoreSchema, _handler: object
) -> 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]]:
+9 -2
View File
@@ -1,6 +1,6 @@
from __future__ import annotations
from typing import TypeAlias
from typing import Any, TypeAlias
from wf_artifacts import WorkflowCapabilityRef
from wf_platform import CapabilityRef
@@ -8,13 +8,20 @@ from wf_platform import CapabilityRef
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.
MCP tools still accept and return plain strings. Internally, workflow-facing
capability ids are either live source capabilities or saved wrapper
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:
return WorkflowCapabilityRef.parse(value)
except ValueError:
+14 -2
View File
@@ -88,7 +88,7 @@ class CapabilityRef:
return core_schema.no_info_plain_validator_function(
cls._validate,
serialization=core_schema.plain_serializer_function_ser_schema(
str,
cls._serialize,
when_used="json",
),
)
@@ -99,4 +99,16 @@ class CapabilityRef:
return value
if isinstance(value, str):
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}
+56 -6
View File
@@ -41,7 +41,10 @@ def test_workflow_artifact_serializes_required_capability_contract() -> None:
assert dumped["version"] == 1
assert dumped["outcomes"] == ["done", "failed"]
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 artifact.required_capability_map()["context7.query-docs"] == capability
@@ -66,7 +69,8 @@ def test_workflow_artifact_can_be_marked_as_wrapper_intent() -> None:
def test_workflow_artifact_accepts_legacy_required_capability_map_and_dumps_list() -> (
None
):
artifact = WorkflowArtifact.model_validate({
artifact = WorkflowArtifact.model_validate(
{
"id": "legacy_capabilities",
"version": 1,
"title": "Legacy Capabilities",
@@ -82,19 +86,63 @@ def test_workflow_artifact_accepts_legacy_required_capability_map_and_dumps_list
"observed_concrete_source": "demo.personal",
}
},
})
}
)
dumped = artifact.model_dump(mode="json")
required = dumped["required_capabilities"][0]
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 "logical_source" not in required
assert "capability_name" not in required
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:
deployment = WorkflowDeployment(
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:
deployment = WorkflowDeployment.model_validate({
deployment = WorkflowDeployment.model_validate(
{
"id": "legacy_bindings.personal",
"artifact_id": "legacy_bindings",
"artifact_version": 1,
"bindings": {"demo": "demo.personal"},
})
}
)
dumped = deployment.model_dump(mode="json")
binding = dumped["bindings"][0]
+9 -5
View File
@@ -73,7 +73,8 @@ def test_file_store_loads_legacy_artifact_and_rewrites_canonical_shape(
artifact_dir.mkdir(parents=True)
artifact_path = artifact_dir / "1.json"
artifact_path.write_text(
json.dumps({
json.dumps(
{
"id": "legacy_capabilities",
"version": 1,
"title": "Legacy Capabilities",
@@ -87,7 +88,8 @@ def test_file_store_loads_legacy_artifact_and_rewrites_canonical_shape(
"input_schema_hash": "sha256:input",
}
},
}),
}
),
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"))
required = rewritten["required_capabilities"][0]
assert required["ref"] == "demo.echo"
assert required["ref"] == {"source": "demo", "capability_key": "echo"}
assert required["kind"] == "tool"
assert "logical_source" 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)
deployment_path = store.deployments_dir / "legacy_bindings.personal.json"
deployment_path.write_text(
json.dumps({
json.dumps(
{
"id": "legacy_bindings.personal",
"artifact_id": "legacy_bindings",
"artifact_version": 1,
"bindings": {"demo": "demo.personal"},
}),
}
),
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]
assert dumped_node["input"][0]["path"] == "input.text"
assert dumped_node["input"][0]["target"] == "text"
assert dumped_node["output"][0]["source"] == "text"
assert dumped_node["output"][0]["target"] == "state.text"
assert dumped_node["input"][0]["path"] == {"root": "input", "parts": ["text"]}
assert dumped_node["input"][0]["target"] == {"root": "local", "parts": ["text"]}
assert dumped_node["output"][0]["source"] == {"root": "local", "parts": ["text"]}
assert dumped_node["output"][0]["target"] == {"root": "state", "parts": ["text"]}
assert "in_map" not in dumped_node
assert "input_values" 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")
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"][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:
@@ -28,10 +31,16 @@ def test_condition_dsl_compiles_authoring_paths_to_typed_core_paths() -> None:
assert isinstance(comparison.right, PathOperand)
assert comparison.left.path == GraphSourcePath.state("score")
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 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:
+85 -41
View File
@@ -6,7 +6,8 @@ from wf_core.paths import GraphSourcePath, LocalPath, StatePath
def test_node_use_accepts_canonical_input_and_output_bindings():
node = NodeUse.model_validate({
node = NodeUse.model_validate(
{
"id": "echo",
"type": "node",
"node": "echo",
@@ -15,7 +16,8 @@ def test_node_use_accepts_canonical_input_and_output_bindings():
{"target": "mode", "value": None},
],
"output": [{"source": "echoed", "target": "state.echoed"}],
})
}
)
path_binding = node.input[0]
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():
node = NodeUse.model_validate({
node = NodeUse.model_validate(
{
"id": "echo",
"type": "node",
"node": "echo",
"in_map": {"input.message": "message"},
"input_values": {"mode": "fast"},
"out_map": {"echoed": "state.echoed"},
})
}
)
dumped = node.model_dump(mode="json")
assert "in_map" not in dumped
assert "input_values" not in dumped
assert "out_map" not in dumped
assert dumped["input"][0]["value"] == "fast"
assert dumped["input"][0]["target"] == "mode"
assert dumped["input"][1]["path"] == "input.message"
assert dumped["input"][1]["target"] == "message"
assert dumped["output"][0]["source"] == "echoed"
assert dumped["output"][0]["target"] == "state.echoed"
assert dumped["input"][0]["target"] == {"root": "local", "parts": ["mode"]}
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"]}
def test_node_use_serializes_canonical_binding_paths_as_strings_in_all_dump_modes():
node = NodeUse.model_validate({
def test_node_use_serializes_canonical_binding_paths_as_structural_json():
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"
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({
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():
with pytest.raises(ValidationError):
NodeUse.model_validate({
NodeUse.model_validate(
{
"id": "bad",
"type": "node",
"node": "bad",
"input": [{"target": "message", "path": "input.message", "value": "x"}],
})
}
)
def test_input_binding_rejects_neither_path_nor_value():
with pytest.raises(ValidationError):
NodeUse.model_validate({
NodeUse.model_validate(
{
"id": "bad",
"type": "node",
"node": "bad",
"input": [{"target": "message"}],
})
}
)
@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]):
with pytest.raises(ValidationError):
NodeUse.model_validate({
NodeUse.model_validate(
{
"id": "bad",
"type": "node",
"node": "bad",
field: [binding],
})
}
)
@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):
with pytest.raises(ValidationError):
NodeUse.model_validate({
NodeUse.model_validate(
{
"id": "bad",
"type": "node",
"node": "bad",
field: value,
})
}
)
def test_deprecated_conversion_preserves_input_value_then_in_map_order():
node = NodeUse.model_validate({
node = NodeUse.model_validate(
{
"id": "ordered",
"type": "node",
"node": "ordered",
"input_values": {"first": 1, "second": 2},
"in_map": {"input.third": "third", "state.fourth": "fourth"},
})
}
)
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[1]["target"] == "second"
assert dumped_input[1]["target"] == {"root": "local", "parts": ["second"]}
assert dumped_input[1]["value"] == 2
assert dumped_input[2]["target"] == "third"
assert dumped_input[2]["path"] == "input.third"
assert dumped_input[3]["target"] == "fourth"
assert dumped_input[3]["path"] == "state.fourth"
assert dumped_input[2]["target"] == {"root": "local", "parts": ["third"]}
assert dumped_input[2]["path"] == {"root": "input", "parts": ["third"]}
assert dumped_input[3]["target"] == {"root": "local", "parts": ["fourth"]}
assert dumped_input[3]["path"] == {"root": "state", "parts": ["fourth"]}
def test_deprecated_input_value_preserves_explicit_null():
node = NodeUse.model_validate({
node = NodeUse.model_validate(
{
"id": "null",
"type": "node",
"node": "null",
"input_values": {"maybe": None},
})
}
)
value_binding = node.input[0]
assert isinstance(value_binding, InputValueBinding)
assert value_binding.value is None
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
+77 -33
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"
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:
assert str(StatePath.of("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..name",
"state.items.0",
"state.user-name",
"state.items[0]",
"output.foo",
],
)
@@ -71,9 +87,6 @@ def test_graph_source_paths_reject_invalid_segments(raw: str) -> None:
[
"state.",
"state..name",
"state.items.0",
"state.user-name",
"state.items[0]",
],
)
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(
("factory", "args"),
[
(GraphSourcePath, ("output", ("user-name",))),
(StatePath, (("0",),)),
(LocalPath, (("items[0]",),)),
(GraphSourcePath, ("output", ("user",))),
(StatePath, (("",),)),
(LocalPath, ((" ",),)),
],
)
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.
source = object.__new__(GraphSourcePath)
object.__setattr__(source, "root", "output")
object.__setattr__(source, "parts", ("user-name",))
object.__setattr__(source, "parts", ("user",))
target = object.__new__(StatePath)
object.__setattr__(target, "parts", ("0",))
object.__setattr__(target, "parts", ("",))
local = object.__new__(LocalPath)
object.__setattr__(local, "parts", ("items[0]",))
object.__setattr__(local, "parts", (" ",))
with pytest.raises(ValidationError):
Payload.model_validate({
Payload.model_validate(
{
"source": source,
"target": StatePath.of("person"),
"local": LocalPath.root(),
})
}
)
with pytest.raises(ValidationError):
Payload.model_validate({
Payload.model_validate(
{
"source": GraphSourcePath.input("user"),
"target": target,
"local": LocalPath.root(),
})
}
)
with pytest.raises(ValidationError):
Payload.model_validate({
Payload.model_validate(
{
"source": GraphSourcePath.input("user"),
"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):
source: GraphSourcePath
target: StatePath
local: LocalPath
payload = Payload.model_validate({
payload = Payload.model_validate(
{
"source": "input.user",
"target": "state.person",
"local": "user",
})
}
)
assert payload.source == GraphSourcePath.input("user")
assert payload.target == StatePath.of("person")
assert payload.local == LocalPath.of("user")
dumped = payload.model_dump(mode="json")
assert dumped["source"] == "input.user"
assert dumped["target"] == "state.person"
assert dumped["local"] == "user"
assert dumped["source"] == {"root": "input", "parts": ["user"]}
assert dumped["target"] == {"root": "state", "parts": ["person"]}
assert dumped["local"] == {"root": "local", "parts": ["user"]}
python_dumped = payload.model_dump()
assert python_dumped["source"] == "input.user"
assert python_dumped["target"] == "state.person"
assert python_dumped["local"] == "user"
assert python_dumped["source"] == {"root": "input", "parts": ["user"]}
assert python_dumped["target"] == {"root": "state", "parts": ["person"]}
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"})
assert operand.model_dump()["path"] == "state.x"
assert operand.model_dump(mode="json")["path"] == "state.x"
assert operand.model_dump()["path"] == {"root": "state", "parts": ["x"]}
assert operand.model_dump(mode="json")["path"] == {
"root": "state",
"parts": ["x"],
}
def test_pydantic_accepts_existing_path_objects() -> None:
@@ -180,11 +222,13 @@ def test_pydantic_accepts_existing_path_objects() -> None:
target: StatePath
local: LocalPath
payload = Payload.model_validate({
payload = Payload.model_validate(
{
"source": GraphSourcePath.state("person"),
"target": StatePath.of("person.name"),
"local": LocalPath.root(),
})
}
)
assert str(payload.source) == "state.person"
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("state.person", {"person"}, set()) 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.person") is True
@@ -29,10 +29,10 @@ def test_raw_canonical_workflow_serializes_new_shape() -> None:
assert "in_map" not in node
assert "input_values" not in node
assert "out_map" not in node
assert node["input"][0]["path"] == "input.text"
assert node["input"][0]["target"] == "text"
assert node["input"][0]["path"] == {"root": "input", "parts": ["text"]}
assert node["input"][0]["target"] == {"root": "local", "parts": ["text"]}
assert node["input"][1]["value"] == "raw:"
assert node["output"][0]["source"] == "message"
assert node["output"][0]["target"] == "state.message"
assert node["output"][0]["source"] == {"root": "local", "parts": ["message"]}
assert node["output"][0]["target"] == {"root": "state", "parts": ["message"]}
assert message_schema["type"] == "string"
assert message_schema["reducer"] == "wf.std.replace"
+34 -3
View File
@@ -33,16 +33,47 @@ def test_platform_refs_validate_and_serialize_through_pydantic() -> None:
source: SourceRef
capability: CapabilityRef
payload = Payload.model_validate({
payload = Payload.model_validate(
{
"source": "demo.personal",
"capability": "demo.personal.echo_tool",
})
}
)
assert payload.source == SourceRef.parse("demo.personal")
assert payload.capability == CapabilityRef.parse("demo.personal.echo_tool")
assert payload.model_dump(mode="json") == {
"source": "demo.personal",
"capability": "demo.personal.echo_tool",
"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 pydantic import BaseModel
from wf_artifacts import WorkflowCapabilityRef
@@ -17,6 +19,40 @@ def test_workflow_capability_ref_preserves_dotted_artifact_ids() -> None:
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:
try:
WorkflowCapabilityRef.parse("demo.echo.v1")
+52 -1
View File
@@ -5,7 +5,7 @@ import shutil
from typing import Any, cast
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_mcp.broker import WfMcpService
from wf_mcp.models import AuthRecord, ConnectionConfig, RawWorkflowPlan
@@ -20,6 +20,8 @@ from wf_platform import (
)
from .test_support import (
EchoInput,
EchoOutput,
FailingDiscoveryAdapter,
FakeAdapter,
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:
return _raw_plan(
name=plan_name,
@@ -476,6 +483,50 @@ def test_service_runs_logical_source_plan_with_dotted_local_name() -> None:
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:
service = WfMcpService(store=FileStore(local_temp_root() / "hidden_spec_store"))
hidden_echo_tool = NodeSpec(
@@ -14,6 +14,19 @@ def test_workflow_surface_capability_id_parses_live_capability_ref() -> None:
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:
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 capability.artifact_id == "echo_wrapper"
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"