feat: add workflow output draft command

This commit is contained in:
lda
2026-06-29 02:06:47 +07:00 Verified
parent 001c802a21
commit fe63eb08d4
25 changed files with 499 additions and 51 deletions
+4
View File
@@ -92,6 +92,10 @@ clear operator feedback before adding more architecture.
and step bindings so agents can recover from bad edits without raw JSON Patch. and step bindings so agents can recover from bad edits without raw JSON Patch.
Implementation: Implementation:
[`draft remove commands`](historical/superpowers/plans/2026-06-28-draft-remove-commands.md). [`draft remove commands`](historical/superpowers/plans/2026-06-28-draft-remove-commands.md).
- Completed: `wf draft set-workflow-output` and full-stack API/RPC/CLI support
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).
- Keep status read-only; do not mutate registry, auth, config, or stores. - Keep status read-only; do not mutate registry, auth, config, or stores.
## Priority 2: Durable Run/Resume Hardening ## Priority 2: Durable Run/Resume Hardening
@@ -18,7 +18,7 @@
- Modify: `src/wf_api/surface.py` - Modify: `src/wf_api/surface.py`
- Test: `tests/wf_api/test_drafts_service.py` - Test: `tests/wf_api/test_drafts_service.py`
- [ ] **Step 1: Write failing API tests** - [x] **Step 1: Write failing API tests**
Add tests that create a draft with empty `output`, call `set_workflow_output_map`, and assert the stored draft has top-level output bindings: Add tests that create a draft with empty `output`, call `set_workflow_output_map`, and assert the stored draft has top-level output bindings:
@@ -60,7 +60,7 @@ async def test_set_workflow_output_map_merges_top_level_output(tmp_path: Path) -
] ]
``` ```
- [ ] **Step 2: Run tests RED** - [x] **Step 2: Run tests RED**
Run: Run:
@@ -70,7 +70,7 @@ uv run pytest tests/wf_api/test_drafts_service.py::test_set_workflow_output_map_
Expected: fail because `set_workflow_output_map` does not exist. Expected: fail because `set_workflow_output_map` does not exist.
- [ ] **Step 3: Implement API method** - [x] **Step 3: Implement API method**
In `WorkflowDraftApi`, add: In `WorkflowDraftApi`, add:
@@ -111,11 +111,11 @@ async def set_workflow_output_map(
Add delegates to `WorkflowApi` and `WorkflowDraftSurface`. Add delegates to `WorkflowApi` and `WorkflowDraftSurface`.
- [ ] **Step 4: Run API tests GREEN** - [x] **Step 4: Run API tests GREEN**
Run the two tests from Step 2. Expected: pass. Run the two tests from Step 2. Expected: pass.
- [ ] **Step 5: Commit** - [x] **Step 5: Commit**
```powershell ```powershell
git add src/wf_api/drafts.py src/wf_api/service.py src/wf_api/surface.py tests/wf_api/test_drafts_service.py git add src/wf_api/drafts.py src/wf_api/service.py src/wf_api/surface.py tests/wf_api/test_drafts_service.py
@@ -135,7 +135,7 @@ git commit -m "feat: edit workflow output map in draft api"
- Test: `tests/wf_cli/test_remote_target.py` - Test: `tests/wf_cli/test_remote_target.py`
- Test: `tests/wf_cli/test_app.py` - Test: `tests/wf_cli/test_app.py`
- [ ] **Step 1: Add failing transport and CLI tests** - [x] **Step 1: Add failing transport and CLI tests**
Add RPC app/client tests that call `workflow.draft_workspaces.set_workflow_output_map` with `{"state.echoed": "message"}`. Add RPC app/client tests that call `workflow.draft_workspaces.set_workflow_output_map` with `{"state.echoed": "message"}`.
@@ -177,7 +177,7 @@ def test_wf_draft_set_workflow_output_uses_rpc_target(monkeypatch: pytest.Monkey
] ]
``` ```
- [ ] **Step 2: Run tests RED** - [x] **Step 2: Run tests RED**
Run: Run:
@@ -187,7 +187,7 @@ uv run pytest tests/wf_transport_rpc_http/test_app.py::test_rpc_draft_workspace_
Expected: fail because DTO/method/command are missing. Expected: fail because DTO/method/command are missing.
- [ ] **Step 3: Implement transport and CLI** - [x] **Step 3: Implement transport and CLI**
Add `SetWorkflowOutputMapParams` with `workspace_id`, `revision`, `output_map`, `merge`. Add `SetWorkflowOutputMapParams` with `workspace_id`, `revision`, `output_map`, `merge`.
@@ -228,11 +228,11 @@ def set_workflow_output(...):
Use the existing `_parse_map_flags` helper. Add `--merge` with the same replace/merge wording used by `set-input` and `set-output`. Use the existing `_parse_map_flags` helper. Add `--merge` with the same replace/merge wording used by `set-input` and `set-output`.
- [ ] **Step 4: Run tests GREEN** - [x] **Step 4: Run tests GREEN**
Run the tests from Step 2. Expected: pass. Run the tests from Step 2. Expected: pass.
- [ ] **Step 5: Commit** - [x] **Step 5: Commit**
```powershell ```powershell
git add src/wf_transport_rpc_http src/wf_cli/commands/drafts.py tests/wf_transport_rpc_http tests/wf_cli git add src/wf_transport_rpc_http src/wf_cli/commands/drafts.py tests/wf_transport_rpc_http tests/wf_cli
@@ -248,7 +248,7 @@ git commit -m "feat: expose workflow output draft command"
- 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: Document command** - [x] **Step 1: Document command**
Add example: Add example:
@@ -261,7 +261,7 @@ wf draft set-workflow-output report_ws \
State clearly: this edits top-level `WorkflowDraft.output`; step-level `wf draft set-output` edits one step's node-output-to-state bindings. State clearly: this edits top-level `WorkflowDraft.output`; step-level `wf draft set-output` edits one step's node-output-to-state bindings.
- [ ] **Step 2: Update skills** - [x] **Step 2: Update skills**
Add a rule: Add a rule:
@@ -270,7 +270,7 @@ Use `wf draft set-workflow-output` for final workflow output projection.
Use `wf draft set-output` only for step output bindings. Use `wf draft set-output` only for step output bindings.
``` ```
- [ ] **Step 3: Verify** - [x] **Step 3: Verify**
Run: Run:
@@ -280,7 +280,7 @@ uv run ruff check src/wf_api src/wf_cli src/wf_transport_rpc_http tests/wf_api t
uv run basedpyright --level error src/wf_api/drafts.py src/wf_cli/commands/drafts.py src/wf_transport_rpc_http tests/wf_api/test_drafts_service.py tests/wf_cli/test_remote_target.py uv run basedpyright --level error src/wf_api/drafts.py src/wf_cli/commands/drafts.py src/wf_transport_rpc_http tests/wf_api/test_drafts_service.py tests/wf_cli/test_remote_target.py
``` ```
- [ ] **Step 4: Commit** - [x] **Step 4: Commit**
```powershell ```powershell
git add docs/wf_cli.md docs/workflow_drafts.md skills/wf-cli/SKILL.md skills/wf-workflow/references/draft-workspaces.md docs/current_roadmap.md git add docs/wf_cli.md docs/workflow_drafts.md skills/wf-cli/SKILL.md skills/wf-workflow/references/draft-workspaces.md docs/current_roadmap.md
@@ -2,34 +2,48 @@
> **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. > **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 schema-aware `wf draft bind` discoverable from validation output for workflow input, state, and workflow output projection errors. **Goal:** Make schema-aware `wf draft bind` correctly support workflow output projection and make focused bind repairs discoverable from validation output.
**Architecture:** Draft validation already enriches diagnostics through `_with_workspace_repair_hints()` in `src/wf_api/drafts.py`. Extend `_draft_repair_hint()` beyond `invalid_destination_path` so agents get concrete `wf draft bind` or `wf draft set-workflow-output` commands instead of falling to JSON Patch. **Architecture:** Node output bindings can only target workflow state. Therefore `local.x -> output.y` must lower atomically into `local.x -> state.y` plus top-level `state.y -> output.y`, while projecting the capability field schema into both state and output schemas. Draft validation already enriches diagnostics through `_with_workspace_repair_hints()` in `src/wf_api/drafts.py`; extend those hints after the bind behavior is correct.
**Tech Stack:** Python 3.14, validation diagnostics, Typer CLI help/docs, pytest. **Tech Stack:** Python 3.14, validation diagnostics, Typer CLI help/docs, pytest.
--- ---
### Task 1: Repair Hint For Missing Workflow Output Schema ### Task 1: Make `bind local -> output` Produce Valid Workflow Output
**Files:** **Files:**
- Modify: `src/wf_api/drafts.py` - Modify: `src/wf_api/draft_authoring.py`
- 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** - [ ] **Step 1: Write failing tests**
Add a draft with top-level output binding `{"path": "state.markdown", "target": "markdown"}` and empty `output_schema.properties`. Validate the workspace and assert the diagnostic has: Add a capability-backed `render` step whose capability output schema declares `markdown`. Call:
```python ```python
assert diagnostic["code"] == "invalid_workflow_output_field" result = await authoring.bind_draft(
assert "wf draft bind" in diagnostic["repair_hint"] or "wf draft set-workflow-output" in diagnostic["repair_hint"] workspace_id="report",
revision=1,
step_id="render",
source_path="local.markdown",
target_path="output.markdown",
)
``` ```
If the source came from a capability local output in the same step, prefer a `wf draft bind ... --from local.markdown --to output.markdown` hint. If the diagnostic lacks step/local context, use: Assert the edit is valid and lowered through state:
```text ```python
wf draft set-workflow-output <workspace> --revision <n> --map state.markdown=markdown assert result["status"] == "valid"
workspace = await drafts.get_draft_workspace(workspace_id="report", include_draft=True)
assert workspace["draft"]["steps"]["render"]["output"] == [
{"source": "markdown", "target": "state.markdown"}
]
assert workspace["draft"]["output"] == [
{"path": "state.markdown", "target": "markdown"}
]
assert workspace["draft"]["state_schema"]["properties"]["markdown"]["type"] == "string"
assert workspace["draft"]["output_schema"]["properties"]["markdown"]["type"] == "string"
``` ```
- [ ] **Step 2: Run tests RED** - [ ] **Step 2: Run tests RED**
@@ -37,36 +51,35 @@ wf draft set-workflow-output <workspace> --revision <n> --map state.markdown=mar
Run: Run:
```powershell ```powershell
uv run pytest tests/wf_api/test_drafts_service.py::test_validate_draft_workspace_hints_workflow_output_projection -q uv run pytest tests/wf_api/test_drafts_service.py::test_bind_draft_local_output_to_workflow_output_lowers_through_state -q
``` ```
Expected: fail because no repair hint is present. Expected: fail because current code writes an illegal `output.*` node destination.
- [ ] **Step 3: Implement hint branch** - [ ] **Step 3: Implement hint branch**
In `_draft_repair_hint`, add handling for: In `WorkflowDraftAuthoringApi.bind_draft`, split the existing local-output branch:
```python ```python
if diagnostic.get("code") == "invalid_workflow_output_field": if source_root == "local" and target_root == "output":
path = diagnostic.get("path") output_parts = target_parts
# For output[N].target diagnostics, tell the user how to edit top-level output. state_path = f"state.{'.'.join(output_parts)}"
return ( # Project the capability output field into state_schema and output_schema.
f"wf draft set-workflow-output {workspace_id} --revision {revision} " # Merge the step local->state binding and top-level state->output binding
"--map <input.or.state.path>=<output_field>" # in one revision-checked patch.
)
``` ```
Keep this generic if details do not include enough fields. Do not invent source paths. 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** - [ ] **Step 4: Run tests GREEN**
Run the test from Step 2. Expected: pass. Run the test from Step 2. Expected: pass with `status: valid`.
- [ ] **Step 5: Commit** - [ ] **Step 5: Commit**
```powershell ```powershell
git add src/wf_api/drafts.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
git commit -m "fix: hint workflow output draft repairs" git commit -m "fix: lower workflow output binds through state"
``` ```
### Task 2: Repair Hint For Undeclared Workflow Input Used By Step Input ### Task 2: Repair Hint For Undeclared Workflow Input Used By Step Input
@@ -148,7 +161,7 @@ wf draft set-workflow-output report_ws --revision 6 --map state.markdown=markdow
Add: Add:
```md ```md
When validation gives a `repair_hint`, run that exact focused command before JSON Patch. For input/output schema errors, prefer `wf draft bind`. 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** - [ ] **Step 3: Verify**
+17 -9
View File
@@ -308,9 +308,10 @@ wf draft set-name concat_ws --revision 1 --name concat_ws_v2
wf draft set-route concat_ws --revision 2 --step call --outcome ok --to __end__ wf draft set-route concat_ws --revision 2 --step call --outcome ok --to __end__
wf draft set-input concat_ws --revision 3 --step call --map input.items=items --map input.separator=separator wf draft set-input concat_ws --revision 3 --step call --map input.items=items --map input.separator=separator
wf draft set-output concat_ws --revision 4 --step call --map value=state.value wf draft set-output concat_ws --revision 4 --step call --map value=state.value
wf draft set-input concat_ws --revision 5 --step call --merge --map input.limit=limit wf draft set-workflow-output concat_ws --revision 5 --map state.value=result
wf draft branch concat_ws --revision 6 --step call --route ok=__end__ --route error=tool_error wf draft set-input concat_ws --revision 6 --step call --merge --map input.limit=limit
wf draft handle concat_ws --revision 7 --to fail --branch lookup:error --branch transform:error wf draft branch concat_ws --revision 7 --step call --route ok=__end__ --route error=tool_error
wf draft handle concat_ws --revision 8 --to fail --branch lookup:error --branch transform:error
wf draft compile concat_ws wf draft compile concat_ws
``` ```
@@ -320,10 +321,17 @@ wf draft compile concat_ws
`set-output` maps node-local output fields to workflow state paths: `set-output` maps node-local output fields to workflow state paths:
`text=state.text` means `local.text -> state.text`. `text=state.text` means `local.text -> state.text`.
By default, `set-input` and `set-output` replace the whole map for that step. `set-workflow-output` maps graph source paths (`input.*`, `state.*`, or
Use repeated `--map` flags in one command when you know the complete map. Use `context.*`) to top-level output fields: `state.value=result` means
`--merge` when adding or updating one entry across a later revision while `state.value -> output.result`.
preserving existing bindings.
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.
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
command when you know the complete map. Use `--merge` when adding or updating
one entry across a later revision while preserving existing bindings.
### Bind A Step Path ### Bind A Step Path
@@ -334,8 +342,8 @@ use `input.*` or `state.*` to `local.*` for step inputs, and `local.*` to
`state.*` or `output.*` for step outputs. `state.*` or `output.*` for step outputs.
```bash ```bash
wf draft bind concat_ws --revision 6 --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 7 --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 validate concat_ws wf draft validate concat_ws
``` ```
+2
View File
@@ -668,6 +668,8 @@ wf draft set-name <workspace_id> --revision <n> --name <name>
wf draft set-route <workspace_id> --revision <n> --step <step_id> --outcome ok --to <target_step_or___end__> wf draft set-route <workspace_id> --revision <n> --step <step_id> --outcome ok --to <target_step_or___end__>
wf draft set-input <workspace_id> --revision <n> --step <step_id> --map input.text=text wf draft set-input <workspace_id> --revision <n> --step <step_id> --map input.text=text
wf draft set-output <workspace_id> --revision <n> --step <step_id> --map text=state.text wf draft set-output <workspace_id> --revision <n> --step <step_id> --map text=state.text
wf draft set-workflow-output <workspace_id> --revision <n> --map state.value=result
wf draft set-workflow-output <workspace_id> --revision <n> --merge --map state.other=other
wf draft branch <workspace_id> --revision <n> --step <step_id> --route ok=__end__ --route error=tool_error wf draft branch <workspace_id> --revision <n> --step <step_id> --route ok=__end__ --route error=tool_error
wf draft handle <workspace_id> --revision <n> --to fail --branch lookup:error --branch transform:error wf draft handle <workspace_id> --revision <n> --to fail --branch lookup:error --branch transform:error
wf draft compile <workspace_id> wf draft compile <workspace_id>
+2
View File
@@ -43,6 +43,8 @@ wf draft set-input <workspace_id> --revision <n> --step <step_id> --map input.te
wf draft set-input <workspace_id> --revision <n> --step <step_id> --merge --map input.other=other wf draft set-input <workspace_id> --revision <n> --step <step_id> --merge --map input.other=other
wf draft set-output <workspace_id> --revision <n> --step <step_id> --map text=state.text wf draft set-output <workspace_id> --revision <n> --step <step_id> --map text=state.text
wf draft set-output <workspace_id> --revision <n> --step <step_id> --merge --map other=state.other wf draft set-output <workspace_id> --revision <n> --step <step_id> --merge --map other=state.other
wf draft set-workflow-output <workspace_id> --revision <n> --map state.value=result
wf draft set-workflow-output <workspace_id> --revision <n> --merge --map state.other=other
wf draft branch <workspace_id> --revision <n> --step <step_id> --route ok=__end__ --route error=fail wf draft branch <workspace_id> --revision <n> --step <step_id> --route ok=__end__ --route error=fail
wf draft handle <workspace_id> --revision <n> --to fail --branch lookup:error --branch transform:error wf draft handle <workspace_id> --revision <n> --to fail --branch lookup:error --branch transform:error
wf draft compile <workspace_id> wf draft compile <workspace_id>
@@ -78,6 +78,7 @@ Prefer focused helpers over JSON Patch for common edits:
- `set_draft_route` - `set_draft_route`
- `set_step_input_map` - `set_step_input_map`
- `set_step_output_map` - `set_step_output_map`
- `set_workflow_output_map`
- `bind_draft` - `bind_draft`
- `add_step_from_capability` - `add_step_from_capability`
- `branch_draft` - `branch_draft`
@@ -93,6 +94,8 @@ wf draft set-input <workspace_id> --revision <n> --step <step_id> --map input.te
wf draft set-input <workspace_id> --revision <n> --step <step_id> --merge --map input.other=other wf draft set-input <workspace_id> --revision <n> --step <step_id> --merge --map input.other=other
wf draft set-output <workspace_id> --revision <n> --step <step_id> --map text=state.text wf draft set-output <workspace_id> --revision <n> --step <step_id> --map text=state.text
wf draft set-output <workspace_id> --revision <n> --step <step_id> --merge --map other=state.other wf draft set-output <workspace_id> --revision <n> --step <step_id> --merge --map other=state.other
wf draft set-workflow-output <workspace_id> --revision <n> --map state.value=result
wf draft set-workflow-output <workspace_id> --revision <n> --merge --map state.other=other
wf draft branch <workspace_id> --revision <n> --step <step_id> --route ok=__end__ --route error=fail wf draft branch <workspace_id> --revision <n> --step <step_id> --route ok=__end__ --route error=fail
wf draft handle <workspace_id> --revision <n> --to fail --branch lookup:error --branch transform:error wf draft handle <workspace_id> --revision <n> --to fail --branch lookup:error --branch transform:error
wf draft compile <workspace_id> wf draft compile <workspace_id>
@@ -101,15 +104,21 @@ wf draft bind <workspace_id> --revision <n> --step <step_id> --from input.<field
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 --bind-output result=state.result 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 --bind-output result=state.result
``` ```
`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`.
`set-input` direction: `input.text=text` means graph source `input.text` maps to `set-input` direction: `input.text=text` means graph source `input.text` maps to
node-local target `local.text`. node-local target `local.text`.
`set-output` direction: `text=state.text` means node-local source `local.text` `set-output` direction: `text=state.text` means node-local source `local.text`
maps to graph target `state.text`. maps to graph target `state.text`.
Without `--merge`, `set-input` and `set-output` replace the whole map for that Without `--merge`, `set-input`, `set-output`, and `set-workflow-output` replace
step. Use repeated `--map` flags in one command for a complete replacement. Use the whole map for that step or output scope. Use repeated `--map` flags in one
`--merge` only when adding/updating entries over multiple revisions. command for a complete replacement. Use `--merge` only when adding/updating
entries over multiple revisions.
- `bind_draft` - `bind_draft`
+52
View File
@@ -299,6 +299,58 @@ class WorkflowDraftApi:
], ],
) )
async def set_workflow_output_map(
self,
*,
workspace_id: str,
revision: int,
output_map: dict[str, str],
merge: bool = False,
) -> dict[str, Any]:
output_bindings: list[dict[str, Any]]
if merge:
workspace = self._draft_store().get_workspace(workspace_id)
remaining = dict(output_map)
output_bindings = []
output_payload = workspace.draft.get("output")
if isinstance(output_payload, list):
for binding in output_payload:
if not isinstance(binding, dict):
continue
source = binding.get("path")
target = binding.get("target")
if isinstance(source, str) and isinstance(target, str):
output_bindings.append(
{
"path": source,
"target": remaining.pop(source, target),
}
)
elif isinstance(target, str) and "value" in binding:
# Literal workflow outputs cannot be represented by the
# path-only CLI map, but --merge must not discard them.
output_bindings.append(dict(binding))
output_bindings.extend(
{"path": source, "target": target}
for source, target in remaining.items()
)
else:
output_bindings = [
{"path": source, "target": target}
for source, target in output_map.items()
]
return await self.patch_draft_workspace(
workspace_id=workspace_id,
revision=revision,
patch=[
{
"op": "replace",
"path": "/output",
"value": output_bindings,
}
],
)
def _step_input_maps( def _step_input_maps(
self, self,
*, *,
+15
View File
@@ -371,6 +371,21 @@ class WorkflowApi:
merge=merge, merge=merge,
) )
async def set_workflow_output_map(
self,
*,
workspace_id: str,
revision: int,
output_map: dict[str, str],
merge: bool = False,
) -> dict[str, Any]:
return await self.drafts.set_workflow_output_map(
workspace_id=workspace_id,
revision=revision,
output_map=output_map,
merge=merge,
)
async def bind_draft( async def bind_draft(
self, self,
*, *,
+9
View File
@@ -114,6 +114,15 @@ class WorkflowDraftSurface(Protocol):
merge: bool = False, merge: bool = False,
) -> dict[str, Any]: ... ) -> dict[str, Any]: ...
async def set_workflow_output_map(
self,
*,
workspace_id: str,
revision: int,
output_map: dict[str, str],
merge: bool = False,
) -> dict[str, Any]: ...
async def bind_draft( async def bind_draft(
self, self,
*, *,
+54
View File
@@ -330,6 +330,60 @@ def set_step_output_map(
) )
@app.command("set-workflow-output")
def set_workflow_output(
ctx: typer.Context,
workspace_id: Annotated[str, typer.Argument(help="Draft workspace id.")],
revision: Annotated[
int, typer.Option("--revision", min=1, help="Expected workspace revision.")
],
mapping: Annotated[
list[str] | None,
typer.Option(
"--map",
help=(
"One output binding GRAPH_SOURCE=OUTPUT_FIELD, for example "
"state.markdown=markdown. Repeat in one command."
),
),
] = None,
merge: Annotated[
bool,
typer.Option(
"--merge",
help="Preserve existing workflow output bindings and add/update the passed --map entries.",
),
] = False,
) -> None:
"""Set the top-level workflow output projection without writing JSON Patch manually.
Default behavior replaces the full workflow output map. Pass all desired
--map entries in one command for a complete replacement. Use --merge only
when adding or updating entries across a later revision.
This edits WorkflowDraft.output (top-level workflow output). Use
wf draft set-output for step-level output bindings.
Repeat --map for multiple mappings:
--map state.markdown=markdown --map state.title=title
Run `wf draft validate <workspace_id>` after editing the projection.
"""
output_map = _parse_map_flags(mapping)
context = load_cli_context(ctx)
emit_json(
run_cli_operation(
context,
context.handlers.set_workflow_output_map(
workspace_id=workspace_id,
revision=revision,
output_map=output_map,
merge=merge,
),
)
)
@app.command("bind") @app.command("bind")
def bind_draft( def bind_draft(
ctx: typer.Context, ctx: typer.Context,
+1
View File
@@ -50,6 +50,7 @@ _SEARCH_ALWAYS_VISIBLE_TOOL_NAMES = [
"wf.workflow.set_draft_route", "wf.workflow.set_draft_route",
"wf.workflow.set_step_input_map", "wf.workflow.set_step_input_map",
"wf.workflow.set_step_output_map", "wf.workflow.set_step_output_map",
"wf.workflow.set_workflow_output_map",
"wf.workflow.create_minimal_draft_workspace", "wf.workflow.create_minimal_draft_workspace",
"wf.workflow.create_artifact_from_workspace", "wf.workflow.create_artifact_from_workspace",
"wf.workflow.create_wrapper_from_workspace", "wf.workflow.create_wrapper_from_workspace",
+15
View File
@@ -243,6 +243,21 @@ class SetStepOutputMapRequest(BaseModel):
) )
class SetWorkflowOutputMapRequest(BaseModel):
"""Typed MCP request for replacing or merging workflow output projection."""
workspace_id: WorkspaceId
revision: int = Field(ge=1, description="Expected current workspace revision.")
output_map: DraftPathMap
merge: bool = Field(
default=False,
description=(
"When false, replace the full workflow output map. When true, "
"preserve existing bindings and add/update only output_map entries."
),
)
class BindDraftRequest(BaseModel): class BindDraftRequest(BaseModel):
"""Typed MCP request for binding one draft step path with schema projection.""" """Typed MCP request for binding one draft step path with schema projection."""
+22
View File
@@ -37,6 +37,7 @@ from .models import (
SetDraftRouteRequest, SetDraftRouteRequest,
SetStepInputMapRequest, SetStepInputMapRequest,
SetStepOutputMapRequest, SetStepOutputMapRequest,
SetWorkflowOutputMapRequest,
TraceRange, TraceRange,
ValidateDeploymentResult, ValidateDeploymentResult,
ValidateDraftWorkspaceRequest, ValidateDraftWorkspaceRequest,
@@ -463,6 +464,27 @@ def register_workflow_tools(server: FastMCP[Any], service: WfMcpService) -> None
) )
) )
@server.tool(
name="wf.workflow.set_workflow_output_map",
title="Set Workflow Output Map",
description=(
"Replace or merge the top-level workflow output projection. "
"Map graph source paths such as state.markdown to public output "
"fields such as markdown."
),
)
async def set_workflow_output_map(
request: SetWorkflowOutputMapRequest,
) -> DraftWorkspaceResult:
return DraftWorkspaceResult.model_validate(
await handlers.set_workflow_output_map(
workspace_id=request.workspace_id,
revision=request.revision,
output_map=request.output_map,
merge=request.merge,
)
)
@server.tool( @server.tool(
name="wf.workflow.bind", name="wf.workflow.bind",
title="Bind Draft", title="Bind Draft",
+2
View File
@@ -42,6 +42,7 @@ from .models import (
SetDraftRouteParams, SetDraftRouteParams,
SetStepInputMapParams, SetStepInputMapParams,
SetStepOutputMapParams, SetStepOutputMapParams,
SetWorkflowOutputMapParams,
StartRunParams, StartRunParams,
TraceRangeParams, TraceRangeParams,
ValidateDeploymentParams, ValidateDeploymentParams,
@@ -88,6 +89,7 @@ __all__ = [
"SetDraftRouteParams", "SetDraftRouteParams",
"SetStepInputMapParams", "SetStepInputMapParams",
"SetStepOutputMapParams", "SetStepOutputMapParams",
"SetWorkflowOutputMapParams",
"StartRunParams", "StartRunParams",
"TraceRangeParams", "TraceRangeParams",
"ValidateDeploymentParams", "ValidateDeploymentParams",
@@ -141,6 +141,24 @@ class RpcDraftClientMixin:
}, },
) )
async def set_workflow_output_map(
self: RpcCaller,
*,
workspace_id: str,
revision: int,
output_map: dict[str, str],
merge: bool = False,
) -> dict[str, Any]:
return await self._call(
"workflow.draft_workspaces.set_workflow_output_map",
{
"workspace_id": workspace_id,
"revision": revision,
"output_map": output_map,
"merge": merge,
},
)
async def bind_draft( async def bind_draft(
self: RpcCaller, self: RpcCaller,
*, *,
@@ -28,6 +28,7 @@ from ..models import (
SetDraftRouteParams, SetDraftRouteParams,
SetStepInputMapParams, SetStepInputMapParams,
SetStepOutputMapParams, SetStepOutputMapParams,
SetWorkflowOutputMapParams,
ValidateDraftParams, ValidateDraftParams,
ValidateDraftWorkspaceParams, ValidateDraftWorkspaceParams,
) )
@@ -185,6 +186,23 @@ def register_methods(
except (ValueError, KeyError, LookupError, FileNotFoundError) as exc: except (ValueError, KeyError, LookupError, FileNotFoundError) as exc:
raise_workflow_rpc_error(exc) raise_workflow_rpc_error(exc)
@entrypoint.method(
name="workflow.draft_workspaces.set_workflow_output_map",
errors=[WorkflowRpcError],
)
async def workflow_draft_workspaces_set_workflow_output_map(
params: SetWorkflowOutputMapParams = RpcParams(),
) -> dict[str, Any]:
try:
return await server.api.set_workflow_output_map(
workspace_id=params.workspace_id,
revision=params.revision,
output_map=params.output_map,
merge=params.merge,
)
except (ValueError, KeyError, LookupError, FileNotFoundError) as exc:
raise_workflow_rpc_error(exc)
@entrypoint.method( @entrypoint.method(
name="workflow.draft_workspaces.bind", name="workflow.draft_workspaces.bind",
errors=[WorkflowRpcError], errors=[WorkflowRpcError],
+7
View File
@@ -141,6 +141,13 @@ class SetStepOutputMapParams(RpcParamsModel):
merge: bool = False merge: bool = False
class SetWorkflowOutputMapParams(RpcParamsModel):
workspace_id: str = Field(min_length=1)
revision: int = Field(ge=1)
output_map: dict[str, str]
merge: bool = False
class BindDraftParams(RpcParamsModel): class BindDraftParams(RpcParamsModel):
workspace_id: str = Field(min_length=1) workspace_id: str = Field(min_length=1)
revision: int = Field(ge=1) revision: int = Field(ge=1)
+63
View File
@@ -1154,6 +1154,69 @@ async def test_compile_draft_workspace_invalid_returns_diagnostics(
assert result["diagnostics"] assert result["diagnostics"]
@pytest.mark.asyncio
async def test_set_workflow_output_map_replaces_top_level_output(
tmp_path: Path,
) -> None:
artifact_store = FileWorkflowArtifactStore(tmp_path / "drafts_out_replace")
api, _service, _authoring = _draft_api(artifact_store, register_echo=True)
await api.create_draft_workspace(workspace_id="report", draft=_echo_draft())
result = await api.set_workflow_output_map(
workspace_id="report",
revision=1,
output_map={"state.echoed": "echoed"},
)
assert result["revision"] == 2
assert result["status"] == "valid", result["diagnostics"]
fetched = await api.get_draft_workspace(workspace_id="report", include_draft=True)
assert fetched["draft"]["output"] == [{"path": "state.echoed", "target": "echoed"}]
@pytest.mark.asyncio
async def test_set_workflow_output_map_merges_top_level_output(tmp_path: Path) -> None:
artifact_store = FileWorkflowArtifactStore(tmp_path / "drafts_out_merge")
api, _service, _authoring = _draft_api(artifact_store, register_echo=True)
draft = {
**_echo_draft(),
"state_schema": {
"fields": {
"echoed": {"type": "string"},
"other": {"type": "string"},
}
},
"output_schema": {
"type": "object",
"properties": {
"echoed": {"type": "string"},
"kind": {"type": "string"},
"other": {"type": "string"},
},
},
"output": [
{"path": "state.echoed", "target": "echoed"},
{"value": "report", "target": "kind"},
],
}
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.other": "other"},
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.echoed", "target": "echoed"},
{"value": "report", "target": "kind"},
{"path": "state.other", "target": "other"},
]
# -- Browser-click test helpers for forward-route tests -- # -- Browser-click test helpers for forward-route tests --
+9
View File
@@ -140,17 +140,26 @@ def test_wf_draft_help_does_not_list_old_create_from_capability() -> None:
def test_wf_draft_map_help_explains_replace_merge_and_validate() -> None: def test_wf_draft_map_help_explains_replace_merge_and_validate() -> None:
input_result = runner.invoke(app, ["draft", "set-input", "--help"]) input_result = runner.invoke(app, ["draft", "set-input", "--help"])
output_result = runner.invoke(app, ["draft", "set-output", "--help"]) output_result = runner.invoke(app, ["draft", "set-output", "--help"])
workflow_output_result = runner.invoke(
app, ["draft", "set-workflow-output", "--help"]
)
assert input_result.exit_code == 0 assert input_result.exit_code == 0
assert output_result.exit_code == 0 assert output_result.exit_code == 0
assert workflow_output_result.exit_code == 0
input_help = " ".join(input_result.output.split()) input_help = " ".join(input_result.output.split())
output_help = " ".join(output_result.output.split()) output_help = " ".join(output_result.output.split())
workflow_output_help = " ".join(workflow_output_result.output.split())
assert "replaces the full input map" in input_help assert "replaces the full input map" in input_help
assert "Use --merge only" in input_help assert "Use --merge only" in input_help
assert "draft validate" in input_help assert "draft validate" in input_help
assert "replaces the full output map" in output_help assert "replaces the full output map" in output_help
assert "Use --merge only" in output_help assert "Use --merge only" in output_help
assert "draft validate" in output_help assert "draft validate" in output_help
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 "draft validate" in workflow_output_help
def test_wf_draft_bind_help_explains_direction() -> None: def test_wf_draft_bind_help_explains_direction() -> None:
+46
View File
@@ -11,6 +11,7 @@ from typer.testing import CliRunner
import wf_cli.context as cli_context import wf_cli.context as cli_context
from wf_api.models import RawWorkflowPlan from wf_api.models import RawWorkflowPlan
from wf_cli.app import app from wf_cli.app import app
from wf_cli.commands import drafts as drafts_module
from wf_cli.context import CliContext, load_cli_context, load_local_cli_context from wf_cli.context import CliContext, load_cli_context, load_local_cli_context
from wf_core import END from wf_core import END
from wf_server import build_local_static_workflow_server from wf_server import build_local_static_workflow_server
@@ -1143,6 +1144,51 @@ def test_wf_draft_focused_edit_commands_use_rpc_target(monkeypatch, tmp_path) ->
] ]
def test_wf_draft_set_workflow_output_uses_rpc_target(monkeypatch, tmp_path) -> None:
calls: list[dict[str, object]] = []
class FakeDrafts:
async def set_workflow_output_map(self, **kwargs: object) -> dict[str, object]:
calls.append(kwargs)
return {"workspace_id": "report", "revision": 2}
def _fake_context(ctx: object) -> CliContext:
return CliContext(
config_path=Path("dummy"),
service=None,
handlers=FakeDrafts(), # type: ignore[arg-type]
source_admin=cast(Any, object()),
admin=cast(Any, object()),
)
monkeypatch.setattr(drafts_module, "load_cli_context", _fake_context)
runner = CliRunner()
result = runner.invoke(
app,
[
"--url",
"http://example.test/rpc",
"draft",
"set-workflow-output",
"report",
"--revision",
"1",
"--map",
"state.markdown=markdown",
],
)
assert result.exit_code == 0
assert calls == [
{
"workspace_id": "report",
"revision": 1,
"output_map": {"state.markdown": "markdown"},
"merge": False,
}
]
def test_wf_draft_remove_route_uses_rpc_target(monkeypatch, tmp_path) -> None: def test_wf_draft_remove_route_uses_rpc_target(monkeypatch, tmp_path) -> None:
server = build_local_static_workflow_server(tmp_path / "store") server = build_local_static_workflow_server(tmp_path / "store")
_patch_rpc_client_to_server(monkeypatch, server) _patch_rpc_client_to_server(monkeypatch, server)
+9
View File
@@ -56,6 +56,7 @@ def test_server_exposes_upstream_admin_and_workflow_tools() -> None:
assert "wf.workflow.set_draft_route" in names assert "wf.workflow.set_draft_route" in names
assert "wf.workflow.set_step_input_map" in names assert "wf.workflow.set_step_input_map" in names
assert "wf.workflow.set_step_output_map" in names assert "wf.workflow.set_step_output_map" in names
assert "wf.workflow.set_workflow_output_map" in names
assert "wf.workflow.bind" in names assert "wf.workflow.bind" in names
assert "wf.workflow.add_step_from_capability" in names assert "wf.workflow.add_step_from_capability" in names
assert "wf.workflow.remove_draft_route" in names assert "wf.workflow.remove_draft_route" in names
@@ -117,6 +118,14 @@ def test_server_exposes_upstream_admin_and_workflow_tools() -> None:
].inputSchema ].inputSchema
set_input_request = set_input_schema["properties"]["request"] set_input_request = set_input_schema["properties"]["request"]
assert "merge" in set_input_request["properties"] assert "merge" in set_input_request["properties"]
set_workflow_output_schema = tools_by_name[
"wf.workflow.set_workflow_output_map"
].inputSchema
set_workflow_output_request = set_workflow_output_schema["properties"][
"request"
]
assert "output_map" in set_workflow_output_request["properties"]
assert "merge" in set_workflow_output_request["properties"]
add_step_schema = tools_by_name[ add_step_schema = tools_by_name[
"wf.workflow.add_step_from_capability" "wf.workflow.add_step_from_capability"
].inputSchema ].inputSchema
+1
View File
@@ -40,6 +40,7 @@ def test_server_search_mode_pins_stable_control_and_workflow_tools() -> None:
assert "wf.workflow.set_draft_route" in names assert "wf.workflow.set_draft_route" in names
assert "wf.workflow.set_step_input_map" in names assert "wf.workflow.set_step_input_map" in names
assert "wf.workflow.set_step_output_map" in names assert "wf.workflow.set_step_output_map" in names
assert "wf.workflow.set_workflow_output_map" in names
assert "wf.workflow.create_minimal_draft_workspace" in names assert "wf.workflow.create_minimal_draft_workspace" in names
assert "wf.workflow.create_artifact_from_workspace" in names assert "wf.workflow.create_artifact_from_workspace" in names
assert "wf.workflow.create_wrapper_from_workspace" in names assert "wf.workflow.create_wrapper_from_workspace" in names
+36
View File
@@ -775,6 +775,42 @@ async def test_rpc_draft_workspace_focused_edit_methods(tmp_path) -> None:
) )
async def test_rpc_draft_workspace_set_workflow_output_map(tmp_path) -> None:
server = build_local_static_workflow_server(tmp_path / "store")
app = create_rpc_app(server)
transport = httpx.ASGITransport(app=app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
created = await _rpc(
client,
"workflow.draft_workspaces.create_from_capability",
{
"workspace_id": "output_ws",
"capability_name": "wf.std.constant",
"name": "output_test",
},
)
result = await _rpc(
client,
"workflow.draft_workspaces.set_workflow_output_map",
{
"workspace_id": "output_ws",
"revision": created["result"]["revision"],
"output_map": {"state.value": "value"},
},
)
fetched = await _rpc(
client,
"workflow.draft_workspaces.get",
{"workspace_id": "output_ws", "include_draft": True},
)
assert result["result"]["revision"] == 2
assert fetched["result"]["draft"]["output"] == [
{"path": "state.value", "target": "value"},
]
async def test_rpc_draft_workspace_remove_route(tmp_path) -> None: async def test_rpc_draft_workspace_remove_route(tmp_path) -> None:
server = build_local_static_workflow_server(tmp_path / "store") server = build_local_static_workflow_server(tmp_path / "store")
app = create_rpc_app(server) app = create_rpc_app(server)
@@ -448,6 +448,39 @@ async def test_rpc_client_creates_artifact_from_plan(tmp_path) -> None:
assert inspected["id"] == "client_plan" assert inspected["id"] == "client_plan"
async def test_rpc_client_set_workflow_output_map(tmp_path) -> None:
server = build_local_static_workflow_server(tmp_path / "store")
app = create_rpc_app(server)
transport = httpx.ASGITransport(app=app)
async with httpx.AsyncClient(
transport=transport, base_url="http://test"
) as http_client:
client = RpcWorkflowApiClient(
url="http://test/rpc",
timeout_seconds=5,
http_client=http_client,
)
await client.create_draft_workspace_from_capability(
workspace_id="client_output_ws",
capability_name="wf.std.constant",
name="client_output",
)
result = await client.set_workflow_output_map(
workspace_id="client_output_ws",
revision=1,
output_map={"state.value": "value"},
)
fetched = await client.get_draft_workspace(
workspace_id="client_output_ws",
include_draft=True,
)
assert result["revision"] == 2
assert fetched["draft"]["output"] == [
{"path": "state.value", "target": "value"},
]
async def test_rpc_client_draft_workspace_focused_edit_methods(tmp_path) -> None: async def test_rpc_client_draft_workspace_focused_edit_methods(tmp_path) -> None:
server = build_local_static_workflow_server(tmp_path / "store") server = build_local_static_workflow_server(tmp_path / "store")
app = create_rpc_app(server) app = create_rpc_app(server)