fix: advertise structural workflow paths in schemas

This commit is contained in:
lda
2026-06-30 01:33:02 +07:00 Verified
parent 0f340bb8a5
commit 30ad99fca1
6 changed files with 101 additions and 30 deletions
+4 -3
View File
@@ -280,9 +280,10 @@ stable.
- Completed: challenge matrix operations now have compact OpenCode thread
titles, policy handling for canonical skill-document reads, and a central
`summarize_trials.py` command for audited result tables.
- Completed: canonical TOML path strings replace structural JSON path objects
in workflow drafts. Paths now emit as `"input.text"`, `"state.echoed"`,
`"message"` (local) instead of `{"root": "input", "parts": ["text"]}`.
- Completed: canonical TOML path strings are the emitted workflow path form.
Paths now serialize as `"input.text"`, `"state.echoed"`, and `"message"`
(local). Structural `{"root": "input", "parts": ["text"]}` path objects
remain accepted and are now advertised in generated schemas as an input form.
- Completed: challenge-driven CLI UX fixes now provide exact available
deployment binding suggestions, reject bare `--bind-output` state targets
before RPC with compact guidance, and accept `wf schema --full` as an alias
+3 -2
View File
@@ -84,8 +84,9 @@ New canonical graph path JSON uses TOML-key strings:
"input.message"
```
Structural root/parts objects are still accepted at model-parse boundaries for
old persisted records, but new schemas and new examples should emit strings.
Structural root/parts objects are still accepted at model-parse boundaries and
are advertised by generated schemas as an input form. Serializers and new
examples should emit strings.
Quote a segment when the field name itself contains a dot or space, for example
`state."person.name"` or `state.person."full name"`.
@@ -313,12 +313,11 @@ formatter. The shared grammar must support quoted TOML keys for literal dots,
spaces, and other non-bare segments. Parse errors identify the complete input
and recommend quoting the invalid segment.
Pydantic JSON schemas advertise path strings rather than the structural
`{root, parts}` object. Serializers emit canonical strings. Validators continue
to accept the structural object only as a read-compatibility path for existing
persisted drafts, artifacts, and runs; new public examples and writes use
strings. This is compatibility for real stored data, not a second documented
syntax.
Pydantic JSON schemas advertise both canonical path strings and structural
`{root, parts}` objects. Serializers emit canonical strings. Validators continue
to accept structural objects so machine clients and old persisted drafts,
artifacts, and runs can use the explicit root/parts form; new public examples
and writes prefer strings.
## Compatibility And Migration
@@ -346,7 +345,7 @@ shim is added without a real external caller.
- an inferred route targets `__end__` unless explicitly overridden;
- output binding still projects referenced schema definitions;
- all path models parse and serialize the canonical TOML-key string grammar;
- structural path objects remain readable but are not emitted;
- structural path objects remain accepted input but are not emitted;
- stored-workspace compile equals `compile_workflow_draft` output;
- compile does not change revision, timestamps, status, or diagnostics.
+13 -7
View File
@@ -18,15 +18,18 @@ class InputPathBinding(BaseModel):
target: LocalPath = Field(
description=(
"Node-local input path to populate. Use {'root': 'local', "
"'parts': ['field']} or {'root': 'local', 'parts': []} for the "
"whole node input payload."
"Node-local input path to populate. Prefer canonical strings such "
"as `field` or `.` for the whole node input payload. Structural "
"objects such as {'root': 'local', 'parts': ['field']} are also "
"accepted as input."
)
)
path: GraphSourcePath = Field(
description=(
"Workflow source path to read from input, state, or context. "
"Example: {'root': 'input', 'parts': ['text']}."
"Prefer canonical strings such as `input.text` or `state.report`. "
"Structural objects such as {'root': 'input', 'parts': ['text']} "
"are also accepted as input."
)
)
@@ -67,14 +70,17 @@ class OutputBinding(BaseModel):
source: LocalPath = Field(
description=(
"Node-local output path to read. Use {'root': 'local', 'parts': []} "
"to write the whole node output payload."
"Node-local output path to read. Prefer canonical strings such as "
"`result` or `.` for the whole node output payload. Structural "
"objects such as {'root': 'local', 'parts': []} are also accepted "
"as input."
)
)
target: StatePath = Field(
description=(
"Writable workflow state path. Bare state is invalid; use a field "
"path such as {'root': 'state', 'parts': ['echoed']}."
"path such as `state.echoed`. Structural objects such as "
"{'root': 'state', 'parts': ['echoed']} are also accepted as input."
)
)
+46 -6
View File
@@ -78,15 +78,47 @@ def format_toml_path_segments(parts: tuple[str, ...]) -> str:
)
def _path_json_schema(description: str) -> dict[str, Any]:
"""Return the canonical string schema for path fields.
def _path_json_schema(
description: str, *, roots: tuple[str, ...], allow_empty_parts: bool
) -> dict[str, Any]:
"""Return the public schema for path fields.
Structural objects remain input-only compatibility for persisted records.
New schemas and serializers expose the canonical TOML-key string form.
Strings are the canonical serialized form. Structural objects are still a
first-class input form, so the JSON Schema must advertise both; otherwise
machine clients tend to quote structural objects and produce invalid TOML
path strings.
"""
root_schema: dict[str, Any]
if len(roots) == 1:
root_schema = {"const": roots[0]}
else:
root_schema = {"enum": list(roots)}
min_items = 0 if allow_empty_parts else 1
return {
"type": "string",
"description": description,
"oneOf": [
{
"type": "string",
"description": "Canonical TOML-key path string.",
},
{
"type": "object",
"description": ("Structural path object accepted as input."),
"additionalProperties": False,
"required": ["root", "parts"],
"properties": {
"root": {
"type": "string",
**root_schema,
},
"parts": {
"type": "array",
"items": {"type": "string", "minLength": 1},
"minItems": min_items,
},
},
},
],
}
@@ -185,6 +217,8 @@ class LocalPath:
) -> dict[str, Any]:
return _path_json_schema(
"Node-local path. Use the root marker `.` for the whole payload.",
roots=("local",),
allow_empty_parts=True,
)
@@ -269,6 +303,8 @@ class GraphSourcePath:
) -> dict[str, Any]:
return _path_json_schema(
"Readable graph path rooted at input, state, or context.",
roots=("input", "state", "context"),
allow_empty_parts=True,
)
@@ -334,7 +370,11 @@ class StatePath:
def __get_pydantic_json_schema__(
cls, _core_schema: core_schema.CoreSchema, _handler: object
) -> dict[str, Any]:
return _path_json_schema("Writable workflow state path.")
return _path_json_schema(
"Writable workflow state path.",
roots=("state",),
allow_empty_parts=False,
)
def split_graph_path(path: str | GraphSourcePath | StatePath) -> tuple[str, list[str]]:
+29 -5
View File
@@ -190,7 +190,7 @@ def test_pydantic_accepts_path_strings_and_serializes_path_strings() -> None:
assert python_dumped["local"] == "user"
def test_path_json_schema_advertises_string_type() -> None:
def test_path_json_schema_advertises_strings_and_structural_objects() -> None:
class Payload(BaseModel):
source: GraphSourcePath
target: StatePath
@@ -198,9 +198,22 @@ def test_path_json_schema_advertises_string_type() -> None:
schema = Payload.model_json_schema()
assert schema["properties"]["source"]["type"] == "string"
assert schema["properties"]["target"]["type"] == "string"
assert schema["properties"]["local"]["type"] == "string"
assert schema["properties"]["source"]["oneOf"][0]["type"] == "string"
assert schema["properties"]["source"]["oneOf"][1]["properties"]["root"]["enum"] == [
"input",
"state",
"context",
]
assert schema["properties"]["target"]["oneOf"][0]["type"] == "string"
assert (
schema["properties"]["target"]["oneOf"][1]["properties"]["root"]["const"]
== "state"
)
assert schema["properties"]["local"]["oneOf"][0]["type"] == "string"
assert (
schema["properties"]["local"]["oneOf"][1]["properties"]["root"]["const"]
== "local"
)
def test_condition_path_operand_serializes_path_as_string() -> None:
@@ -354,4 +367,15 @@ def test_path_models_serialize_strings_but_accept_structural_compat() -> None:
"target": 'state."person name"',
"local": '"payload.text"',
}
assert Payload.model_json_schema()["properties"]["source"]["type"] == "string"
assert (
Payload.model_json_schema()["properties"]["source"]["oneOf"][0]["type"]
== "string"
)
def test_json_encoded_structural_path_string_is_invalid() -> None:
class Payload(BaseModel):
source: GraphSourcePath
with pytest.raises(ValidationError, match="invalid TOML path"):
Payload.model_validate({"source": '{"root":"input","parts":["button_label"]}'})