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 - Completed: challenge matrix operations now have compact OpenCode thread
titles, policy handling for canonical skill-document reads, and a central titles, policy handling for canonical skill-document reads, and a central
`summarize_trials.py` command for audited result tables. `summarize_trials.py` command for audited result tables.
- Completed: canonical TOML path strings replace structural JSON path objects - Completed: canonical TOML path strings are the emitted workflow path form.
in workflow drafts. Paths now emit as `"input.text"`, `"state.echoed"`, Paths now serialize as `"input.text"`, `"state.echoed"`, and `"message"`
`"message"` (local) instead of `{"root": "input", "parts": ["text"]}`. (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 - Completed: challenge-driven CLI UX fixes now provide exact available
deployment binding suggestions, reject bare `--bind-output` state targets deployment binding suggestions, reject bare `--bind-output` state targets
before RPC with compact guidance, and accept `wf schema --full` as an alias 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" "input.message"
``` ```
Structural root/parts objects are still accepted at model-parse boundaries for Structural root/parts objects are still accepted at model-parse boundaries and
old persisted records, but new schemas and new examples should emit strings. 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 Quote a segment when the field name itself contains a dot or space, for example
`state."person.name"` or `state.person."full name"`. `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 spaces, and other non-bare segments. Parse errors identify the complete input
and recommend quoting the invalid segment. and recommend quoting the invalid segment.
Pydantic JSON schemas advertise path strings rather than the structural Pydantic JSON schemas advertise both canonical path strings and structural
`{root, parts}` object. Serializers emit canonical strings. Validators continue `{root, parts}` objects. Serializers emit canonical strings. Validators continue
to accept the structural object only as a read-compatibility path for existing to accept structural objects so machine clients and old persisted drafts,
persisted drafts, artifacts, and runs; new public examples and writes use artifacts, and runs can use the explicit root/parts form; new public examples
strings. This is compatibility for real stored data, not a second documented and writes prefer strings.
syntax.
## Compatibility And Migration ## Compatibility And Migration
@@ -346,7 +345,7 @@ shim is added without a real external caller.
- an inferred route targets `__end__` unless explicitly overridden; - an inferred route targets `__end__` unless explicitly overridden;
- output binding still projects referenced schema definitions; - output binding still projects referenced schema definitions;
- all path models parse and serialize the canonical TOML-key string grammar; - 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; - stored-workspace compile equals `compile_workflow_draft` output;
- compile does not change revision, timestamps, status, or diagnostics. - compile does not change revision, timestamps, status, or diagnostics.
+13 -7
View File
@@ -18,15 +18,18 @@ class InputPathBinding(BaseModel):
target: LocalPath = Field( target: LocalPath = Field(
description=( description=(
"Node-local input path to populate. Use {'root': 'local', " "Node-local input path to populate. Prefer canonical strings such "
"'parts': ['field']} or {'root': 'local', 'parts': []} for the " "as `field` or `.` for the whole node input payload. Structural "
"whole node input payload." "objects such as {'root': 'local', 'parts': ['field']} are also "
"accepted as input."
) )
) )
path: GraphSourcePath = Field( path: GraphSourcePath = Field(
description=( description=(
"Workflow source path to read from input, state, or context. " "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( source: LocalPath = Field(
description=( description=(
"Node-local output path to read. Use {'root': 'local', 'parts': []} " "Node-local output path to read. Prefer canonical strings such as "
"to write the whole node output payload." "`result` or `.` for the whole node output payload. Structural "
"objects such as {'root': 'local', 'parts': []} are also accepted "
"as input."
) )
) )
target: StatePath = Field( target: StatePath = Field(
description=( description=(
"Writable workflow state path. Bare state is invalid; use a field " "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]: def _path_json_schema(
"""Return the canonical string schema for path fields. 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. Strings are the canonical serialized form. Structural objects are still a
New schemas and serializers expose the canonical TOML-key string form. 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 { return {
"type": "string",
"description": description, "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]: ) -> dict[str, Any]:
return _path_json_schema( return _path_json_schema(
"Node-local path. Use the root marker `.` for the whole payload.", "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]: ) -> dict[str, Any]:
return _path_json_schema( return _path_json_schema(
"Readable graph path rooted at input, state, or context.", "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__( 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 _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]]: 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" 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): class Payload(BaseModel):
source: GraphSourcePath source: GraphSourcePath
target: StatePath target: StatePath
@@ -198,9 +198,22 @@ def test_path_json_schema_advertises_string_type() -> None:
schema = Payload.model_json_schema() schema = Payload.model_json_schema()
assert schema["properties"]["source"]["type"] == "string" assert schema["properties"]["source"]["oneOf"][0]["type"] == "string"
assert schema["properties"]["target"]["type"] == "string" assert schema["properties"]["source"]["oneOf"][1]["properties"]["root"]["enum"] == [
assert schema["properties"]["local"]["type"] == "string" "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: 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"', "target": 'state."person name"',
"local": '"payload.text"', "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"]}'})