599 lines
19 KiB
Markdown
599 lines
19 KiB
Markdown
# 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:
|
|
|
|
```json
|
|
{
|
|
"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:
|
|
|
|
```json
|
|
{
|
|
"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_hint` and `details` to `DraftDiagnostic`.
|
|
- Validate compiled workflows with `workflow.validate_structure()`.
|
|
- Convert core validation issues into draft diagnostics.
|
|
- Modify: `src/wf_api/drafts.py`
|
|
- Add workspace-aware repair hints in `validate_draft_workspace`.
|
|
- Tests:
|
|
- `tests/artifacts/test_draft_adapter.py`
|
|
- `tests/wf_api/test_drafts_service.py`
|
|
- Docs/skills:
|
|
- `docs/wf_cli.md`
|
|
- `skills/wf-cli/SKILL.md`
|
|
- `skills/wf-workflow/references/draft-workspaces.md`
|
|
- `docs/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:
|
|
|
|
```python
|
|
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:
|
|
|
|
```powershell
|
|
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:
|
|
|
|
```python
|
|
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:
|
|
|
|
```python
|
|
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`:
|
|
|
|
```python
|
|
_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:
|
|
|
|
```python
|
|
try:
|
|
compiled_plan = compile_workflow_draft(draft)
|
|
except (ValidationError, KeyError, ValueError) as exc:
|
|
return _invalid_result(_diagnostic_from_exception(exc))
|
|
```
|
|
|
|
with:
|
|
|
|
```python
|
|
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:
|
|
|
|
```python
|
|
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:
|
|
|
|
```powershell
|
|
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**
|
|
|
|
```powershell
|
|
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`:
|
|
|
|
```python
|
|
@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:
|
|
|
|
```powershell
|
|
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`:
|
|
|
|
```python
|
|
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_workspace` diagnostics before saving**
|
|
|
|
In `WorkflowDraftApi.validate_draft_workspace`, replace:
|
|
|
|
```python
|
|
validation = await self.validate_draft(draft=workspace.draft)
|
|
```
|
|
|
|
with:
|
|
|
|
```python
|
|
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:
|
|
|
|
```powershell
|
|
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**
|
|
|
|
```powershell
|
|
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.md` to `docs/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:
|
|
|
|
```python
|
|
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:
|
|
|
|
```powershell
|
|
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:
|
|
|
|
```markdown
|
|
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:
|
|
|
|
```markdown
|
|
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:
|
|
|
|
```markdown
|
|
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:
|
|
|
|
```markdown
|
|
- 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:
|
|
|
|
```powershell
|
|
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:
|
|
|
|
```powershell
|
|
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**
|
|
|
|
```powershell
|
|
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_api` because only that layer knows the workspace id and revision.
|
|
- The first repair hint only targets `invalid_destination_path` for 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.
|