fix: lower draft output bindings through state

This commit is contained in:
lda
2026-06-29 09:53:12 +07:00 Verified
parent fe63eb08d4
commit 0425c697f3
10 changed files with 482 additions and 50 deletions
+7 -5
View File
@@ -280,11 +280,13 @@ stable.
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
for `--verbose`. for `--verbose`.
- Planned: close the next draft-authoring UX gaps found by debug challenge - Completed: `wf draft bind local.x -> output.y` now lowers through state
runs: add a first-class workflow-level output command, improve schema-aware atomically (projecting into both state_schema and output_schema), and
`wf draft bind` discoverability and repair hints for workflow input/output validation repair hints cover undeclared workflow input source paths.
projection, and stop auto-binding optional capability inputs unless explicitly Implementation plan:
requested or safely defaulted. [`bind repair hints`](historical/superpowers/plans/2026-06-29-draft-bind-repair-hints.md).
- Planned: stop capability-backed draft creation from auto-binding optional
capability inputs unless they are explicitly requested or safely defaulted.
## Historical References ## Historical References
@@ -17,7 +17,7 @@
- Test: `tests/wf_api/test_drafts_service.py` - Test: `tests/wf_api/test_drafts_service.py`
- Test: `tests/wf_cli/test_remote_target.py` - Test: `tests/wf_cli/test_remote_target.py`
- [ ] **Step 1: Write failing tests** - [x] **Step 1: Write failing tests**
Add a capability-backed `render` step whose capability output schema declares `markdown`. Call: Add a capability-backed `render` step whose capability output schema declares `markdown`. Call:
@@ -46,7 +46,7 @@ assert workspace["draft"]["state_schema"]["properties"]["markdown"]["type"] == "
assert workspace["draft"]["output_schema"]["properties"]["markdown"]["type"] == "string" assert workspace["draft"]["output_schema"]["properties"]["markdown"]["type"] == "string"
``` ```
- [ ] **Step 2: Run tests RED** - [x] **Step 2: Run tests RED**
Run: Run:
@@ -56,7 +56,7 @@ uv run pytest tests/wf_api/test_drafts_service.py::test_bind_draft_local_output_
Expected: fail because current code writes an illegal `output.*` node destination. Expected: fail because current code writes an illegal `output.*` node destination.
- [ ] **Step 3: Implement hint branch** - [x] **Step 3: Implement hint branch**
In `WorkflowDraftAuthoringApi.bind_draft`, split the existing local-output branch: In `WorkflowDraftAuthoringApi.bind_draft`, split the existing local-output branch:
@@ -71,11 +71,11 @@ if source_root == "local" and target_root == "output":
Use `project_property_to_schema_path` for both schemas so `$defs` are preserved. Do not create a node output binding with an `output.*` target; `OutputBinding.target` is `StatePath`. Use `project_property_to_schema_path` for both schemas so `$defs` are preserved. Do not create a node output binding with an `output.*` target; `OutputBinding.target` is `StatePath`.
- [ ] **Step 4: Run tests GREEN** - [x] **Step 4: Run tests GREEN**
Run the test from Step 2. Expected: pass with `status: valid`. Run the test from Step 2. Expected: pass with `status: valid`.
- [ ] **Step 5: Commit** - [x] **Step 5: Prepare for the integration commit**
```powershell ```powershell
git add src/wf_api/draft_authoring.py tests/wf_api/test_drafts_service.py tests/wf_cli/test_remote_target.py git add src/wf_api/draft_authoring.py tests/wf_api/test_drafts_service.py tests/wf_cli/test_remote_target.py
@@ -89,7 +89,7 @@ git commit -m "fix: lower workflow output binds through state"
- Modify: `src/wf_api/drafts.py` - Modify: `src/wf_api/drafts.py`
- Test: `tests/wf_api/test_drafts_service.py` - Test: `tests/wf_api/test_drafts_service.py`
- [ ] **Step 1: Add diagnostic details** - [x] **Step 1: Add diagnostic details**
When core reports `invalid_source_path` for a step input path like `steps.wait.input[0].path`, draft diagnostics should include enough details to build a hint: When core reports `invalid_source_path` for a step input path like `steps.wait.input[0].path`, draft diagnostics should include enough details to build a hint:
@@ -103,7 +103,7 @@ When core reports `invalid_source_path` for a step input path like `steps.wait.i
Write a failing test that validates a draft using `input.simulate` without declaring `input_schema.properties.simulate` and asserts those details exist. Write a failing test that validates a draft using `input.simulate` without declaring `input_schema.properties.simulate` and asserts those details exist.
- [ ] **Step 2: Run test RED** - [x] **Step 2: Run test RED**
Run: Run:
@@ -113,7 +113,7 @@ uv run pytest tests/wf_api/test_drafts_service.py::test_validate_draft_workspace
Expected: fail because details are missing or incomplete. Expected: fail because details are missing or incomplete.
- [ ] **Step 3: Add repair hint** - [x] **Step 3: Add repair hint**
In `_draft_repair_hint`, if code is `invalid_source_path`, details include a step id, and `source_path` starts with `input.`, return: In `_draft_repair_hint`, if code is `invalid_source_path`, details include a step id, and `source_path` starts with `input.`, return:
@@ -123,7 +123,7 @@ wf draft bind <workspace> --revision <n> --step <step_id> --from input.<field> -
This command declares the workflow input schema field from the capability input field and merges the step input binding. This command declares the workflow input schema field from the capability input field and merges the step input binding.
- [ ] **Step 4: Run tests GREEN** - [x] **Step 4: Run tests GREEN**
Run: Run:
@@ -131,7 +131,7 @@ Run:
uv run pytest tests/wf_api/test_drafts_service.py::test_validate_draft_workspace_details_invalid_input_source_path tests/wf_api/test_drafts_service.py::test_validate_draft_workspace_hints_input_schema_projection -q uv run pytest tests/wf_api/test_drafts_service.py::test_validate_draft_workspace_details_invalid_input_source_path tests/wf_api/test_drafts_service.py::test_validate_draft_workspace_hints_input_schema_projection -q
``` ```
- [ ] **Step 5: Commit** - [x] **Step 5: Prepare for the integration commit**
```powershell ```powershell
git add src/wf_artifacts/drafts/api.py src/wf_api/drafts.py tests/wf_api/test_drafts_service.py git add src/wf_artifacts/drafts/api.py src/wf_api/drafts.py tests/wf_api/test_drafts_service.py
@@ -146,7 +146,7 @@ git commit -m "fix: hint workflow input schema repairs"
- Modify: `skills/wf-workflow/references/draft-workspaces.md` - Modify: `skills/wf-workflow/references/draft-workspaces.md`
- Modify: `docs/current_roadmap.md` - Modify: `docs/current_roadmap.md`
- [ ] **Step 1: Add repair-hint examples** - [x] **Step 1: Add repair-hint examples**
Document: Document:
@@ -156,7 +156,7 @@ wf draft bind report_ws --revision 5 --step render --from local.markdown --to ou
wf draft set-workflow-output report_ws --revision 6 --map state.markdown=markdown wf draft set-workflow-output report_ws --revision 6 --map state.markdown=markdown
``` ```
- [ ] **Step 2: Add skill rule** - [x] **Step 2: Add skill rule**
Add: Add:
@@ -164,7 +164,7 @@ Add:
When validation gives a `repair_hint`, run that exact focused command before JSON Patch. Use `wf draft bind local.x -> output.y` when one capability output should become public workflow output; it creates the required state intermediary and schemas atomically. When validation gives a `repair_hint`, run that exact focused command before JSON Patch. Use `wf draft bind local.x -> output.y` when one capability output should become public workflow output; it creates the required state intermediary and schemas atomically.
``` ```
- [ ] **Step 3: Verify** - [x] **Step 3: Verify**
Run: Run:
@@ -174,7 +174,7 @@ uv run ruff check src/wf_api src/wf_artifacts tests/wf_api tests/wf_cli
uv run basedpyright --level error src/wf_api/drafts.py src/wf_artifacts/drafts/api.py tests/wf_api/test_drafts_service.py uv run basedpyright --level error src/wf_api/drafts.py src/wf_artifacts/drafts/api.py tests/wf_api/test_drafts_service.py
``` ```
- [ ] **Step 4: Commit** - [x] **Step 4: Prepare for the integration commit**
```powershell ```powershell
git add docs/wf_cli.md skills/wf-cli/SKILL.md skills/wf-workflow/references/draft-workspaces.md docs/current_roadmap.md git add docs/wf_cli.md skills/wf-cli/SKILL.md skills/wf-workflow/references/draft-workspaces.md docs/current_roadmap.md
+15
View File
@@ -344,9 +344,24 @@ use `input.*` or `state.*` to `local.*` for step inputs, and `local.*` to
```bash ```bash
wf draft bind concat_ws --revision 9 --step call --from local.value --to state.value wf draft bind concat_ws --revision 9 --step call --from local.value --to state.value
wf draft bind concat_ws --revision 9 --step call --from input.text --to local.text wf draft bind concat_ws --revision 9 --step call --from input.text --to local.text
wf draft bind concat_ws --revision 9 --step call --from local.result --to output.result
wf draft validate concat_ws wf draft validate concat_ws
``` ```
When validation gives a `repair_hint` with an exact focused `wf draft bind`
command, run it before falling back to JSON Patch.
Repair-hint examples:
```bash
# Declare an undeclared workflow input field and bind it to a step input
wf draft bind report_ws --revision 4 --step read --from input.path --to local.path
# Lower a capability output through state into workflow output
wf draft bind report_ws --revision 5 --step render --from local.markdown --to output.markdown
# Set workflow output independently (no schema projection)
wf draft set-workflow-output report_ws --revision 6 --map state.markdown=markdown
```
The command combines two common edits: The command combines two common edits:
- It copies the selected capability local field schema into the workflow input, - It copies the selected capability local field schema into the workflow input,
+5 -3
View File
@@ -54,9 +54,11 @@ wf draft add-step <workspace_id> --revision <n> --step <step_id> --capability <q
wf draft validate <workspace_id> wf draft validate <workspace_id>
wf draft save <workspace_id> --artifact <artifact_id> --version <n> --title <title> wf draft save <workspace_id> --artifact <artifact_id> --version <n> --title <title>
When `wf draft validate` returns a `repair_hint`, prefer running that focused When `wf draft validate` returns a `repair_hint`, run that exact focused command
command before writing JSON Patch manually. Re-run `wf draft validate` after the before writing JSON Patch manually. Use `wf draft bind local.x -> output.y` when
repair. one capability output should become public workflow output; it creates the
required state intermediary and projects the field schema into both state and
output schemas atomically. Re-run `wf draft validate` after the repair.
wf artifact create-from-plan workflow.plan.json --artifact <artifact_id> --version <n> --title <title> wf artifact create-from-plan workflow.plan.json --artifact <artifact_id> --version <n> --title <title>
wf deploy save <deployment_id> --artifact <artifact_id> --version <n> --binding <logical>=<concrete> wf deploy save <deployment_id> --artifact <artifact_id> --version <n> --binding <logical>=<concrete>
@@ -128,11 +128,15 @@ entries over multiple revisions.
this over manual JSON Patch when validation says a target schema field is this over manual JSON Patch when validation says a target schema field is
missing. The selected step must have `use` so the helper can find the missing. The selected step must have `use` so the helper can find the
capability schema. It intentionally rejects non-capability/control steps capability schema. It intentionally rejects non-capability/control steps
instead of guessing. instead of guessing. A `local.x -> output.y` bind is atomic: it projects the
capability field schema into both workflow state and output schemas, writes
`local.x -> state.y` on the step, and publishes `state.y -> output.y` at the
workflow boundary.
```bash ```bash
wf draft bind <workspace_id> --revision <n> --step <step_id> --from local.<field> --to state.<field> wf draft bind <workspace_id> --revision <n> --step <step_id> --from local.<field> --to state.<field>
wf draft bind <workspace_id> --revision <n> --step <step_id> --from input.<field> --to local.<field> wf draft bind <workspace_id> --revision <n> --step <step_id> --from input.<field> --to local.<field>
wf draft bind <workspace_id> --revision <n> --step <step_id> --from local.<field> --to output.<field>
wf draft validate <workspace_id> wf draft validate <workspace_id>
``` ```
@@ -176,7 +180,8 @@ an unexpected extra argument because it is not attached to its own flag.
a `compiled_plan`. a `compiled_plan`.
Validation repair hints are product guidance. If a diagnostic suggests Validation repair hints are product guidance. If a diagnostic suggests
`wf draft bind`, use it before hand-editing schemas or step bindings. `wf draft bind`, run that exact focused command before hand-editing schemas or
step bindings, then validate the new revision.
Remove commands are for recovery. They do not delete schema fields and Remove commands are for recovery. They do not delete schema fields and
`remove-step` does not remove inbound routes. Validate after removal and repair `remove-step` does not remove inbound routes. Validate after removal and repair
+98 -11
View File
@@ -12,7 +12,12 @@ from wf_core.models.steps import (
InputBinding, InputBinding,
OutputBinding, OutputBinding,
) )
from wf_core.paths import GraphSourcePath, LocalPath from wf_core.paths import (
GraphSourcePath,
LocalPath,
format_toml_path_segments,
parse_toml_path_segments,
)
from .constants import ( from .constants import (
DEFAULT_CALL_STEP_ID, DEFAULT_CALL_STEP_ID,
@@ -190,11 +195,19 @@ class WorkflowDraftAuthoringApi:
if not source_path.startswith("local.") if not source_path.startswith("local.")
else ("local", LocalPath.parse(source_path).parts) else ("local", LocalPath.parse(source_path).parts)
) )
target_root, target_parts = ( if target_path.startswith("output."):
_graph_parts(target_path) # GraphSourcePath excludes output targets, but output fields still
if not target_path.startswith("local.") # use the same canonical TOML-key grammar as other workflow paths.
else ("local", LocalPath.parse(target_path).parts) output_path_parts = parse_toml_path_segments(target_path)
) target_root = output_path_parts[0]
target_parts = output_path_parts[1:]
if target_root != "output" or not target_parts:
raise ValueError("output path must name a field, such as output.result")
elif target_path.startswith("local."):
target_root = "local"
target_parts = LocalPath.parse(target_path).parts
else:
target_root, target_parts = _graph_parts(target_path)
if target_root == "local" and source_root in {"input", "state"}: if target_root == "local" and source_root in {"input", "state"}:
local_field = _local_field(target_path) local_field = _local_field(target_path)
@@ -228,15 +241,89 @@ class WorkflowDraftAuthoringApi:
], ],
) )
if source_root == "local" and target_root in {"state", "output"}: if source_root == "local" and target_root == "output":
local_field = _local_field(source_path)
output_schema_source = (
spec.output_schema_contract or spec.output_model.model_json_schema()
)
state_path_str = format_toml_path_segments(("state", *target_parts))
output_target_str = format_toml_path_segments(target_parts)
state_schema = workspace.draft.get("state_schema", {})
if not isinstance(state_schema, dict):
raise ValueError("draft state_schema must be an object")
projected_state = project_property_to_schema_path(
target_schema=state_schema,
source_schema=output_schema_source,
source_field=local_field,
target_parts=target_parts,
allow_existing_equivalent=True,
)
output_schema = workspace.draft.get("output_schema", {})
if not isinstance(output_schema, dict):
raise ValueError("draft output_schema must be an object")
projected_output = project_property_to_schema_path(
target_schema=output_schema,
source_schema=output_schema_source,
source_field=local_field,
target_parts=target_parts,
allow_existing_equivalent=True,
)
output_map = {
**self.drafts._step_output_map(
workspace_id=workspace_id, step_id=step_id
),
local_field: state_path_str,
}
existing_output = workspace.draft.get("output")
if isinstance(existing_output, list):
output_bindings = [
b
for b in existing_output
if not (
isinstance(b, dict) and b.get("target") == output_target_str
)
]
else:
output_bindings = []
output_bindings.append(
{"path": state_path_str, "target": output_target_str}
)
return await self.drafts.patch_draft_workspace(
workspace_id=workspace_id,
revision=revision,
patch=[
{
"op": "replace",
"path": "/state_schema",
"value": projected_state,
},
{
"op": "replace",
"path": "/output_schema",
"value": projected_output,
},
{
"op": "replace",
"path": f"/steps/{escape_json_pointer(step_id)}/output",
"value": output_bindings_payload(output_map),
},
{"op": "replace", "path": "/output", "value": output_bindings},
],
)
if source_root == "local" and target_root == "state":
local_field = _local_field(source_path) local_field = _local_field(source_path)
output_schema = ( output_schema = (
spec.output_schema_contract or spec.output_model.model_json_schema() spec.output_schema_contract or spec.output_model.model_json_schema()
) )
schema_key = "state_schema" if target_root == "state" else "output_schema" target_schema = workspace.draft.get("state_schema", {})
target_schema = workspace.draft.get(schema_key, {})
if not isinstance(target_schema, dict): if not isinstance(target_schema, dict):
raise ValueError(f"draft {schema_key} must be an object") raise ValueError("draft state_schema must be an object")
projected = project_property_to_schema_path( projected = project_property_to_schema_path(
target_schema=target_schema, target_schema=target_schema,
source_schema=output_schema, source_schema=output_schema,
@@ -253,7 +340,7 @@ class WorkflowDraftAuthoringApi:
workspace_id=workspace_id, workspace_id=workspace_id,
revision=revision, revision=revision,
patch=[ patch=[
{"op": "replace", "path": f"/{schema_key}", "value": projected}, {"op": "replace", "path": "/state_schema", "value": projected},
{ {
"op": "replace", "op": "replace",
"path": f"/steps/{escape_json_pointer(step_id)}/output", "path": f"/steps/{escape_json_pointer(step_id)}/output",
+16 -2
View File
@@ -514,12 +514,13 @@ def _draft_repair_hint(
workspace_id: str, workspace_id: str,
revision: int, revision: int,
) -> str | None: ) -> str | None:
if diagnostic.get("code") != "invalid_destination_path": code = diagnostic.get("code")
return None
step_id = diagnostic.get("step_id") step_id = diagnostic.get("step_id")
details = diagnostic.get("details") details = diagnostic.get("details")
if not isinstance(step_id, str) or not isinstance(details, dict): if not isinstance(step_id, str) or not isinstance(details, dict):
return None return None
if code == "invalid_destination_path":
output_field = details.get("output_field") output_field = details.get("output_field")
state_path = details.get("state_path") state_path = details.get("state_path")
if not isinstance(output_field, str) or not isinstance(state_path, str): if not isinstance(output_field, str) or not isinstance(state_path, str):
@@ -528,3 +529,16 @@ def _draft_repair_hint(
f"wf draft bind {workspace_id} --revision {revision} " f"wf draft bind {workspace_id} --revision {revision} "
f"--step {step_id} --from local.{output_field} --to {state_path}" f"--step {step_id} --from local.{output_field} --to {state_path}"
) )
if code == "invalid_source_path":
source_path = details.get("source_path")
target_field = details.get("target_field")
if not isinstance(source_path, str) or not isinstance(target_field, str):
return None
if source_path.startswith("input."):
return (
f"wf draft bind {workspace_id} --revision {revision} "
f"--step {step_id} --from {source_path} --to local.{target_field}"
)
return None
+11 -1
View File
@@ -14,8 +14,13 @@ def project_property_to_schema_path(
source_schema: JsonObject, source_schema: JsonObject,
source_field: str, source_field: str,
target_parts: tuple[str, ...], target_parts: tuple[str, ...],
allow_existing_equivalent: bool = False,
) -> JsonObject: ) -> JsonObject:
"""Copy one source property schema into a target JSON Schema object path.""" """Copy one source property schema into a target JSON Schema object path.
``allow_existing_equivalent`` accepts exact schema equality only. It does
not attempt semantic JSON Schema compatibility analysis.
"""
if not target_parts: if not target_parts:
raise ValueError("target schema path must not be empty") raise ValueError("target schema path must not be empty")
_check_schema("target_schema", target_schema) _check_schema("target_schema", target_schema)
@@ -50,6 +55,11 @@ def project_property_to_schema_path(
) )
leaf = target_parts[-1] leaf = target_parts[-1]
if leaf in properties: if leaf in properties:
if allow_existing_equivalent and properties[leaf] == source_property:
_merge_definition_block(projected, source_schema, "$defs")
_merge_definition_block(projected, source_schema, "definitions")
_check_schema("projected target_schema", projected)
return projected
raise ValueError(f"schema path {'.'.join(target_parts)!r} already exists") raise ValueError(f"schema path {'.'.join(target_parts)!r} already exists")
properties[leaf] = deepcopy(source_property) properties[leaf] = deepcopy(source_property)
+46 -3
View File
@@ -9,7 +9,7 @@ import jsonpatch
from pydantic import BaseModel, Field, ValidationError from pydantic import BaseModel, Field, ValidationError
from wf_core.models.schemas import NodeDef from wf_core.models.schemas import NodeDef
from wf_core.models.steps import OutputBinding from wf_core.models.steps import InputPathBinding, OutputBinding
from wf_core.models.workflow import Workflow from wf_core.models.workflow import Workflow
from wf_core.validation.issues import ValidationIssue, ValidationIssueCode from wf_core.validation.issues import ValidationIssue, ValidationIssueCode
@@ -155,6 +155,9 @@ def _format_location(location: tuple[object, ...]) -> str:
_NODE_OUTPUT_TARGET_RE = re.compile( _NODE_OUTPUT_TARGET_RE = re.compile(
r"^nodes\[(?P<node_index>\d+)\]\.output\[(?P<output_index>\d+)\]\.target$" r"^nodes\[(?P<node_index>\d+)\]\.output\[(?P<output_index>\d+)\]\.target$"
) )
_NODE_INPUT_PATH_RE = re.compile(
r"^nodes\[(?P<node_index>\d+)\]\.input\[(?P<input_index>\d+)\]\.path$"
)
def _diagnostics_from_workflow_issues(workflow: Workflow) -> list[DraftDiagnostic]: def _diagnostics_from_workflow_issues(workflow: Workflow) -> list[DraftDiagnostic]:
@@ -191,8 +194,17 @@ def _details_for_issue(
workflow: Workflow, workflow: Workflow,
issue: ValidationIssue, issue: ValidationIssue,
) -> dict[str, Any]: ) -> dict[str, Any]:
if issue.code is not ValidationIssueCode.INVALID_DESTINATION_PATH: if issue.code is ValidationIssueCode.INVALID_DESTINATION_PATH:
return _details_for_invalid_destination(workflow, issue)
if issue.code is ValidationIssueCode.INVALID_SOURCE_PATH:
return _details_for_invalid_source(workflow, issue)
return {} return {}
def _details_for_invalid_destination(
workflow: Workflow,
issue: ValidationIssue,
) -> dict[str, Any]:
match = _NODE_OUTPUT_TARGET_RE.match(issue.path) match = _NODE_OUTPUT_TARGET_RE.match(issue.path)
if match is None: if match is None:
return {} return {}
@@ -215,11 +227,42 @@ def _details_for_issue(
} }
def _details_for_invalid_source(
workflow: Workflow,
issue: ValidationIssue,
) -> dict[str, Any]:
"""Extract step_input source_path and target_field for INVALID_SOURCE_PATH."""
match = _NODE_INPUT_PATH_RE.match(issue.path)
if match is None:
return {}
node_index = int(match.group("node_index"))
input_index = int(match.group("input_index"))
if node_index >= len(workflow.nodes):
return {}
inputs = getattr(workflow.nodes[node_index], "input", None)
if not isinstance(inputs, list) or input_index >= len(inputs):
return {}
binding = inputs[input_index]
if not isinstance(binding, InputPathBinding):
return {}
target_field = _single_local_path(binding.target)
if target_field is None:
return {}
return {
"source_path": str(binding.path),
"target_field": target_field,
}
def _single_local_field(binding: OutputBinding) -> str | None: def _single_local_field(binding: OutputBinding) -> str | None:
return _single_local_path(binding.source)
def _single_local_path(value: object) -> str | None:
from wf_core.local_paths import LocalPathError, split_local_path from wf_core.local_paths import LocalPathError, split_local_path
try: try:
parts = split_local_path(binding.source) parts = split_local_path(str(value))
except LocalPathError: except LocalPathError:
return None return None
if len(parts) != 1: if len(parts) != 1:
+254
View File
@@ -1421,3 +1421,257 @@ async def test_forward_route_becomes_valid_after_target_step_is_added(
assert add_collect["revision"] == 3 assert add_collect["revision"] == 3
validated = await api.validate_draft_workspace(workspace_id="browser") validated = await api.validate_draft_workspace(workspace_id="browser")
assert validated["status"] == "valid" assert validated["status"] == "valid"
@pytest.mark.asyncio
async def test_bind_draft_local_output_to_workflow_output_lowers_through_state(
tmp_path: Path,
) -> None:
artifact_store = FileWorkflowArtifactStore(
tmp_path / "drafts_bind_output_workflow_output"
)
api, service, authoring = _draft_api(artifact_store, register_echo=True)
await api.create_draft_workspace(
workspace_id="report",
draft={
"name": "report",
"input_schema": {"type": "object", "properties": {}},
"state_schema": {"type": "object", "properties": {}},
"output_schema": {"type": "object", "properties": {}},
"start": "render",
"steps": {
"render": {
"use": "demo.personal.echo_tool",
"input": [],
"output": [],
}
},
"routes": {"render": {"ok": "__end__"}},
},
)
result = await authoring.bind_draft(
workspace_id="report",
revision=1,
step_id="render",
source_path="local.echoed",
target_path="output.echoed",
)
assert result["status"] == "valid"
workspace = await api.get_draft_workspace(workspace_id="report", include_draft=True)
assert workspace["draft"]["steps"]["render"]["output"] == [
{"source": "echoed", "target": "state.echoed"}
]
assert workspace["draft"]["output"] == [
{"path": "state.echoed", "target": "echoed"}
]
assert (
workspace["draft"]["state_schema"]["properties"]["echoed"]["type"] == "string"
)
assert (
workspace["draft"]["output_schema"]["properties"]["echoed"]["type"] == "string"
)
@pytest.mark.asyncio
async def test_bind_draft_local_output_to_workflow_output_reuses_compatible_state(
tmp_path: Path,
) -> None:
artifact_store = FileWorkflowArtifactStore(
tmp_path / "drafts_bind_existing_output_state"
)
api, _service, authoring = _draft_api(artifact_store, register_echo=True)
await api.create_draft_workspace(
workspace_id="report",
draft={
"name": "report",
"input_schema": {"type": "object", "properties": {}},
"state_schema": {
"type": "object",
"properties": {
"echoed": {
"description": "Echoed text",
"title": "Echoed",
"type": "string",
}
},
},
"output_schema": {"type": "object", "properties": {}},
"start": "render",
"steps": {
"render": {
"use": "demo.personal.echo_tool",
"input": [],
"output": [{"source": "echoed", "target": "state.echoed"}],
}
},
"routes": {"render": {"ok": "__end__"}},
},
)
result = await authoring.bind_draft(
workspace_id="report",
revision=1,
step_id="render",
source_path="local.echoed",
target_path="output.echoed",
)
assert result["status"] == "valid"
workspace = await api.get_draft_workspace(workspace_id="report", include_draft=True)
assert workspace["draft"]["steps"]["render"]["output"] == [
{"source": "echoed", "target": "state.echoed"}
]
assert workspace["draft"]["output"] == [
{"path": "state.echoed", "target": "echoed"}
]
@pytest.mark.asyncio
async def test_bind_draft_local_output_to_quoted_workflow_output_field(
tmp_path: Path,
) -> None:
artifact_store = FileWorkflowArtifactStore(tmp_path / "drafts_bind_quoted_output")
api, _service, authoring = _draft_api(artifact_store, register_echo=True)
draft = _echo_draft()
draft["state_schema"] = {"type": "object", "properties": {}}
draft["output_schema"] = {"type": "object", "properties": {}}
draft["steps"]["echo"]["output"] = []
await api.create_draft_workspace(workspace_id="quoted", draft=draft)
result = await authoring.bind_draft(
workspace_id="quoted",
revision=1,
step_id="echo",
source_path="local.echoed",
target_path='output."public.echoed"',
)
assert result["status"] == "valid"
workspace = await api.get_draft_workspace(workspace_id="quoted", include_draft=True)
assert workspace["draft"]["steps"]["echo"]["output"] == [
{"source": "echoed", "target": 'state."public.echoed"'}
]
assert workspace["draft"]["output"] == [
{"path": 'state."public.echoed"', "target": '"public.echoed"'}
]
@pytest.mark.asyncio
async def test_validate_draft_workspace_omits_unusable_nested_input_repair_hint(
tmp_path: Path,
) -> None:
artifact_store = FileWorkflowArtifactStore(
tmp_path / "drafts_nested_input_schema_hint"
)
api, _service, _authoring = _draft_api(artifact_store, register_echo=True)
await api.create_draft_workspace(
workspace_id="nested_ws",
draft={
"name": "nested",
"input_schema": {"type": "object", "properties": {}},
"state_schema": {"type": "object", "properties": {}},
"output_schema": {"type": "object", "properties": {}},
"start": "call",
"steps": {
"call": {
"use": "demo.personal.echo_tool",
"input": [{"target": "payload.text", "path": "input.undeclared"}],
"output": [],
}
},
"routes": {"call": {"ok": "__end__"}},
},
)
payload = await api.validate_draft_workspace(workspace_id="nested_ws")
diagnostic = next(
item for item in payload["diagnostics"] if item["code"] == "invalid_source_path"
)
assert "repair_hint" not in diagnostic
@pytest.mark.asyncio
async def test_validate_draft_workspace_details_invalid_input_source_path(
tmp_path: Path,
) -> None:
"""invalid_source_path on a step input must carry step_id, source_path, target_field."""
artifact_store = FileWorkflowArtifactStore(
tmp_path / "drafts_invalid_source_details"
)
api, service, authoring = _draft_api(artifact_store, register_echo=True)
await api.create_draft_workspace(
workspace_id="wait_ws",
draft={
"name": "wait",
"input_schema": {"type": "object", "properties": {}},
"state_schema": {"type": "object", "properties": {}},
"output_schema": {"type": "object", "properties": {}},
"start": "wait",
"steps": {
"wait": {
"use": "demo.personal.echo_tool",
"input": [
{
"target": "text",
"path": "input.undeclared",
}
],
"output": [],
}
},
"routes": {"wait": {"ok": "__end__"}},
},
)
payload = await api.validate_draft_workspace(workspace_id="wait_ws")
diagnostic = payload["diagnostics"][0]
assert diagnostic["code"] == "invalid_source_path"
assert diagnostic["step_id"] == "wait"
assert diagnostic["details"]["source_path"] == "input.undeclared"
assert diagnostic["details"]["target_field"] == "text"
@pytest.mark.asyncio
async def test_validate_draft_workspace_hints_input_schema_projection(
tmp_path: Path,
) -> None:
"""invalid_source_path with input.* source must produce a repair_hint."""
artifact_store = FileWorkflowArtifactStore(tmp_path / "drafts_input_schema_hint")
api, service, authoring = _draft_api(artifact_store, register_echo=True)
await api.create_draft_workspace(
workspace_id="wait_ws",
draft={
"name": "wait",
"input_schema": {"type": "object", "properties": {}},
"state_schema": {"type": "object", "properties": {}},
"output_schema": {"type": "object", "properties": {}},
"start": "wait",
"steps": {
"wait": {
"use": "demo.personal.echo_tool",
"input": [
{
"target": "text",
"path": "input.undeclared",
}
],
"output": [],
}
},
"routes": {"wait": {"ok": "__end__"}},
},
)
payload = await api.validate_draft_workspace(workspace_id="wait_ws")
diagnostic = payload["diagnostics"][0]
assert diagnostic["code"] == "invalid_source_path"
assert diagnostic["step_id"] == "wait"
assert diagnostic["repair_hint"] == (
"wf draft bind wait_ws --revision 1 "
"--step wait --from input.undeclared --to local.text"
)