fix: smooth draft schema projection ux

This commit is contained in:
lda
2026-06-29 18:55:22 +07:00 Verified
parent 664beec837
commit 5f520d74bd
15 changed files with 335 additions and 16 deletions
+4
View File
@@ -96,6 +96,10 @@ clear operator feedback before adding more architecture.
for editing top-level workflow output bindings. Accepts repeatable `--map`
and `--merge` flag. Implementation:
[`set-workflow-output API/RPC/CLI`](historical/superpowers/plans/2026-06-29-set-workflow-output.md).
- Completed: challenge-driven output UX polish makes `set-workflow-output`
project missing top-level output schema fields from declared `input.*` and
`state.*` sources, and challenge prompt templates now always include
`ux_issues_found: []` so debug-profile reports do not fail by omission.
- Keep status read-only; do not mutate registry, auth, config, or stores.
## Priority 2: Durable Run/Resume Hardening
+9 -5
View File
@@ -277,15 +277,15 @@ wf draft create concat_ws --capability wf.std.concat --name concat_ws
```
Capability-backed draft creation auto-binds required capability inputs only.
Optional inputs remain available in the workflow input schema but are not wired
to the step until explicitly requested. Bind one when the workflow should
expose it:
Optional inputs are not wired by default. Bind one when the workflow should
expose it; the focused helper projects the workflow input schema:
```bash
wf draft bind report_ws --revision 2 --step call --from input.path --to local.path
```
Use `wf draft set-input --merge` instead when adding several explicit mappings
for fields already declared in the workflow input or state schema.
to an existing step input map.
List and inspect drafts:
@@ -337,8 +337,9 @@ wf draft compile concat_ws
`context.*`) to top-level output fields: `state.value=result` means
`state.value -> output.result`.
The output field must already be declared in `output_schema`. The command edits
the projection map; it does not infer or add output schema fields.
For single-field `input.*` and `state.*` sources, the command projects missing
top-level `output_schema` fields from the source schema. More complex or
undeclared paths still rely on `wf draft validate` diagnostics.
By default, `set-input`, `set-output`, and `set-workflow-output` replace the
whole map for that step or output scope. Use repeated `--map` flags in one
@@ -386,6 +387,9 @@ Use `set-route` separately for outcome routing.
Use `wf draft add-step` when adding a new capability-backed step
to an existing draft. The command is explicit: it does not guess missing maps.
Explicit top-level `--input input.x=x` and `--input state.x=x` mappings project
the corresponding workflow input/state schema fields from the capability input
schema.
When the capability declares multiple outcomes, provide exactly one
`--route OUTCOME=TARGET` for each declared outcome. Missing or unknown outcomes
are rejected before the draft is mutated.
@@ -92,6 +92,7 @@ challenge_report:
failed: 0
missed_requirements:
- "none"
ux_issues_found: []
notes: "short explanation"
```
@@ -122,6 +123,8 @@ Reporting rules:
abandoned workflow plans.
- `attempts.failed` should count attempts that failed validation, failed to run,
produced wrong output, or were abandoned.
- `ux_issues_found` should always be present. Use an empty list when you found
no UX issues; otherwise include concrete command/error/workaround notes.
Spawned subagents count as you. If a subagent reads product code, set
`read.product_code: true`. If a subagent reads prior attempts, set
@@ -92,6 +92,7 @@ challenge_report:
failed: 0
missed_requirements:
- "none"
ux_issues_found: []
notes: "short explanation"
```
@@ -126,6 +127,8 @@ Reporting rules:
abandoned workflow plans.
- `attempts.failed` should count attempts that failed validation, failed to run,
produced wrong output, or were abandoned.
- `ux_issues_found` should always be present. Use an empty list when you found
no UX issues; otherwise include concrete command/error/workaround notes.
Spawned subagents count as you. If a subagent reads product code, set
`read.product_code: true`. If a subagent reads prior attempts, set
+9 -2
View File
@@ -55,8 +55,15 @@ wf draft validate <workspace_id>
wf draft save <workspace_id> --artifact <artifact_id> --version <n> --title <title>
Draft creation auto-binds required capability inputs only. Optional inputs are
reported in wrapper-hint notes; bind them explicitly with `wf draft bind` or
`wf draft set-input --merge` only when the workflow should expose them.
reported in wrapper-hint notes; bind them explicitly only when the workflow
should expose them. Use `wf draft bind --from input.x --to local.x` for an
existing step, or `wf draft add-step --input input.x=x` while adding a step;
both project the workflow input schema for top-level fields.
`wf draft set-workflow-output` projects missing public output schema fields for
single-field `input.*` and `state.*` sources. Prefer it for final workflow
outputs; use `wf draft bind --from local.x --to output.y` when the source is a
step-local capability output.
When `wf draft validate` returns a `repair_hint`, run that exact focused command
before writing JSON Patch manually. To make one capability output public, use
@@ -115,7 +115,8 @@ wf draft add-step <workspace_id> --revision <n> --step <step_id> --capability <q
`set-workflow-output` maps a graph source path (`input.*`, `state.*`, or
`context.*`) to one public workflow output field. It edits top-level
`WorkflowDraft.output`; `set-output` edits one step's local-to-state bindings.
The public output field must already exist in `output_schema`.
For single-field `input.*` and `state.*` sources, missing public output schema
fields are projected automatically from the source schema.
`set-input` direction: `input.text=text` means graph source `input.text` maps to
node-local target `local.text`.
@@ -158,6 +159,9 @@ wf draft validate <workspace_id>
capabilities require exact route coverage; missing or unknown outcomes are
rejected before mutation. It still requires explicit choices; if you do not
know a map, inspect the capability or run validation rather than guessing.
Explicit top-level `--input input.x=x` and `--input state.x=x` mappings
project the corresponding workflow input/state schema fields from the
capability input schema.
```bash
wf draft add-step <workspace_id> --revision <n> --step <step_id> --capability <qualified_name> --from-step <prev> --from-outcome ok --route ok=__end__ --route error=fail --input input.text=text --input input.other=other --bind-output result=state.result --bind-output title=state.title
+57 -1
View File
@@ -1,6 +1,6 @@
from __future__ import annotations
from collections.abc import Sequence
from collections.abc import Mapping, Sequence
from dataclasses import dataclass
from typing import Any
@@ -64,6 +64,18 @@ def _local_parts(path: str) -> tuple[str, ...]:
return LocalPath.parse(path.removeprefix("local.")).parts
def _schema_path_exists(schema: Mapping[str, Any], parts: Sequence[str]) -> bool:
current: Any = schema
for part in parts:
if not isinstance(current, Mapping):
return False
properties = current.get("properties")
if not isinstance(properties, Mapping) or part not in properties:
return False
current = properties[part]
return True
class WorkflowDraftAuthoringApi:
"""Capability-aware semantic edits over revisioned workflow drafts."""
@@ -343,6 +355,7 @@ class WorkflowDraftAuthoringApi:
source_schema=output_schema,
source_field=local_field,
target_parts=target_parts,
allow_existing_equivalent=True,
)
output_map = {
**self.drafts._step_output_map(
@@ -434,7 +447,40 @@ class WorkflowDraftAuthoringApi:
input_map = input_map or {}
bind_outputs = bind_outputs or {}
projected_input_schema = workspace.draft.get("input_schema", {})
if not isinstance(projected_input_schema, dict):
raise ValueError("draft input_schema must be an object")
projected_state_schema = state_schema
input_schema = (
spec.input_schema_contract or spec.input_model.model_json_schema()
)
for graph_path, local_path in input_map.items():
try:
source_root, source_parts = _graph_parts(graph_path)
local_parts = LocalPath.parse(local_path).parts
except ValueError:
continue
if source_root not in {"input", "state"} or len(local_parts) != 1:
continue
schema_key = "input_schema" if source_root == "input" else "state_schema"
target_schema = (
projected_input_schema
if source_root == "input"
else projected_state_schema
)
if _schema_path_exists(target_schema, source_parts):
continue
projected = project_property_to_schema_path(
target_schema=target_schema,
source_schema=input_schema,
source_field=local_parts[0],
target_parts=source_parts,
allow_existing_equivalent=True,
)
if schema_key == "input_schema":
projected_input_schema = projected
else:
projected_state_schema = projected
for output_field, path in bind_outputs.items():
sf = state_root_field(path)
projected_state_schema = project_output_property_to_state_schema(
@@ -442,6 +488,7 @@ class WorkflowDraftAuthoringApi:
output_schema=output_schema,
output_field=output_field,
state_field=sf,
allow_existing_equivalent=True,
)
patch: list[dict[str, Any]] = [
@@ -460,6 +507,15 @@ class WorkflowDraftAuthoringApi:
"value": step_routes,
},
]
if projected_input_schema != workspace.draft.get("input_schema", {}):
patch.insert(
0,
{
"op": "replace",
"path": "/input_schema",
"value": projected_input_schema,
},
)
if projected_state_schema != state_schema:
patch.insert(
0,
+108 -7
View File
@@ -25,6 +25,7 @@ from wf_core.models.steps import (
InputValueBinding,
OutputBinding,
)
from wf_core.paths import GraphSourcePath, parse_toml_path_segments
from .capability_requirements import (
required_capabilities_for_plan,
@@ -43,6 +44,7 @@ from .draft_payloads import (
output_bindings_payload as _draft_output_bindings_payload,
)
from .operation_context import WorkflowOperationContext
from .schema_projection import project_property_to_schema_path
class WorkflowDraftApi:
@@ -339,18 +341,88 @@ class WorkflowDraftApi:
{"path": source, "target": target}
for source, target in output_map.items()
]
workspace = self._draft_store().get_workspace(workspace_id)
output_schema = self._workflow_output_schema_for_bindings(
draft=workspace.draft,
output_bindings=output_bindings,
)
patch = [
{
"op": "replace",
"path": "/output",
"value": output_bindings,
}
]
if output_schema is not workspace.draft.get("output_schema"):
patch.insert(
0,
{
"op": "replace",
"path": "/output_schema",
"value": output_schema,
},
)
return await self.patch_draft_workspace(
workspace_id=workspace_id,
revision=revision,
patch=[
{
"op": "replace",
"path": "/output",
"value": output_bindings,
}
],
patch=patch,
)
def _workflow_output_schema_for_bindings(
self,
*,
draft: dict[str, Any],
output_bindings: Sequence[Mapping[str, Any]],
) -> dict[str, Any]:
"""Project missing top-level output fields from input/state schemas.
This is intentionally conservative: only single-field ``input.x`` and
``state.x`` sources can be copied unambiguously. More complex sources
still fall through to existing validation diagnostics instead of
guessing a schema.
"""
output_schema = draft.get("output_schema", {})
if not isinstance(output_schema, dict):
raise ValueError("draft output_schema must be an object")
projected = output_schema
changed = False
for binding in output_bindings:
source = binding.get("path")
target = binding.get("target")
if not isinstance(source, str) or not isinstance(target, str):
continue
source_schema = _workflow_source_schema(draft, source)
if source_schema is None:
continue
try:
target_parts = parse_toml_path_segments(target)
except ValueError:
continue
if _schema_path_exists(projected, target_parts):
continue
try:
source_path = GraphSourcePath.parse(source)
except ValueError:
continue
if len(source_path.parts) != 1:
continue
try:
updated = project_property_to_schema_path(
target_schema=projected,
source_schema=source_schema,
source_field=source_path.parts[0],
target_parts=target_parts,
allow_existing_equivalent=True,
)
except ValueError as exc:
if str(exc).startswith("source field "):
continue
raise
if updated != projected:
changed = True
projected = updated
return projected if changed else output_schema
def _step_input_maps(
self,
*,
@@ -367,6 +439,35 @@ class WorkflowDraftApi:
return _output_map_from_payload(step.get("output", []))
def _workflow_source_schema(
draft: Mapping[str, Any],
source_path: str,
) -> dict[str, Any] | None:
try:
parsed = GraphSourcePath.parse(source_path)
except ValueError:
return None
if parsed.root == "input":
schema = draft.get("input_schema")
elif parsed.root == "state":
schema = draft.get("state_schema")
else:
return None
return schema if isinstance(schema, dict) else None
def _schema_path_exists(schema: Mapping[str, Any], parts: Sequence[str]) -> bool:
current: Any = schema
for part in parts:
if not isinstance(current, Mapping):
return False
properties = current.get("properties")
if not isinstance(properties, Mapping) or part not in properties:
return False
current = properties[part]
return True
def _draft_input_maps(
*,
input: Sequence[InputBinding] | None,
+2
View File
@@ -75,6 +75,7 @@ def project_output_property_to_state_schema(
output_schema: JsonObject,
output_field: str,
state_field: str,
allow_existing_equivalent: bool = False,
) -> JsonObject:
"""Root state projection convenience wrapper.
@@ -86,6 +87,7 @@ def project_output_property_to_state_schema(
source_schema=output_schema,
source_field=output_field,
target_parts=(state_field,),
allow_existing_equivalent=allow_existing_equivalent,
)
except ValueError as exc:
msg = str(exc)
+3
View File
@@ -364,6 +364,9 @@ def set_workflow_output(
This edits WorkflowDraft.output (top-level workflow output). Use
wf draft set-output for step-level output bindings.
For single-field input/state sources, missing output_schema fields are
projected automatically from the source schema.
Repeat --map for multiple mappings:
--map state.markdown=markdown --map state.title=title
@@ -950,6 +950,9 @@ def test_browser_click_wrapper_produces_expected_paths_and_command_prefix() -> N
)
assert BROWSER_CLICK_DEF.default_prompt.name == "challenge-prompt.md"
assert BROWSER_CLICK_DEF.default_prompt.parent.name == "browser_click_challenge"
assert "ux_issues_found: []" in BROWSER_CLICK_DEF.default_prompt.read_text(
encoding="utf-8"
)
def test_generic_runner_can_be_configured_with_fake_challenge_and_fake_opencode(
@@ -40,6 +40,7 @@ def test_report_challenge_prompt_requires_full_product_lifecycle() -> None:
assert "render_markdown_report" in prompt
assert "deployment" in prompt.lower()
assert "run_id" in prompt
assert "ux_issues_found: []" in prompt
def test_report_challenge_workspace_template_contains_safe_input_files(
+107
View File
@@ -1217,6 +1217,41 @@ async def test_set_workflow_output_map_merges_top_level_output(tmp_path: Path) -
]
@pytest.mark.asyncio
async def test_set_workflow_output_map_projects_missing_output_schema(
tmp_path: Path,
) -> None:
artifact_store = FileWorkflowArtifactStore(tmp_path / "drafts_out_project")
api, _service, _authoring = _draft_api(artifact_store, register_echo=True)
draft = {
**_echo_draft(),
"state_schema": {
"type": "object",
"properties": {
"echoed": {"type": "string"},
"after": {"type": "object"},
},
},
"output_schema": {"type": "object", "properties": {}},
"output": [],
}
await api.create_draft_workspace(workspace_id="report", draft=draft)
result = await api.set_workflow_output_map(
workspace_id="report",
revision=1,
output_map={"state.after": "after"},
merge=True,
)
assert result["status"] == "valid", result["diagnostics"]
fetched = await api.get_draft_workspace(workspace_id="report", include_draft=True)
assert fetched["draft"]["output"] == [{"path": "state.after", "target": "after"}]
assert fetched["draft"]["output_schema"]["properties"]["after"] == {
"type": "object"
}
# -- Browser-click test helpers for forward-route tests --
@@ -1312,6 +1347,44 @@ async def test_create_draft_from_capability_does_not_bind_optional_inputs(
assert any("open_browser" in note for note in created["wrapper_hints"]["notes"])
@pytest.mark.asyncio
async def test_add_step_projects_explicit_optional_workflow_inputs(
tmp_path: Path,
) -> None:
api, _service = _browser_click_api(
FileWorkflowArtifactStore(tmp_path / "drafts_explicit_optional_input")
)
await api.create_draft_workspace_from_capability(
workspace_id="browser",
capability_name="local.browser_click.open_click_page",
name="browser",
)
result = await api.add_step_from_capability(
workspace_id="browser",
revision=1,
step_id="wait",
capability_name="local.browser_click.wait_for_click",
route_from_step="call",
routes={"ok": "__end__"},
input_map={
"state.session_id": "session_id",
"input.simulate": "simulate",
"input.timeout_seconds": "timeout_seconds",
},
bind_outputs={"after": "state.after"},
)
assert result["status"] == "valid", result["diagnostics"]
workspace = await api.get_draft_workspace(
workspace_id="browser", include_draft=True
)
properties = workspace["draft"]["input_schema"]["properties"]
assert properties["simulate"]["type"] == "object"
assert properties["timeout_seconds"]["type"] == "integer"
@pytest.mark.asyncio
async def test_add_step_persists_invalid_forward_route(tmp_path: Path) -> None:
api, _service = _browser_click_api(
@@ -1551,6 +1624,40 @@ async def test_bind_draft_local_output_to_workflow_output_reuses_compatible_stat
]
@pytest.mark.asyncio
async def test_bind_draft_local_output_to_state_reuses_compatible_state(
tmp_path: Path,
) -> None:
artifact_store = FileWorkflowArtifactStore(tmp_path / "drafts_bind_existing_state")
api, _service, authoring = _draft_api(artifact_store, register_echo=True)
draft = _echo_draft()
draft["state_schema"] = {
"type": "object",
"properties": {
"echoed": {
"description": "Echoed text",
"title": "Echoed",
"type": "string",
}
},
}
await api.create_draft_workspace(workspace_id="report", draft=draft)
result = await authoring.bind_draft(
workspace_id="report",
revision=1,
step_id="echo",
source_path="local.echoed",
target_path="state.echoed",
)
assert result["status"] == "valid", result["diagnostics"]
workspace = await api.get_draft_workspace(workspace_id="report", include_draft=True)
assert workspace["draft"]["steps"]["echo"]["output"] == [
{"source": "echoed", "target": "state.echoed"}
]
@pytest.mark.asyncio
async def test_bind_draft_local_output_to_quoted_workflow_output_field(
tmp_path: Path,
+20
View File
@@ -99,6 +99,26 @@ def test_project_output_property_rejects_existing_state_field() -> None:
)
def test_project_output_property_allows_equivalent_existing_state_field() -> None:
state_schema = {
"type": "object",
"properties": {"after": {"type": "object"}},
}
projected = project_output_property_to_state_schema(
state_schema=state_schema,
output_schema={
"type": "object",
"properties": {"after": {"type": "object"}},
},
output_field="after",
state_field="after",
allow_existing_equivalent=True,
)
assert projected == state_schema
def test_project_output_property_rejects_invalid_output_schema() -> None:
with pytest.raises(ValueError, match="output_schema is not valid JSON Schema"):
project_output_property_to_state_schema(
+1
View File
@@ -159,6 +159,7 @@ def test_wf_draft_map_help_explains_replace_merge_and_validate() -> None:
assert "replaces the full workflow output map" in workflow_output_help
assert "Use --merge only" in workflow_output_help
assert "GRAPH_SOURCE=OUTPUT_FIELD" in workflow_output_help
assert "output_schema fields are projected" in workflow_output_help
assert "draft validate" in workflow_output_help