fix: lower draft output bindings through state
This commit is contained in:
@@ -280,11 +280,13 @@ stable.
|
||||
deployment binding suggestions, reject bare `--bind-output` state targets
|
||||
before RPC with compact guidance, and accept `wf schema --full` as an alias
|
||||
for `--verbose`.
|
||||
- Planned: close the next draft-authoring UX gaps found by debug challenge
|
||||
runs: add a first-class workflow-level output command, improve schema-aware
|
||||
`wf draft bind` discoverability and repair hints for workflow input/output
|
||||
projection, and stop auto-binding optional capability inputs unless explicitly
|
||||
requested or safely defaulted.
|
||||
- Completed: `wf draft bind local.x -> output.y` now lowers through state
|
||||
atomically (projecting into both state_schema and output_schema), and
|
||||
validation repair hints cover undeclared workflow input source paths.
|
||||
Implementation plan:
|
||||
[`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
|
||||
|
||||
|
||||
+14
-14
@@ -17,7 +17,7 @@
|
||||
- Test: `tests/wf_api/test_drafts_service.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:
|
||||
|
||||
@@ -46,7 +46,7 @@ assert workspace["draft"]["state_schema"]["properties"]["markdown"]["type"] == "
|
||||
assert workspace["draft"]["output_schema"]["properties"]["markdown"]["type"] == "string"
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run tests RED**
|
||||
- [x] **Step 2: Run tests RED**
|
||||
|
||||
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.
|
||||
|
||||
- [ ] **Step 3: Implement hint branch**
|
||||
- [x] **Step 3: Implement hint 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`.
|
||||
|
||||
- [ ] **Step 4: Run tests GREEN**
|
||||
- [x] **Step 4: Run tests GREEN**
|
||||
|
||||
Run the test from Step 2. Expected: pass with `status: valid`.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
- [x] **Step 5: Prepare for the integration commit**
|
||||
|
||||
```powershell
|
||||
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`
|
||||
- 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:
|
||||
|
||||
@@ -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.
|
||||
|
||||
- [ ] **Step 2: Run test RED**
|
||||
- [x] **Step 2: Run test RED**
|
||||
|
||||
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.
|
||||
|
||||
- [ ] **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:
|
||||
|
||||
@@ -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.
|
||||
|
||||
- [ ] **Step 4: Run tests GREEN**
|
||||
- [x] **Step 4: Run tests GREEN**
|
||||
|
||||
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
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
- [x] **Step 5: Prepare for the integration commit**
|
||||
|
||||
```powershell
|
||||
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: `docs/current_roadmap.md`
|
||||
|
||||
- [ ] **Step 1: Add repair-hint examples**
|
||||
- [x] **Step 1: Add repair-hint examples**
|
||||
|
||||
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
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add skill rule**
|
||||
- [x] **Step 2: Add skill rule**
|
||||
|
||||
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.
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Verify**
|
||||
- [x] **Step 3: Verify**
|
||||
|
||||
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
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
- [x] **Step 4: Prepare for the integration commit**
|
||||
|
||||
```powershell
|
||||
git add docs/wf_cli.md skills/wf-cli/SKILL.md skills/wf-workflow/references/draft-workspaces.md docs/current_roadmap.md
|
||||
@@ -344,9 +344,24 @@ use `input.*` or `state.*` to `local.*` for step inputs, and `local.*` to
|
||||
```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 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
|
||||
```
|
||||
|
||||
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:
|
||||
|
||||
- It copies the selected capability local field schema into the workflow input,
|
||||
|
||||
@@ -54,9 +54,11 @@ wf draft add-step <workspace_id> --revision <n> --step <step_id> --capability <q
|
||||
wf draft validate <workspace_id>
|
||||
wf draft save <workspace_id> --artifact <artifact_id> --version <n> --title <title>
|
||||
|
||||
When `wf draft validate` returns a `repair_hint`, prefer running that focused
|
||||
command before writing JSON Patch manually. Re-run `wf draft validate` after the
|
||||
repair.
|
||||
When `wf draft validate` returns a `repair_hint`, run that exact focused command
|
||||
before writing JSON Patch manually. Use `wf draft bind local.x -> output.y` when
|
||||
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 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
|
||||
missing. The selected step must have `use` so the helper can find the
|
||||
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
|
||||
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 local.<field> --to output.<field>
|
||||
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`.
|
||||
|
||||
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-step` does not remove inbound routes. Validate after removal and repair
|
||||
|
||||
@@ -12,7 +12,12 @@ from wf_core.models.steps import (
|
||||
InputBinding,
|
||||
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 (
|
||||
DEFAULT_CALL_STEP_ID,
|
||||
@@ -190,11 +195,19 @@ class WorkflowDraftAuthoringApi:
|
||||
if not source_path.startswith("local.")
|
||||
else ("local", LocalPath.parse(source_path).parts)
|
||||
)
|
||||
target_root, target_parts = (
|
||||
_graph_parts(target_path)
|
||||
if not target_path.startswith("local.")
|
||||
else ("local", LocalPath.parse(target_path).parts)
|
||||
)
|
||||
if target_path.startswith("output."):
|
||||
# GraphSourcePath excludes output targets, but output fields still
|
||||
# use the same canonical TOML-key grammar as other workflow paths.
|
||||
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"}:
|
||||
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)
|
||||
output_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(schema_key, {})
|
||||
target_schema = workspace.draft.get("state_schema", {})
|
||||
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(
|
||||
target_schema=target_schema,
|
||||
source_schema=output_schema,
|
||||
@@ -253,7 +340,7 @@ class WorkflowDraftAuthoringApi:
|
||||
workspace_id=workspace_id,
|
||||
revision=revision,
|
||||
patch=[
|
||||
{"op": "replace", "path": f"/{schema_key}", "value": projected},
|
||||
{"op": "replace", "path": "/state_schema", "value": projected},
|
||||
{
|
||||
"op": "replace",
|
||||
"path": f"/steps/{escape_json_pointer(step_id)}/output",
|
||||
|
||||
+24
-10
@@ -514,17 +514,31 @@ def _draft_repair_hint(
|
||||
workspace_id: str,
|
||||
revision: int,
|
||||
) -> str | None:
|
||||
if diagnostic.get("code") != "invalid_destination_path":
|
||||
return None
|
||||
code = diagnostic.get("code")
|
||||
step_id = diagnostic.get("step_id")
|
||||
details = diagnostic.get("details")
|
||||
if not isinstance(step_id, str) or not isinstance(details, dict):
|
||||
return None
|
||||
output_field = details.get("output_field")
|
||||
state_path = details.get("state_path")
|
||||
if not isinstance(output_field, str) or not isinstance(state_path, str):
|
||||
return None
|
||||
return (
|
||||
f"wf draft bind {workspace_id} --revision {revision} "
|
||||
f"--step {step_id} --from local.{output_field} --to {state_path}"
|
||||
)
|
||||
|
||||
if code == "invalid_destination_path":
|
||||
output_field = details.get("output_field")
|
||||
state_path = details.get("state_path")
|
||||
if not isinstance(output_field, str) or not isinstance(state_path, str):
|
||||
return None
|
||||
return (
|
||||
f"wf draft bind {workspace_id} --revision {revision} "
|
||||
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
|
||||
|
||||
@@ -14,8 +14,13 @@ def project_property_to_schema_path(
|
||||
source_schema: JsonObject,
|
||||
source_field: str,
|
||||
target_parts: tuple[str, ...],
|
||||
allow_existing_equivalent: bool = False,
|
||||
) -> 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:
|
||||
raise ValueError("target schema path must not be empty")
|
||||
_check_schema("target_schema", target_schema)
|
||||
@@ -50,6 +55,11 @@ def project_property_to_schema_path(
|
||||
)
|
||||
leaf = target_parts[-1]
|
||||
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")
|
||||
properties[leaf] = deepcopy(source_property)
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ import jsonpatch
|
||||
from pydantic import BaseModel, Field, ValidationError
|
||||
|
||||
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.validation.issues import ValidationIssue, ValidationIssueCode
|
||||
|
||||
@@ -155,6 +155,9 @@ def _format_location(location: tuple[object, ...]) -> str:
|
||||
_NODE_OUTPUT_TARGET_RE = re.compile(
|
||||
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]:
|
||||
@@ -191,8 +194,17 @@ def _details_for_issue(
|
||||
workflow: Workflow,
|
||||
issue: ValidationIssue,
|
||||
) -> dict[str, Any]:
|
||||
if issue.code is not ValidationIssueCode.INVALID_DESTINATION_PATH:
|
||||
return {}
|
||||
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 {}
|
||||
|
||||
|
||||
def _details_for_invalid_destination(
|
||||
workflow: Workflow,
|
||||
issue: ValidationIssue,
|
||||
) -> dict[str, Any]:
|
||||
match = _NODE_OUTPUT_TARGET_RE.match(issue.path)
|
||||
if match is None:
|
||||
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:
|
||||
return _single_local_path(binding.source)
|
||||
|
||||
|
||||
def _single_local_path(value: object) -> str | None:
|
||||
from wf_core.local_paths import LocalPathError, split_local_path
|
||||
|
||||
try:
|
||||
parts = split_local_path(binding.source)
|
||||
parts = split_local_path(str(value))
|
||||
except LocalPathError:
|
||||
return None
|
||||
if len(parts) != 1:
|
||||
|
||||
@@ -1421,3 +1421,257 @@ async def test_forward_route_becomes_valid_after_target_step_is_added(
|
||||
assert add_collect["revision"] == 3
|
||||
validated = await api.validate_draft_workspace(workspace_id="browser")
|
||||
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"
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user