19 KiB
Draft Validation Repair Hints 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: Make wf draft validate return structured repair hints for common draft authoring failures, starting with missing state fields for step output bindings.
Architecture: Preserve low-level draft validation in wf_artifacts, but stop flattening core workflow validation into opaque ValueError text. Convert wf_core.validation.ValidationIssue objects into DraftDiagnostic objects with structured details, then let wf_api add workspace-aware CLI repair hints when validating a stored draft workspace. The first exact hint targets the helper we just added: wf draft bind-output-to-state.
Tech Stack: Python 3.14, Pydantic DTOs, existing wf_core validation reports, existing draft workspace API, pytest, ruff, basedpyright.
Scope
When a draft contains a step output binding like:
{
"source": {"root": "local", "parts": ["after"]},
"target": {"root": "state", "parts": ["after"]}
}
but state_schema.properties.after is missing, wf draft validate <workspace_id> should return a diagnostic like:
{
"code": "invalid_destination_path",
"path": "nodes[0].output[0].target",
"step_id": "wait",
"message": "destination path must start with state. and reference a declared root field",
"details": {
"output_field": "after",
"state_path": "state.after"
},
"repair_hint": "wf draft bind-output-to-state browser_ws --revision 5 --step wait --output after --state state.after"
}
This slice only adds exact repair hints for stored draft validation. patch_draft_workspace may still return diagnostics without exact commands; users and agents should run wf draft validate after edits.
Non-goals:
- Do not add auto-fix.
- Do not infer routes.
- Do not parse arbitrary exception strings.
- Do not add nested state path support.
- Do not reimplement JSON Schema validation.
Files
- Modify:
src/wf_artifacts/drafts/api.py- Add
repair_hintanddetailstoDraftDiagnostic. - Validate compiled workflows with
workflow.validate_structure(). - Convert core validation issues into draft diagnostics.
- Add
- Modify:
src/wf_api/drafts.py- Add workspace-aware repair hints in
validate_draft_workspace.
- Add workspace-aware repair hints in
- Tests:
tests/artifacts/test_draft_adapter.pytests/wf_api/test_drafts_service.py
- Docs/skills:
docs/wf_cli.mdskills/wf-cli/SKILL.mdskills/wf-workflow/references/draft-workspaces.mddocs/current_roadmap.md
Task 1: Preserve Core Validation Issues In Draft Diagnostics
Files:
-
Modify:
src/wf_artifacts/drafts/api.py -
Modify:
tests/artifacts/test_draft_adapter.py -
Step 1: Write failing low-level draft validation test
In tests/artifacts/test_draft_adapter.py, add this test near the existing validate_workflow_draft tests:
def test_validate_workflow_draft_reports_structured_output_destination_issue() -> None:
draft = {
"name": "missing_state_field",
"input_schema": {},
"state_schema": {"type": "object", "properties": {}},
"output_schema": {},
"start": "snap",
"steps": {
"snap": {
"use": "demo.snapshot",
"output": [
{
"source": {"root": "local", "parts": ["after"]},
"target": {"root": "state", "parts": ["after"]},
}
],
}
},
"routes": {"snap": {"ok": "__end__"}},
}
result = validate_workflow_draft(draft)
assert result["status"] == "invalid"
diagnostic = result["diagnostics"][0]
assert diagnostic["code"] == "invalid_destination_path"
assert diagnostic["path"] == "nodes[0].output[0].target"
assert diagnostic["step_id"] == "snap"
assert diagnostic["details"] == {
"output_field": "after",
"state_path": "state.after",
}
assert "repair_hint" not in diagnostic
- Step 2: Run test to verify red
Run:
uv run pytest tests/artifacts/test_draft_adapter.py -q -k "structured_output_destination_issue"
Expected: fail because validate_workflow_draft currently returns valid or flattens structure errors without these details.
- Step 3: Extend
DraftDiagnostic
In src/wf_artifacts/drafts/api.py, change DraftDiagnostic to:
class DraftDiagnostic(BaseModel):
"""Machine-readable reason a keyed draft could not be compiled."""
code: str
path: str
step_id: str | None = None
message: str
repair_hint: str | None = None
details: dict[str, Any] = {}
Keep details as a plain JSON object. Do not add a specific typed model yet; draft diagnostics cover several validation families.
- Step 4: Add core issue conversion helpers
In src/wf_artifacts/drafts/api.py, add imports:
import re
from wf_core.models.steps import OutputBinding
from wf_core.models.workflow import Workflow
from wf_core.validation.issues import ValidationIssue, ValidationIssueCode
Add these helpers after _diagnostic_from_exception:
_NODE_OUTPUT_TARGET_RE = re.compile(r"^nodes\[(?P<node_index>\d+)\]\.output\[(?P<output_index>\d+)\]\.target$")
def _diagnostics_from_workflow_issues(workflow: Workflow) -> list[DraftDiagnostic]:
report = workflow.validate_structure()
return [_diagnostic_from_issue(workflow, issue) for issue in report.errors]
def _diagnostic_from_issue(
workflow: Workflow,
issue: ValidationIssue,
) -> DraftDiagnostic:
step_id = _step_id_for_issue_path(workflow, issue.path)
return DraftDiagnostic(
code=str(issue.code),
path=issue.path,
step_id=step_id,
message=issue.message,
details=_details_for_issue(workflow, issue),
)
def _step_id_for_issue_path(workflow: Workflow, path: str) -> str | None:
match = re.match(r"^nodes\[(?P<node_index>\d+)\]", path)
if match is None:
return None
node_index = int(match.group("node_index"))
if node_index >= len(workflow.nodes):
return None
node_id = getattr(workflow.nodes[node_index], "id", None)
return node_id if isinstance(node_id, str) else None
def _details_for_issue(
workflow: Workflow,
issue: ValidationIssue,
) -> dict[str, Any]:
if issue.code is not ValidationIssueCode.INVALID_DESTINATION_PATH:
return {}
match = _NODE_OUTPUT_TARGET_RE.match(issue.path)
if match is None:
return {}
node_index = int(match.group("node_index"))
output_index = int(match.group("output_index"))
if node_index >= len(workflow.nodes):
return {}
outputs = getattr(workflow.nodes[node_index], "output", None)
if not isinstance(outputs, list) or output_index >= len(outputs):
return {}
binding = outputs[output_index]
if not isinstance(binding, OutputBinding):
return {}
output_field = _single_local_field(binding)
if output_field is None:
return {}
return {
"output_field": output_field,
"state_path": str(binding.target),
}
def _single_local_field(binding: OutputBinding) -> str | None:
parts = getattr(binding.source, "parts", None)
root = getattr(binding.source, "root", None)
if root != "local" or not isinstance(parts, list) or len(parts) != 1:
return None
field = parts[0]
return field if isinstance(field, str) else None
- Step 5: Validate compiled workflow structure
In validate_workflow_draft, replace:
try:
compiled_plan = compile_workflow_draft(draft)
except (ValidationError, KeyError, ValueError) as exc:
return _invalid_result(_diagnostic_from_exception(exc))
with:
try:
parsed = WorkflowDraft.model_validate(draft)
workflow = build_workflow_from_draft(parsed)
except (ValidationError, KeyError, ValueError) as exc:
return _invalid_result(_diagnostic_from_exception(exc))
diagnostics = _diagnostics_from_workflow_issues(workflow)
if diagnostics:
return _invalid_result(*diagnostics)
compiled_plan = workflow.model_dump(mode="json", by_alias=True, exclude={"node_defs"})
Then update _invalid_result to accept multiple diagnostics:
def _invalid_result(*diagnostics: DraftDiagnostic) -> JsonObject:
return {
"status": "invalid",
"diagnostics": [item.model_dump(mode="json", exclude_none=True) for item in diagnostics],
}
Do not change compile_workflow_draft; it should remain a compiler that raises on model/adapter failures and returns raw workflow JSON.
- Step 6: Run low-level tests to verify green
Run:
uv run pytest tests/artifacts/test_draft_adapter.py -q -k "structured_output_destination_issue or validate_workflow_draft"
Expected: selected tests pass.
- Step 7: Commit
git add src/wf_artifacts/drafts/api.py tests/artifacts/test_draft_adapter.py
git commit -m "feat: expose structured draft validation issues"
Task 2: Add Workspace-Aware Repair Hints In Draft API
Files:
-
Modify:
src/wf_api/drafts.py -
Modify:
tests/wf_api/test_drafts_service.py -
Step 1: Write failing repair hint test
In tests/wf_api/test_drafts_service.py, add this test near test_validate_draft_workspace_refreshes_status:
@pytest.mark.asyncio
async def test_validate_draft_workspace_suggests_bind_output_to_state(
tmp_path: Path,
) -> None:
artifact_store = FileWorkflowArtifactStore(tmp_path / "drafts_repair_hint")
api, service = _draft_api(artifact_store)
service.register_connection(
ConnectionConfig(id="demo.personal", server="demo", account="personal")
)
service.register_specs("demo.personal", _snapshot_tool)
await api.create_draft_workspace(
workspace_id="snapshot_ws",
draft={
"name": "snapshot",
"input_schema": {"type": "object", "properties": {}},
"state_schema": {"type": "object", "properties": {}},
"output_schema": {"type": "object", "properties": {}},
"start": "snap",
"steps": {
"snap": {
"use": "demo.personal.snapshot_tool",
"input": [],
"output": [
{
"source": {"root": "local", "parts": ["after"]},
"target": {"root": "state", "parts": ["after"]},
}
],
}
},
"routes": {"snap": {"ok": "__end__"}},
},
)
payload = await api.validate_draft_workspace(workspace_id="snapshot_ws")
diagnostic = payload["diagnostics"][0]
assert diagnostic["code"] == "invalid_destination_path"
assert diagnostic["step_id"] == "snap"
assert diagnostic["repair_hint"] == (
"wf draft bind-output-to-state snapshot_ws --revision 1 "
"--step snap --output after --state state.after"
)
- Step 2: Run test to verify red
Run:
uv run pytest tests/wf_api/test_drafts_service.py -q -k "suggests_bind_output_to_state"
Expected: fail because repair_hint is missing.
- Step 3: Add repair hint enrichment helpers
In src/wf_api/drafts.py, add this helper near _state_root_field:
def _with_workspace_repair_hints(
payload: dict[str, Any],
*,
workspace_id: str,
revision: int,
) -> dict[str, Any]:
diagnostics = payload.get("diagnostics")
if not isinstance(diagnostics, list):
return payload
enriched = []
changed = False
for diagnostic in diagnostics:
if not isinstance(diagnostic, dict):
enriched.append(diagnostic)
continue
repaired = dict(diagnostic)
hint = _draft_repair_hint(
repaired,
workspace_id=workspace_id,
revision=revision,
)
if hint is not None:
repaired["repair_hint"] = hint
changed = True
enriched.append(repaired)
if not changed:
return payload
return {**payload, "diagnostics": enriched}
def _draft_repair_hint(
diagnostic: Mapping[str, Any],
*,
workspace_id: str,
revision: int,
) -> str | None:
if diagnostic.get("code") != "invalid_destination_path":
return None
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-output-to-state {workspace_id} --revision {revision} "
f"--step {step_id} --output {output_field} --state {state_path}"
)
This helper belongs in wf_api, not wf_artifacts, because command names and workspace revisions are product-surface concerns.
- Step 4: Enrich
validate_draft_workspacediagnostics before saving
In WorkflowDraftApi.validate_draft_workspace, replace:
validation = await self.validate_draft(draft=workspace.draft)
with:
validation = _with_workspace_repair_hints(
await self.validate_draft(draft=workspace.draft),
workspace_id=workspace_id,
revision=workspace.revision,
)
This stores enriched diagnostics in the workspace summary so repeated inspection shows the same hint.
- Step 5: Run API tests to verify green
Run:
uv run pytest tests/wf_api/test_drafts_service.py -q -k "suggests_bind_output_to_state or validate_draft_workspace"
Expected: selected tests pass.
- Step 6: Commit
git add src/wf_api/drafts.py tests/wf_api/test_drafts_service.py
git commit -m "feat: add draft validation repair hints"
Task 3: CLI/RPC Smoke And Docs
Files:
-
Modify:
tests/wf_cli/test_remote_target.py -
Modify:
docs/wf_cli.md -
Modify:
skills/wf-cli/SKILL.md -
Modify:
skills/wf-workflow/references/draft-workspaces.md -
Modify:
docs/current_roadmap.md -
Move:
docs/superpowers/plans/2026-06-26-draft-validation-repair-hints.mdtodocs/historical/superpowers/plans/2026-06-26-draft-validation-repair-hints.md -
Step 1: Extend CLI/RPC draft validation smoke test
In tests/wf_cli/test_remote_target.py, update
test_wf_remote_draft_artifact_deploy_lifecycle. After the existing successful
validated assertion block, add a second invalid draft workspace:
invalid_created = runner.invoke(
app,
[
*base_args,
"draft",
"create-from-capability",
"repair_ws",
"wf.std.constant",
"--name",
"repair_constant",
],
)
assert invalid_created.exit_code == 0, invalid_created.output
invalid_patch = runner.invoke(
app,
[
*base_args,
"draft",
"set-output",
"repair_ws",
"--revision",
"1",
"--step",
"call",
"--map",
"value=state.missing",
],
)
assert invalid_patch.exit_code == 0, invalid_patch.output
invalid_validated = runner.invoke(
app,
[*base_args, "draft", "validate", "repair_ws"],
)
assert invalid_validated.exit_code == 0, invalid_validated.output
assert '"status": "invalid"' in invalid_validated.output
assert "bind-output-to-state repair_ws --revision 2" in invalid_validated.output
assert "--step call --output value --state state.missing" in invalid_validated.output
This test uses the existing _patch_rpc_client_to_server setup already present
in test_wf_remote_draft_artifact_deploy_lifecycle, so it proves the CLI
preserves the repair hint returned by the RPC-backed API path.
- Step 2: Run CLI smoke test
Run:
uv run pytest tests/wf_cli/test_remote_target.py -q -k "repair_hints or draft_validate"
Expected: pass.
- Step 3: Update CLI docs
In docs/wf_cli.md, near draft validation docs, add:
Draft validation diagnostics may include `repair_hint` commands. Treat these as
the next focused command to try, not as proof that the draft is fixed. Re-run
`wf draft validate <workspace_id>` after applying a hint.
- Step 4: Update skills
In skills/wf-cli/SKILL.md, add under draft rules:
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.
In skills/wf-workflow/references/draft-workspaces.md, add:
Validation repair hints are product guidance. If a diagnostic suggests
`bind-output-to-state`, use it before hand-editing `state_schema` or step output
bindings.
- Step 5: Update roadmap
In docs/current_roadmap.md, add:
- Completed: draft validation now preserves structured core validation issues
and adds exact `bind-output-to-state` repair hints for missing state fields.
- Step 6: Move plan to historical
Run:
Move-Item docs\superpowers\plans\2026-06-26-draft-validation-repair-hints.md docs\historical\superpowers\plans\2026-06-26-draft-validation-repair-hints.md
- Step 7: Run final verification
Run:
uv run pytest tests/artifacts/test_draft_adapter.py tests/wf_api/test_drafts_service.py tests/wf_cli/test_remote_target.py -q -k "structured_output_destination_issue or suggests_bind_output_to_state or repair_hints or validate_draft_workspace"
uv run ruff check src/wf_artifacts/drafts/api.py src/wf_api/drafts.py tests/artifacts/test_draft_adapter.py tests/wf_api/test_drafts_service.py tests/wf_cli/test_remote_target.py
uv run basedpyright --level error src/wf_artifacts/drafts/api.py src/wf_api/drafts.py tests/artifacts/test_draft_adapter.py tests/wf_api/test_drafts_service.py tests/wf_cli/test_remote_target.py
Expected:
-
Focused tests pass.
-
Ruff reports
All checks passed!. -
Basedpyright reports
0 errors. -
Step 8: Commit
git add -A
git commit -m "docs: record draft validation repair hints"
Self-Review Notes
- This plan intentionally returns structured validation issues before generating repair hints. Regex-parsing flattened exception text would be brittle and would hide useful issue codes.
- Exact CLI repair hints are added in
wf_apibecause only that layer knows the workspace id and revision. - The first repair hint only targets
invalid_destination_pathfor node output bindings with a single top-level local output field. - This plan does not change
bind-output-to-state; it only makes validation point agents toward it.