docs: complete generic draft step authoring

This commit is contained in:
lda
2026-07-21 08:26:44 +07:00 Verified
parent f5bac90818
commit bc4c4e0257
21 changed files with 288 additions and 155 deletions
+3 -6
View File
@@ -2,9 +2,6 @@
## Draft authoring parity ## Draft authoring parity
- [ ] No dedicated draft CLI subcommands for adding non-capability step kinds. - [x] Dedicated draft CLI subcommands cover every draft step kind.
- [ ] `DraftInterruptPayload` cannot preserve `request_schema` or - [x] Draft interrupts preserve request and resume schemas.
`resume_schema`, so typed interrupt contracts cannot be authored through the - [x] Draft subgraphs preserve workflow references and boundary contracts.
draft model.
- [ ] `DraftStep` and its adapter have no subgraph representation even though
`SubgraphNode` is a canonical core step.
+7 -7
View File
@@ -550,12 +550,12 @@ clear operator feedback before adding more architecture.
output-to-state helper and reducing manual draft patch repairs in agent output-to-state helper and reducing manual draft patch repairs in agent
challenge runs. challenge runs.
- Completed: draft CLI vocabulary now uses `wf draft create --capability` and - Completed: draft CLI vocabulary now uses `wf draft create --capability` and
`wf draft add-step --capability`, replacing the longer the typed `wf draft add <kind>` command tree. All nine draft step kinds have
`*-from-capability` commands that agents repeatedly guessed around. dedicated commands; capability insertion retains schema projection, while
- Completed: `wf draft add-step` inserts one explicit interrupt and subgraph commands preserve their explicit boundary contracts.
capability-backed step with route, input, and output-to-state schema/binding Generic insertion is exposed through Python and JSON-RPC with atomic incoming
wiring in a single revision, reducing brittle JSON Patch authoring for and outgoing route wiring. Implementation:
multi-step workflows. Accepts `--route OUTCOME=TARGET` for multi-outcome steps. [`generic draft step authoring`](historical/superpowers/plans/2026-07-20-generic-draft-add-step.md).
- Completed: `wf draft branch` and `wf draft handle` provide atomic route - Completed: `wf draft branch` and `wf draft handle` provide atomic route
editing for existing draft steps without rewriting the full routes object. editing for existing draft steps without rewriting the full routes object.
- Completed: `wf draft compile` returns the compiled raw plan plus required - Completed: `wf draft compile` returns the compiled raw plan plus required
@@ -786,7 +786,7 @@ stable.
[`required-only wrapper inputs`](historical/superpowers/plans/2026-06-29-required-only-wrapper-inputs.md). [`required-only wrapper inputs`](historical/superpowers/plans/2026-06-29-required-only-wrapper-inputs.md).
- Completed: `wf draft set-input` rejects `local.x` targets before RPC and - Completed: `wf draft set-input` rejects `local.x` targets before RPC and
shows the equivalent bare-target mapping. shows the equivalent bare-target mapping.
- Completed: `wf draft add-step --route` errors include declared outcomes and - Completed: `wf draft add capability --route` errors include declared outcomes and
direct add/remove repair guidance. direct add/remove repair guidance.
- Completed: repeated idempotent `wf draft bind input/state -> local` behavior - Completed: repeated idempotent `wf draft bind input/state -> local` behavior
is covered by regression tests. is covered by regression tests.
@@ -54,7 +54,7 @@
- Produces: `DraftSubgraphPayload`, `DraftSubgraphStep`, expanded `DraftInterruptPayload`, and updated `DraftStep`. - Produces: `DraftSubgraphPayload`, `DraftSubgraphStep`, expanded `DraftInterruptPayload`, and updated `DraftStep`.
- Produces: adapter lowering to `InterruptNode` and `SubgraphNode` with contracts intact. - Produces: adapter lowering to `InterruptNode` and `SubgraphNode` with contracts intact.
- [ ] **Step 1: Write failing model tests for typed interrupt contracts** - [x] **Step 1: Write failing model tests for typed interrupt contracts**
Add a draft containing: Add a draft containing:
@@ -79,7 +79,7 @@ Add a draft containing:
Assert the parsed fields and `model_dump(mode="json", by_alias=True)` preserve both schemas. Assert the parsed fields and `model_dump(mode="json", by_alias=True)` preserve both schemas.
- [ ] **Step 2: Write failing model tests for subgraph boundaries** - [x] **Step 2: Write failing model tests for subgraph boundaries**
Cover both workflow reference forms: Cover both workflow reference forms:
@@ -97,13 +97,13 @@ Cover both workflow reference forms:
Assert `DraftSubgraphStep` is selected and aliases round-trip. Assert `DraftSubgraphStep` is selected and aliases round-trip.
- [ ] **Step 3: Run model tests and confirm red** - [x] **Step 3: Run model tests and confirm red**
Run: `uv run pytest tests/artifacts/test_draft_models.py -q` Run: `uv run pytest tests/artifacts/test_draft_models.py -q`
Expected: failures for forbidden interrupt schema fields and unknown `subgraph` kind. Expected: failures for forbidden interrupt schema fields and unknown `subgraph` kind.
- [ ] **Step 4: Implement the draft model fields and union member** - [x] **Step 4: Implement the draft model fields and union member**
Import `SchemaRef` and `WorkflowRef`, add `subgraph` to `STEP_KIND_KEYS`, add the payload/step classes from the approved design, and append `DraftSubgraphStep` to `DraftStep`. Add to `DraftInterruptPayload`: Import `SchemaRef` and `WorkflowRef`, add `subgraph` to `STEP_KIND_KEYS`, add the payload/step classes from the approved design, and append `DraftSubgraphStep` to `DraftStep`. Add to `DraftInterruptPayload`:
@@ -116,7 +116,7 @@ Add a field validator that accepts `None` and rejects a supplied schema unless
`schema.type == "object"`. This keeps untyped interrupts untyped while `schema.type == "object"`. This keeps untyped interrupts untyped while
validating explicit contracts at draft parse time. validating explicit contracts at draft parse time.
- [ ] **Step 5: Write failing adapter tests** - [x] **Step 5: Write failing adapter tests**
Assert `build_workflow_from_draft` produces: Assert `build_workflow_from_draft` produces:
@@ -130,7 +130,7 @@ assert child.output_schema == output_schema
assert child.outcomes == ["ok", "error"] assert child.outcomes == ["ok", "error"]
``` ```
- [ ] **Step 6: Implement adapter lowering** - [x] **Step 6: Implement adapter lowering**
Build interrupt keyword arguments so `request_schema`/`resume_schema` are Build interrupt keyword arguments so `request_schema`/`resume_schema` are
omitted when `None`; passing object defaults would incorrectly set omitted when `None`; passing object defaults would incorrectly set
@@ -149,7 +149,7 @@ return node
Use an explicit `isinstance(step, DraftSubgraphStep)` branch before the final `TypeError`. Use an explicit `isinstance(step, DraftSubgraphStep)` branch before the final `TypeError`.
- [ ] **Step 7: Verify and commit** - [x] **Step 7: Verify and commit**
Run: Run:
@@ -182,7 +182,7 @@ git commit -m "feat: complete draft step model parity"
- Consumes: `DraftStep` including `DraftSubgraphStep` from Task 1. - Consumes: `DraftStep` including `DraftSubgraphStep` from Task 1.
- Produces: `RouteSource` and `WorkflowApiSurface.add_step(*, workspace_id, revision, step_id, step, incoming, routes)`. - Produces: `RouteSource` and `WorkflowApiSurface.add_step(*, workspace_id, revision, step_id, step, incoming, routes)`.
- [ ] **Step 1: Rename the internal route value object** - [x] **Step 1: Rename the internal route value object**
Replace `DraftOutcomeRef` with: Replace `DraftOutcomeRef` with:
@@ -197,7 +197,7 @@ class RouteSource:
Update `handle_draft`, `WorkflowApi.handle_draft`, imports, and existing tests. Do not retain an alias because all callers are repository-owned. Update `handle_draft`, `WorkflowApi.handle_draft`, imports, and existing tests. Do not retain an alias because all callers are repository-owned.
- [ ] **Step 2: Write failing parameterized insertion tests** - [x] **Step 2: Write failing parameterized insertion tests**
Parameterize the nine payloads (`use`, `foreach`, `interrupt`, `join`, `end`, `when`, `choose`, `match`, `subgraph`). For each, call: Parameterize the nine payloads (`use`, `foreach`, `interrupt`, `join`, `end`, `when`, `choose`, `match`, `subgraph`). For each, call:
@@ -213,7 +213,7 @@ result = await api.add_step(
Use a Pydantic `TypeAdapter(DraftStep)` in the test and assert revision `2` plus the canonical dumped payload under `draft.steps.new_step`. Use a Pydantic `TypeAdapter(DraftStep)` in the test and assert revision `2` plus the canonical dumped payload under `draft.steps.new_step`.
- [ ] **Step 3: Write failing atomic routing/error tests** - [x] **Step 3: Write failing atomic routing/error tests**
Cover: Cover:
@@ -225,7 +225,7 @@ Cover:
- incomplete but valid route subsets accepted; - incomplete but valid route subsets accepted;
- each failure leaves revision and draft bytes unchanged. - each failure leaves revision and draft bytes unchanged.
- [ ] **Step 4: Implement declared-outcome validation** - [x] **Step 4: Implement declared-outcome validation**
Add a private helper with exhaustive `isinstance` branches: Add a private helper with exhaustive `isinstance` branches:
@@ -251,7 +251,7 @@ def _draft_step_route_outcomes(self, step: DraftStep) -> set[str] | None:
`None` means top-level routes are forbidden, not unknown. `None` means top-level routes are forbidden, not unknown.
- [ ] **Step 5: Implement `WorkflowDraftAuthoringApi.add_step`** - [x] **Step 5: Implement `WorkflowDraftAuthoringApi.add_step`**
Build a patch only after all checks pass: Build a patch only after all checks pass:
@@ -294,11 +294,11 @@ return await self.drafts.patch_draft_workspace(
Check `steps`, `routes`, duplicate id, incoming source existence, forbidden routes, and unknown route keys before this call. Check `steps`, `routes`, duplicate id, incoming source existence, forbidden routes, and unknown route keys before this call.
- [ ] **Step 6: Expose the method through service and surface** - [x] **Step 6: Expose the method through service and surface**
Use the exact signature from the design in both `WorkflowApi` and `WorkflowApiSurface`. The service method delegates to `self.draft_authoring.add_step` without converting the typed step back to a raw dict. Use the exact signature from the design in both `WorkflowApi` and `WorkflowApiSurface`. The service method delegates to `self.draft_authoring.add_step` without converting the typed step back to a raw dict.
- [ ] **Step 7: Verify and commit** - [x] **Step 7: Verify and commit**
Run: Run:
@@ -329,7 +329,7 @@ git commit -m "feat: add atomic generic draft step insertion"
- Consumes: `WorkflowApiSurface.add_step`, `DraftStep`, and `RouteSource` from Task 2. - Consumes: `WorkflowApiSurface.add_step`, `DraftStep`, and `RouteSource` from Task 2.
- Produces: method `workflow.draft_workspaces.add_step` and remote client parity. - Produces: method `workflow.draft_workspaces.add_step` and remote client parity.
- [ ] **Step 1: Write failing RPC parameter tests** - [x] **Step 1: Write failing RPC parameter tests**
Add: Add:
@@ -350,7 +350,7 @@ class AddDraftStepParams(RpcParamsModel):
Before implementation, tests should attempt to import the models and validate a foreach alias (`as`), a when alias (`if`), typed interrupt schemas, and a subgraph artifact reference. Add malformed tests for unknown/multiple kind keys and blank route-source fields. Before implementation, tests should attempt to import the models and validate a foreach alias (`as`), a when alias (`if`), typed interrupt schemas, and a subgraph artifact reference. Add malformed tests for unknown/multiple kind keys and blank route-source fields.
- [ ] **Step 2: Implement parameter models and canonical serialization tests** - [x] **Step 2: Implement parameter models and canonical serialization tests**
Import `DraftStep` from `wf_artifacts.drafts`. Assert: Import `DraftStep` from `wf_artifacts.drafts`. Assert:
@@ -360,11 +360,11 @@ assert dumped["step"]["foreach"]["as"] == "item"
assert "as_" not in dumped["step"]["foreach"] assert "as_" not in dumped["step"]["foreach"]
``` ```
- [ ] **Step 3: Write a failing server round-trip test** - [x] **Step 3: Write a failing server round-trip test**
Call `workflow.draft_workspaces.add_step` against a temporary store with a typed interrupt step, incoming source, and routes. Assert one revision increment and preserved request/resume schemas. Add a malformed RPC request and assert the workspace is unchanged. Call `workflow.draft_workspaces.add_step` against a temporary store with a typed interrupt step, incoming source, and routes. Assert one revision increment and preserved request/resume schemas. Add a malformed RPC request and assert the workspace is unchanged.
- [ ] **Step 4: Register the method** - [x] **Step 4: Register the method**
Add to `methods/drafts.py`: Add to `methods/drafts.py`:
@@ -397,7 +397,7 @@ async def workflow_draft_workspaces_add_step(
raise_workflow_rpc_error(exc) raise_workflow_rpc_error(exc)
``` ```
- [ ] **Step 5: Write failing client request-shape tests** - [x] **Step 5: Write failing client request-shape tests**
Use the existing recording transport fixture. Assert exact method name and payload: Use the existing recording transport fixture. Assert exact method name and payload:
@@ -409,7 +409,7 @@ assert request["params"]["incoming"] == {"step_id": "lookup", "outcome": "ok"}
Parameterize all nine variants so alias/schema/reference fields cannot be dropped. Parameterize all nine variants so alias/schema/reference fields cannot be dropped.
- [ ] **Step 6: Implement the client method** - [x] **Step 6: Implement the client method**
The client accepts typed values and dumps aliases explicitly: The client accepts typed values and dumps aliases explicitly:
@@ -441,7 +441,7 @@ async def add_step(
) )
``` ```
- [ ] **Step 7: Verify and commit** - [x] **Step 7: Verify and commit**
Run: Run:
@@ -472,7 +472,7 @@ git commit -m "feat: expose generic draft steps over rpc"
- Consumes: `WorkflowApiSurface.add_step` and existing `add_step_from_capability`. - Consumes: `WorkflowApiSurface.add_step` and existing `add_step_from_capability`.
- Produces: `draft_add.app` registered as `wf draft add` and migrated `capability` command. - Produces: `draft_add.app` registered as `wf draft add` and migrated `capability` command.
- [ ] **Step 1: Write failing command-tree tests** - [x] **Step 1: Write failing command-tree tests**
Assert: Assert:
@@ -486,7 +486,7 @@ removed = runner.invoke(app, ["draft", "add-step", "--help"])
assert removed.exit_code != 0 assert removed.exit_code != 0
``` ```
- [ ] **Step 2: Extract only shared parser helpers** - [x] **Step 2: Extract only shared parser helpers**
Move `_parse_assignment_flags`, `_parse_map_flags`, `_parse_output_map_flags`, `_parse_step_input_map_flags`, and `_parse_route_flags` from `drafts.py` into `draft_options.py`. Add: Move `_parse_assignment_flags`, `_parse_map_flags`, `_parse_output_map_flags`, `_parse_step_input_map_flags`, and `_parse_route_flags` from `drafts.py` into `draft_options.py`. Add:
@@ -511,7 +511,7 @@ def route_source(from_step: str | None, from_outcome: str | None) -> RouteSource
Keep imports updated so existing draft commands retain identical parsing. Keep imports updated so existing draft commands retain identical parsing.
- [ ] **Step 3: Create and register the subgroup** - [x] **Step 3: Create and register the subgroup**
In `draft_add.py`: In `draft_add.py`:
@@ -525,7 +525,7 @@ app = typer.Typer(
In `drafts.py`, import `draft_add` and register `app.add_typer(draft_add.app, name="add")` after constructing the draft app. In `drafts.py`, import `draft_add` and register `app.add_typer(draft_add.app, name="add")` after constructing the draft app.
- [ ] **Step 4: Move the capability command without changing behavior** - [x] **Step 4: Move the capability command without changing behavior**
Register the existing body as `@app.command("capability")`. Keep all current Register the existing body as `@app.command("capability")`. Keep all current
capability options and call `context.handlers.add_step_from_capability` with capability options and call `context.handlers.add_step_from_capability` with
@@ -534,7 +534,7 @@ fields, parsed routes, input map, and output bindings exactly as the removed
command does. Its docstring must state that it also projects schemas/bindings command does. Its docstring must state that it also projects schemas/bindings
and recommend `wf draft validate`. and recommend `wf draft validate`.
- [ ] **Step 5: Verify local and remote capability behavior** - [x] **Step 5: Verify local and remote capability behavior**
Update old CLI tests from: Update old CLI tests from:
@@ -550,7 +550,7 @@ wf draft add capability WORKSPACE --revision REVISION --step STEP --capability Q
Keep assertions on request payload, projected schemas, route errors, and revision unchanged. Add a remote test proving it still calls `workflow.draft_workspaces.add_step_from_capability`, not generic insertion. Keep assertions on request payload, projected schemas, route errors, and revision unchanged. Add a remote test proving it still calls `workflow.draft_workspaces.add_step_from_capability`, not generic insertion.
- [ ] **Step 6: Verify and commit** - [x] **Step 6: Verify and commit**
Run: Run:
@@ -579,7 +579,7 @@ git commit -m "feat: group draft add commands"
- Consumes: generic `add_step`, parsing helpers, and concrete draft models. - Consumes: generic `add_step`, parsing helpers, and concrete draft models.
- Produces: four type-specific commands with local/remote parity. - Produces: four type-specific commands with local/remote parity.
- [ ] **Step 1: Add a private command dispatcher and failing delegation tests** - [x] **Step 1: Add a private command dispatcher and failing delegation tests**
Use one helper so every command has identical transport behavior: Use one helper so every command has identical transport behavior:
@@ -611,7 +611,7 @@ def _submit_step(
Tests must invoke both local fake handlers and `--url` RPC targets and assert the concrete model received by `add_step`. Tests must invoke both local fake handlers and `--url` RPC targets and assert the concrete model received by `add_step`.
- [ ] **Step 2: Implement `interrupt` with schema and binding validation** - [x] **Step 2: Implement `interrupt` with schema and binding validation**
Construct: Construct:
@@ -640,7 +640,7 @@ DraftInterruptStep(interrupt=DraftInterruptPayload(
Use the repository's existing binding payload/model helpers rather than duplicating path conversion. Tests cover two outcomes, both schemas, aliases, duplicate flags, malformed files, and no API call after parse failure. Use the repository's existing binding payload/model helpers rather than duplicating path conversion. Tests cover two outcomes, both schemas, aliases, duplicate flags, malformed files, and no API call after parse failure.
- [ ] **Step 3: Implement `foreach` and validate policy relationships** - [x] **Step 3: Implement `foreach` and validate policy relationships**
Construct `DraftForeachPayload` from `--over`, `--as`, `--mode`, and: Construct `DraftForeachPayload` from `--over`, `--as`, `--mode`, and:
@@ -658,11 +658,11 @@ concurrent = (
Reject concurrent limits in serial mode with `typer.BadParameter`; rely on Pydantic to require `collect_to` for collect behavior. Route tests cover `loop`, `done`, and `completed_with_errors`. Reject concurrent limits in serial mode with `typer.BadParameter`; rely on Pydantic to require `collect_to` for collect behavior. Route tests cover `loop`, `done`, and `completed_with_errors`.
- [ ] **Step 4: Implement `join` and `end`** - [x] **Step 4: Implement `join` and `end`**
`join` constructs `DraftJoinStep(join={})` and accepts routes. `end` constructs `DraftEndStep(end=DraftEndPayload(outcome=outcome))`, exposes no `--route`, and passes `routes=None`. `join` constructs `DraftJoinStep(join={})` and accepts routes. `end` constructs `DraftEndStep(end=DraftEndPayload(outcome=outcome))`, exposes no `--route`, and passes `routes=None`.
- [ ] **Step 5: Pin per-command help and error surfaces** - [x] **Step 5: Pin per-command help and error surfaces**
For each command assert `--help` lists its own fields and does not list unrelated fields. Specifically: For each command assert `--help` lists its own fields and does not list unrelated fields. Specifically:
@@ -671,7 +671,7 @@ For each command assert `--help` lists its own fields and does not list unrelate
- join has only common routing flags; - join has only common routing flags;
- end has `--outcome` but no `--route`. - end has `--outcome` but no `--route`.
- [ ] **Step 6: Verify and commit** - [x] **Step 6: Verify and commit**
Run: Run:
@@ -701,7 +701,7 @@ git commit -m "feat: add draft control step commands"
- Consumes: `_submit_step`, JSON-file parsing, and draft models from prior tasks. - Consumes: `_submit_step`, JSON-file parsing, and draft models from prior tasks.
- Produces: `when`, `choose`, `match`, and `subgraph` commands. - Produces: `when`, `choose`, `match`, and `subgraph` commands.
- [ ] **Step 1: Write failing `when` tests and implement the command** - [x] **Step 1: Write failing `when` tests and implement the command**
Given `condition.json`: Given `condition.json`:
@@ -721,7 +721,7 @@ DraftWhenStep(when=DraftWhenPayload(
The command must not expose `--route` because targets are embedded. The command must not expose `--route` because targets are embedded.
- [ ] **Step 2: Write failing `choose` tests and implement the command** - [x] **Step 2: Write failing `choose` tests and implement the command**
`--clauses-file` contains a JSON array. Validate with `--clauses-file` contains a JSON array. Validate with
`TypeAdapter(list[DraftChooseClause]).validate_python(value)`, then construct `TypeAdapter(list[DraftChooseClause]).validate_python(value)`, then construct
@@ -729,7 +729,7 @@ The command must not expose `--route` because targets are embedded.
Tests cover ordered clauses, canonical `if` alias output, an empty array, a Tests cover ordered clauses, canonical `if` alias output, an empty array, a
non-array document, and no generic routes. non-array document, and no generic routes.
- [ ] **Step 3: Write failing `match` tests and implement the command** - [x] **Step 3: Write failing `match` tests and implement the command**
`--cases-file` contains a JSON array. Validate with `--cases-file` contains a JSON array. Validate with
`TypeAdapter(list[DraftMatchCase])`, then construct: `TypeAdapter(list[DraftMatchCase])`, then construct:
@@ -744,7 +744,7 @@ DraftMatchStep(match=DraftMatchPayload(
Tests preserve scalar `equals` values (`str`, `int`, `bool`, `None`) and ordered targets. Tests preserve scalar `equals` values (`str`, `int`, `bool`, `None`) and ordered targets.
- [ ] **Step 4: Write failing subgraph reference tests** - [x] **Step 4: Write failing subgraph reference tests**
Cover: Cover:
@@ -756,7 +756,7 @@ Cover:
All invalid combinations must fail before `add_step` is called. All invalid combinations must fail before `add_step` is called.
- [ ] **Step 5: Implement subgraph construction** - [x] **Step 5: Implement subgraph construction**
Build the reference explicitly: Build the reference explicitly:
@@ -777,7 +777,7 @@ else:
Then construct `DraftSubgraphPayload` with optional schema files, canonical input/output bindings, outcomes defaulting to `['ok']`, and description. Pass repeatable routes through `_submit_step`. Then construct `DraftSubgraphPayload` with optional schema files, canonical input/output bindings, outcomes defaulting to `['ok']`, and description. Pass repeatable routes through `_submit_step`.
- [ ] **Step 6: Verify all nine commands and remote parity** - [x] **Step 6: Verify all nine commands and remote parity**
Add a parameterized remote test that invokes every generic command and asserts method `workflow.draft_workspaces.add_step`, canonical step payload aliases, incoming route source, and routes. Keep capability in a separate assertion because it intentionally calls the composed method. Add a parameterized remote test that invokes every generic command and asserts method `workflow.draft_workspaces.add_step`, canonical step payload aliases, incoming route source, and routes. Keep capability in a separate assertion because it intentionally calls the composed method.
@@ -789,7 +789,7 @@ uv run ruff check src/wf_cli/commands tests/wf_cli
uv run basedpyright src/wf_cli/commands tests/wf_cli --level error uv run basedpyright src/wf_cli/commands tests/wf_cli --level error
``` ```
- [ ] **Step 7: Commit** - [x] **Step 7: Commit**
```bash ```bash
git add src/wf_cli/commands/draft_add.py tests/wf_cli git add src/wf_cli/commands/draft_add.py tests/wf_cli
@@ -814,7 +814,7 @@ git commit -m "feat: add draft decision and subgraph commands"
- Consumes: all implemented commands and method names. - Consumes: all implemented commands and method names.
- Produces: accurate live docs and a clean, archived implementation record. - Produces: accurate live docs and a clean, archived implementation record.
- [ ] **Step 1: Search live references before editing** - [x] **Step 1: Search live references before editing**
Run: Run:
@@ -825,7 +825,7 @@ rg -n -F 'add_step_from_capability' docs skills --glob '!docs/historical/**'
Classify each reference: migrate command examples; retain API references when they describe the composed capability helper; do not rewrite thesis/history solely for naming. Classify each reference: migrate command examples; retain API references when they describe the composed capability helper; do not rewrite thesis/history solely for naming.
- [ ] **Step 2: Update user-facing CLI and skill documentation** - [x] **Step 2: Update user-facing CLI and skill documentation**
Document the command tree and at least these complete examples: Document the command tree and at least these complete examples:
@@ -846,11 +846,11 @@ wf draft add when report_ws --revision 3 --step decide \
Explain that `when`/`choose`/`match` embed targets and do not accept `--route`, while invalid intermediate drafts remain saveable and should be checked with `wf draft validate`. Explain that `when`/`choose`/`match` embed targets and do not accept `--route`, while invalid intermediate drafts remain saveable and should be checked with `wf draft validate`.
- [ ] **Step 3: Update API architecture and roadmap** - [x] **Step 3: Update API architecture and roadmap**
Document `workflow.draft_workspaces.add_step`, the `DraftStep` boundary, separate map-key `step_id`, atomic route wiring, and the continued role of `add_step_from_capability`. Mark the roadmap slice complete only after verification. Document `workflow.draft_workspaces.add_step`, the `DraftStep` boundary, separate map-key `step_id`, atomic route wiring, and the continued role of `add_step_from_capability`. Mark the roadmap slice complete only after verification.
- [ ] **Step 4: Resolve tracked issues honestly** - [x] **Step 4: Resolve tracked issues honestly**
Change the three items in `ISSUES.md` to checked entries only if tests prove: Change the three items in `ISSUES.md` to checked entries only if tests prove:
@@ -862,7 +862,7 @@ Change the three items in `ISSUES.md` to checked entries only if tests prove:
Add any newly discovered out-of-scope defects as unchecked, reproducible statements. Add any newly discovered out-of-scope defects as unchecked, reproducible statements.
- [ ] **Step 5: Run focused regression suites** - [x] **Step 5: Run focused regression suites**
```bash ```bash
uv run pytest tests/artifacts/test_draft_models.py tests/artifacts/test_draft_adapter.py tests/wf_api/test_drafts_service.py tests/wf_transport_rpc_http/test_app.py tests/wf_transport_rpc_http/test_client.py tests/wf_cli/test_app.py tests/wf_cli/test_remote_target.py -q uv run pytest tests/artifacts/test_draft_models.py tests/artifacts/test_draft_adapter.py tests/wf_api/test_drafts_service.py tests/wf_transport_rpc_http/test_app.py tests/wf_transport_rpc_http/test_client.py tests/wf_cli/test_app.py tests/wf_cli/test_remote_target.py -q
@@ -870,7 +870,7 @@ uv run pytest tests/artifacts/test_draft_models.py tests/artifacts/test_draft_ad
Expected: all pass. Expected: all pass.
- [ ] **Step 6: Run the repository quality gate** - [x] **Step 6: Run the repository quality gate**
```bash ```bash
uv run ruff check uv run ruff check
@@ -881,11 +881,11 @@ git diff --check
If formatting fails, run `uv run ruff format`, inspect the diff, and rerun all four checks. Do not claim the full `uv run pytest -q` suite unless it is actually run; the focused matrix above is the required test gate for this slice. If formatting fails, run `uv run ruff format`, inspect the diff, and rerun all four checks. Do not claim the full `uv run pytest -q` suite unless it is actually run; the focused matrix above is the required test gate for this slice.
- [ ] **Step 7: Review and archive** - [x] **Step 7: Review and archive**
Run the `requesting-code-review` skill against the design/spec and this plan. Fix Critical/Important findings, rerun affected checks, tick completed plan checkboxes, then move the plan to the matching historical path and update live links. Run the `requesting-code-review` skill against the design/spec and this plan. Fix Critical/Important findings, rerun affected checks, tick completed plan checkboxes, then move the plan to the matching historical path and update live links.
- [ ] **Step 8: Commit documentation and issue closure** - [x] **Step 8: Commit documentation and issue closure**
```bash ```bash
git add docs skills ISSUES.md git add docs skills ISSUES.md
@@ -149,7 +149,7 @@ authoring intent.
### Add Step From Capability ### Add Step From Capability
`add-step` atomically adds: `add capability` atomically adds:
- one capability-backed `use` step; - one capability-backed `use` step;
- explicit input bindings; - explicit input bindings;
@@ -160,7 +160,7 @@ authoring intent.
The outgoing CLI option is repeatable: The outgoing CLI option is repeatable:
```powershell ```powershell
wf draft add-step WORKSPACE ` wf draft add capability WORKSPACE `
--revision 4 ` --revision 4 `
--step second_echo ` --step second_echo `
--capability everything.default.echo ` --capability everything.default.echo `
@@ -11,7 +11,7 @@ helpers behave too much like final validation gates. In challenge runs, agents
tried: tried:
```powershell ```powershell
wf draft add-step browser_click ` wf draft add capability browser_click `
--revision 1 ` --revision 1 `
--step wait ` --step wait `
--capability local.browser_click.wait_for_click ` --capability local.browser_click.wait_for_click `
@@ -66,7 +66,7 @@ Expected behavior:
## Acceptance Criteria ## Acceptance Criteria
- A focused test proves `wf draft add-step --route ok=collect` persists an - A focused test proves `wf draft add capability --route ok=collect` persists an
invalid workspace when `collect` does not exist yet. invalid workspace when `collect` does not exist yet.
- A follow-up edit that adds `collect` can make the same workspace valid. - A follow-up edit that adds `collect` can make the same workspace valid.
- `wf draft save` and `wf draft compile` continue to reject the invalid - `wf draft save` and `wf draft compile` continue to reject the invalid
+11 -2
View File
@@ -169,8 +169,17 @@ frontends. Internally, they should prefer typed models from `wf_core`,
`WorkflowDraftApi` owns draft workspace lifecycle, validation, compilation, `WorkflowDraftApi` owns draft workspace lifecycle, validation, compilation,
JSON Patch application, and focused low-level map edits. `WorkflowDraftAuthoringApi` JSON Patch application, and focused low-level map edits. `WorkflowDraftAuthoringApi`
is the semantic authoring layer above it: capability-aware bootstrap, bind, is the semantic authoring layer above it: capability-aware bootstrap, bind,
add-step, branch, handle, and remove helpers lower intent into ordinary draft typed step insertion, branch, handle, and remove helpers lower intent into
workspace patches while preserving revision checks. ordinary draft workspace patches while preserving revision checks.
`workflow.draft_workspaces.add_step` accepts a discriminated `DraftStep` value,
while `step_id` remains the separate map key chosen by the caller. The operation
atomically inserts that step plus an optional incoming `RouteSource` and any
top-level outcome routes, so a failed structural check does not partially wire
the graph. Decision targets for `when`, `choose`, and `match` remain embedded in
their typed payloads. The composed `add_step_from_capability` operation remains
separate because it also resolves capability metadata, projects schemas, and
requires complete declared-outcome coverage.
## Relationship To wf_core ## Relationship To wf_core
+44 -5
View File
@@ -389,21 +389,27 @@ The command combines two common edits:
Use `set-route` separately for outcome routing. Use `set-route` separately for outcome routing.
### Add A Capability Step To A Draft ### Add Typed Steps To A Draft
Use `wf draft add-step` when adding a new capability-backed step Use `wf draft add` to add one typed step to an existing draft:
to an existing draft. The command is explicit: it does not guess missing maps.
```text
capability interrupt foreach join end
when choose match subgraph
```
The capability command is explicit: it does not guess missing maps.
Explicit top-level `--input input.x=x` and `--input state.x=x` mappings project 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 the corresponding workflow input/state schema fields from the capability input
schema. schema.
When the capability declares multiple outcomes, provide exactly one When the capability declares multiple outcomes, provide exactly one
`--route OUTCOME=TARGET` for each declared outcome. Missing or unknown outcomes `--route OUTCOME=TARGET` for each declared outcome. Missing or unknown outcomes
are rejected before the draft is mutated. When `add-step --route` rejects an are rejected before the draft is mutated. When `add capability --route` rejects an
outcome, use the declared outcomes and repair text from the error. Remove outcome, use the declared outcomes and repair text from the error. Remove
unknown route entries and add one route for each missing declared outcome. unknown route entries and add one route for each missing declared outcome.
```bash ```bash
wf draft add-step report_ws \ wf draft add capability report_ws \
--revision 3 \ --revision 3 \
--step render \ --step render \
--capability local.report.render_markdown_report \ --capability local.report.render_markdown_report \
@@ -417,6 +423,39 @@ wf draft add-step report_ws \
--bind-output title=state.title --bind-output title=state.title
``` ```
Interrupts preserve explicit request and resume contracts:
```bash
wf draft add interrupt report_ws \
--revision 4 \
--step review \
--kind issue_review \
--request-schema-file request.schema.json \
--resume-schema-file resume.schema.json \
--outcome submitted \
--outcome cancelled \
--from-step draft_issues \
--from-outcome ok \
--route submitted=create_issues \
--route cancelled=revision_requested
```
Decision steps embed their targets and therefore do not accept `--route`:
```bash
wf draft add when report_ws \
--revision 5 \
--step decide \
--condition-file has-report.json \
--then publish \
--otherwise revise
```
`choose` reads an ordered clause array from `--clauses-file`; `match` reads an
ordered scalar case array from `--cases-file`. `subgraph` accepts either
`--workflow-name` or an immutable `--artifact-id` plus `--artifact-version`,
along with explicit boundary schemas and bindings.
Repeat `--input` and `--bind-output` once per mapping. Do not put multiple Repeat `--input` and `--bind-output` once per mapping. Do not put multiple
mappings after a single flag; `--bind-output title=state.title mappings after a single flag; `--bind-output title=state.title
summary=state.summary` is parsed as an unexpected extra argument. summary=state.summary` is parsed as an unexpected extra argument.
+12 -4
View File
@@ -50,7 +50,9 @@ wf draft handle <workspace_id> --revision <n> --to fail --branch lookup:error --
wf draft compile <workspace_id> wf draft compile <workspace_id>
wf draft bind <workspace_id> --revision <n> --step <step_id> --from local.<field> --to state.<field> wf draft bind <workspace_id> --revision <n> --step <step_id> --from local.<field> --to state.<field>
wf draft bind <workspace_id> --revision <n> --step <step_id> --from input.<field> --to local.<field> wf draft bind <workspace_id> --revision <n> --step <step_id> --from input.<field> --to local.<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 capability <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 interrupt <workspace_id> --revision <n> --step review --kind issue_review --outcome submitted --outcome cancelled --route submitted=next --route cancelled=revise
wf draft add when <workspace_id> --revision <n> --step decide --condition-file condition.json --then next --otherwise revise
wf draft validate <workspace_id> wf draft validate <workspace_id>
wf draft save <workspace_id> --artifact <artifact_id> --version <n> --title <title> wf draft save <workspace_id> --artifact <artifact_id> --version <n> --title <title>
@@ -105,7 +107,7 @@ projection. Use `input/state -> local` for step inputs and `local ->
state/output` for step outputs. It requires a capability-backed step with state/output` for step outputs. It requires a capability-backed step with
`use`; use JSON Patch for non-capability/control draft steps. `use`; use JSON Patch for non-capability/control draft steps.
To add a capability step, prefer `wf draft add-step` over raw To add a capability step, prefer `wf draft add capability` over raw
JSON Patch when the route, input bindings, and output-to-state bindings are JSON Patch when the route, input bindings, and output-to-state bindings are
known. It is explicit and does not guess missing maps. known. It is explicit and does not guess missing maps.
If a capability has multiple outcomes, pass one `--route OUTCOME=TARGET` for If a capability has multiple outcomes, pass one `--route OUTCOME=TARGET` for
@@ -114,9 +116,15 @@ Repeat `--input` and `--bind-output` once per mapping. Do not put multiple
mappings after one flag. mappings after one flag.
```bash ```bash
wf draft add-step <workspace_id> --revision <n> --step <step_id> --capability <qualified_name> --input state.title=title --input state.summary=summary --bind-output markdown=state.markdown --bind-output title=state.title wf draft add capability <workspace_id> --revision <n> --step <step_id> --capability <qualified_name> --input state.title=title --input state.summary=summary --bind-output markdown=state.markdown --bind-output title=state.title
``` ```
Use the matching `wf draft add <kind>` command for control steps. `when`,
`choose`, and `match` embed their targets and do not accept `--route`.
Interrupt and subgraph commands preserve their explicit schema contracts.
Intermediate drafts may remain `status: invalid`; run `wf draft validate`
after the intended steps and routes are present.
`wf draft compile` prints the raw plan JSON directly on success. Do not expect a `wf draft compile` prints the raw plan JSON directly on success. Do not expect a
top-level `compiled_plan` key from the CLI output. top-level `compiled_plan` key from the CLI output.
@@ -145,7 +153,7 @@ top-level `compiled_plan` key from the CLI output.
- Do not use planning-session specs or implementation plans as user-facing runtime guidance. - Do not use planning-session specs or implementation plans as user-facing runtime guidance.
- `set-input --map` is `GRAPH_SOURCE=BARE_LOCAL_FIELD`; never prefix the target - `set-input --map` is `GRAPH_SOURCE=BARE_LOCAL_FIELD`; never prefix the target
with `local.`. with `local.`.
- For `add-step --route`, route only outcomes reported by `wf cap inspect` or - For `wf draft add capability --route`, route only outcomes reported by `wf cap inspect` or
the command error's `declared_outcomes` field. the command error's `declared_outcomes` field.
- Do not confuse draft shape with raw plan shape: drafts use `steps/routes/use`; - Do not confuse draft shape with raw plan shape: drafts use `steps/routes/use`;
raw plans use `nodes/edges/node`. raw plans use `nodes/edges/node`.
@@ -74,7 +74,7 @@ If a patch returns `revision_conflict`, fetch the workspace again and retry
against the latest revision. against the latest revision.
Forward routes in drafts are allowed as invalid intermediate state. If Forward routes in drafts are allowed as invalid intermediate state. If
`wf draft add-step --route ok=collect` returns `status: invalid`, add the `wf draft add capability --route ok=collect` returns `status: invalid`, add the
missing `collect` step next, then run `wf draft validate`. Do not save or missing `collect` step next, then run `wf draft validate`. Do not save or
compile until validation is valid. compile until validation is valid.
@@ -88,6 +88,7 @@ Prefer focused helpers over JSON Patch for common edits:
- `set_step_output_map` - `set_step_output_map`
- `set_workflow_output_map` - `set_workflow_output_map`
- `bind_draft` - `bind_draft`
- `add_step`
- `add_step_from_capability` - `add_step_from_capability`
- `branch_draft` - `branch_draft`
- `handle_draft` - `handle_draft`
@@ -109,7 +110,9 @@ wf draft handle <workspace_id> --revision <n> --to fail --branch lookup:error --
wf draft compile <workspace_id> wf draft compile <workspace_id>
wf draft bind <workspace_id> --revision <n> --step <step_id> --from local.<field> --to state.<field> wf draft bind <workspace_id> --revision <n> --step <step_id> --from local.<field> --to state.<field>
wf draft bind <workspace_id> --revision <n> --step <step_id> --from input.<field> --to local.<field> wf draft bind <workspace_id> --revision <n> --step <step_id> --from input.<field> --to local.<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 capability <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 interrupt <workspace_id> --revision <n> --step review --kind issue_review --request-schema-file request.schema.json --resume-schema-file resume.schema.json --outcome submitted --outcome cancelled --route submitted=next --route cancelled=revise
wf draft add when <workspace_id> --revision <n> --step decide --condition-file condition.json --then next --otherwise revise
``` ```
`set-workflow-output` maps a graph source path (`input.*`, `state.*`, or `set-workflow-output` maps a graph source path (`input.*`, `state.*`, or
@@ -163,7 +166,8 @@ wf draft validate <workspace_id>
`--route OUTCOME=TARGET` for each outcome; when omitted and the capability `--route OUTCOME=TARGET` for each outcome; when omitted and the capability
declares a single outcome, that outcome routes to `__end__`. Multi-outcome declares a single outcome, that outcome routes to `__end__`. Multi-outcome
capabilities require exact route coverage; missing or unknown outcomes are capabilities require exact route coverage; missing or unknown outcomes are
rejected before mutation. When `add-step --route` rejects an outcome, the rejected before mutation. When `wf draft add capability --route` rejects an
outcome, the
error reports declared outcomes and direct add/remove repair guidance. Remove error reports declared outcomes and direct add/remove repair guidance. Remove
unknown route entries and add one route for each missing declared outcome. It unknown route entries and add one route for each missing declared outcome. It
still requires explicit choices; if you do not still requires explicit choices; if you do not
@@ -173,7 +177,7 @@ wf draft validate <workspace_id>
capability input schema. capability input schema.
```bash ```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 wf draft add capability <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
wf draft validate <workspace_id> wf draft validate <workspace_id>
``` ```
@@ -181,6 +185,16 @@ Repeat `--input` and `--bind-output` once per mapping. Do not write
`--bind-output title=state.title summary=state.summary`; the second mapping is `--bind-output title=state.title summary=state.summary`; the second mapping is
an unexpected extra argument because it is not attached to its own flag. an unexpected extra argument because it is not attached to its own flag.
- `add_step`
Adds any typed `DraftStep` with optional incoming and outgoing route wiring in
one revision. The CLI exposes one command per kind under `wf draft add`:
`interrupt`, `foreach`, `join`, `end`, `when`, `choose`, `match`, and
`subgraph`. Decision targets are embedded and reject `--route`. Interrupts
and subgraphs preserve JSON Schema boundary contracts. Invalid intermediate
drafts remain saveable in the workspace but must pass `wf draft validate`
before compile or artifact save.
- `branch_draft` - `branch_draft`
Updates routes for an existing step in one revision without rewriting the Updates routes for an existing step in one revision without rewriting the
@@ -23,12 +23,15 @@ validated, runnable deployment.
local input/output property. It declares the matching schema and merges local input/output property. It declares the matching schema and merges
the binding in one revision-checked edit. the binding in one revision-checked edit.
- When adding a new capability-backed step, prefer: - When adding a new capability-backed step, prefer:
```bash ```bash
wf draft add-step ... wf draft add capability ...
wf draft validate <workspace_id> wf draft validate <workspace_id>
``` ```
Use raw `wf draft patch` only when changing structure that no focused helper
covers. For control flow, use the corresponding typed `wf draft add <kind>`
command. Use raw `wf draft patch` only when changing structure that no
focused helper covers.
- Use JSON Patch only for general structural edits. - Use JSON Patch only for general structural edits.
6. Save an artifact. 6. Save an artifact.
- Draft artifact: - Draft artifact:
+7 -1
View File
@@ -166,7 +166,13 @@ class WorkflowDraftAuthoringApi:
routes: dict[str, str] | None = None, routes: dict[str, str] | None = None,
) -> dict[str, Any]: ) -> dict[str, Any]:
"""Add one typed draft step and optional route edits in one revision.""" """Add one typed draft step and optional route edits in one revision."""
workspace = self.drafts._draft_store().get_workspace(workspace_id) checked = self._workspace_if_revision_matches(
workspace_id=workspace_id,
revision=revision,
)
if isinstance(checked, dict):
return checked
workspace = checked
steps = workspace.draft.get("steps") steps = workspace.draft.get("steps")
if not isinstance(steps, dict): if not isinstance(steps, dict):
raise ValueError("draft steps must be an object") raise ValueError("draft steps must be an object")
+2 -2
View File
@@ -74,8 +74,8 @@ def _add_step(builder: WorkflowBuilder, step_id: str, step: DraftStep):
"outcomes": step.interrupt.outcomes, "outcomes": step.interrupt.outcomes,
} }
if step.interrupt.request_schema is not None: if step.interrupt.request_schema is not None:
interrupt_kwargs["request_schema"] = step.interrupt.request_schema.model_dump( interrupt_kwargs["request_schema"] = (
mode="json", exclude_none=True step.interrupt.request_schema.model_dump(mode="json", exclude_none=True)
) )
if step.interrupt.resume_schema is not None: if step.interrupt.resume_schema is not None:
interrupt_kwargs["resume_schema"] = step.interrupt.resume_schema.model_dump( interrupt_kwargs["resume_schema"] = step.interrupt.resume_schema.model_dump(
+1 -3
View File
@@ -205,9 +205,7 @@ class DraftSubgraphPayload(BaseModel):
workflow: WorkflowRef workflow: WorkflowRef
desc: str | None = None desc: str | None = None
input_schema: SchemaRef = Field(default_factory=lambda: SchemaRef(type="object")) input_schema: SchemaRef = Field(default_factory=lambda: SchemaRef(type="object"))
output_schema: SchemaRef = Field( output_schema: SchemaRef = Field(default_factory=lambda: SchemaRef(type="object"))
default_factory=lambda: SchemaRef(type="object")
)
input: list[InputBinding] = Field(default_factory=list) input: list[InputBinding] = Field(default_factory=list)
output: list[OutputBinding] = Field(default_factory=list) output: list[OutputBinding] = Field(default_factory=list)
outcomes: list[str] = Field(default_factory=lambda: ["ok"], min_length=1) outcomes: list[str] = Field(default_factory=lambda: ["ok"], min_length=1)
+45 -11
View File
@@ -145,6 +145,10 @@ def add_step_from_capability(
This command does not guess missing maps. Pass the route and bindings you This command does not guess missing maps. Pass the route and bindings you
want, then run `wf draft validate <workspace_id>`. want, then run `wf draft validate <workspace_id>`.
Example:
`wf draft add capability report_ws --revision 1 --step render
--capability local.report.render --route ok=__end__`
Repeat the flag for multiple bindings: Repeat the flag for multiple bindings:
`--input state.title=title --input state.summary=summary` `--input state.title=title --input state.summary=summary`
`--bind-output title=state.title --bind-output summary=state.summary` `--bind-output title=state.title --bind-output summary=state.summary`
@@ -222,7 +226,11 @@ def add_interrupt_step(
typer.Option("--route", help="Route mapping OUTCOME=TARGET. Repeat as needed."), typer.Option("--route", help="Route mapping OUTCOME=TARGET. Repeat as needed."),
] = None, ] = None,
) -> None: ) -> None:
"""Add a typed interrupt and its request/resume contract.""" """Add a typed interrupt and its request/resume contract.
Example: `wf draft add interrupt WS --revision 1 --step review --kind review`.
Run `wf draft validate WS` after editing.
"""
request_map = _parse_step_input_map_flags(request, option_name="--request") request_map = _parse_step_input_map_flags(request, option_name="--request")
resume_map = _parse_output_map_flags(resume, option_name="--resume") resume_map = _parse_output_map_flags(resume, option_name="--resume")
routes = _parse_route_flags(route) routes = _parse_route_flags(route)
@@ -287,9 +295,7 @@ def add_foreach_step(
over: Annotated[ over: Annotated[
str, typer.Option("--over", help="Graph path containing the item list.") str, typer.Option("--over", help="Graph path containing the item list.")
], ],
as_: Annotated[ as_: Annotated[str, typer.Option("--as", help="Context key for the current item.")],
str, typer.Option("--as", help="Context key for the current item.")
],
mode: Annotated[ mode: Annotated[
Literal["serial", "concurrent"], Literal["serial", "concurrent"],
typer.Option("--mode", help="Item admission mode."), typer.Option("--mode", help="Item admission mode."),
@@ -324,7 +330,11 @@ def add_foreach_step(
typer.Option("--route", help="Route mapping OUTCOME=TARGET. Repeat as needed."), typer.Option("--route", help="Route mapping OUTCOME=TARGET. Repeat as needed."),
] = None, ] = None,
) -> None: ) -> None:
"""Add a foreach loop with explicit item and concurrency policies.""" """Add a foreach loop with explicit item and concurrency policies.
Example: `wf draft add foreach WS --revision 1 --step each --over state.items --as item`.
Run `wf draft validate WS` after editing.
"""
if mode == "serial" and (max_active is not None or max_outstanding is not None): if mode == "serial" and (max_active is not None or max_outstanding is not None):
raise typer.BadParameter( raise typer.BadParameter(
"--max-active and --max-outstanding require --mode concurrent" "--max-active and --max-outstanding require --mode concurrent"
@@ -389,7 +399,11 @@ def add_join_step(
typer.Option("--route", help="Route mapping OUTCOME=TARGET. Repeat as needed."), typer.Option("--route", help="Route mapping OUTCOME=TARGET. Repeat as needed."),
] = None, ] = None,
) -> None: ) -> None:
"""Add a join step.""" """Add a join step.
Example: `wf draft add join WS --revision 1 --step joined --route done=__end__`.
Run `wf draft validate WS` after editing.
"""
_submit_step( _submit_step(
ctx, ctx,
workspace_id=workspace_id, workspace_id=workspace_id,
@@ -421,7 +435,11 @@ def add_end_step(
typer.Option("--from-outcome", help="Outcome on --from-step (default: ok)."), typer.Option("--from-outcome", help="Outcome on --from-step (default: ok)."),
] = None, ] = None,
) -> None: ) -> None:
"""Add an explicit terminal outcome step.""" """Add an explicit terminal outcome step.
Example: `wf draft add end WS --revision 1 --step finish --outcome ok`.
Run `wf draft validate WS` after editing.
"""
try: try:
step = DraftEndStep(end=DraftEndPayload(outcome=outcome)) step = DraftEndStep(end=DraftEndPayload(outcome=outcome))
except ValidationError as exc: except ValidationError as exc:
@@ -461,7 +479,11 @@ def add_when_step(
typer.Option("--from-outcome", help="Outcome on --from-step (default: ok)."), typer.Option("--from-outcome", help="Outcome on --from-step (default: ok)."),
] = None, ] = None,
) -> None: ) -> None:
"""Add a boolean decision whose targets are embedded in the step.""" """Add a boolean decision whose targets are embedded in the step.
Example: `wf draft add when WS --revision 1 --step decide --condition-file condition.json --then next`.
Run `wf draft validate WS` after editing.
"""
try: try:
condition = _condition_adapter.validate_python( condition = _condition_adapter.validate_python(
parse_json_file(condition_file, option_name="--condition-file") parse_json_file(condition_file, option_name="--condition-file")
@@ -508,7 +530,11 @@ def add_choose_step(
typer.Option("--from-outcome", help="Outcome on --from-step (default: ok)."), typer.Option("--from-outcome", help="Outcome on --from-step (default: ok)."),
] = None, ] = None,
) -> None: ) -> None:
"""Add an ordered first-true decision with embedded targets.""" """Add an ordered first-true decision with embedded targets.
Example: `wf draft add choose WS --revision 1 --step decide --clauses-file clauses.json`.
Run `wf draft validate WS` after editing.
"""
try: try:
clauses = _choose_clauses_adapter.validate_python( clauses = _choose_clauses_adapter.validate_python(
parse_json_file(clauses_file, option_name="--clauses-file") parse_json_file(clauses_file, option_name="--clauses-file")
@@ -554,7 +580,11 @@ def add_match_step(
typer.Option("--from-outcome", help="Outcome on --from-step (default: ok)."), typer.Option("--from-outcome", help="Outcome on --from-step (default: ok)."),
] = None, ] = None,
) -> None: ) -> None:
"""Add an ordered scalar match decision with embedded targets.""" """Add an ordered scalar match decision with embedded targets.
Example: `wf draft add match WS --revision 1 --step decide --value state.status --cases-file cases.json`.
Run `wf draft validate WS` after editing.
"""
try: try:
cases = _match_cases_adapter.validate_python( cases = _match_cases_adapter.validate_python(
parse_json_file(cases_file, option_name="--cases-file") parse_json_file(cases_file, option_name="--cases-file")
@@ -637,7 +667,11 @@ def add_subgraph_step(
typer.Option("--route", help="Route mapping OUTCOME=TARGET. Repeat as needed."), typer.Option("--route", help="Route mapping OUTCOME=TARGET. Repeat as needed."),
] = None, ] = None,
) -> None: ) -> None:
"""Add a child-workflow boundary with an explicit reference and contract.""" """Add a child-workflow boundary with an explicit reference and contract.
Example: `wf draft add subgraph WS --revision 1 --step child --workflow-name child`.
Run `wf draft validate WS` after editing.
"""
if workflow_name is not None: if workflow_name is not None:
if artifact_id is not None or artifact_version is not None: if artifact_id is not None or artifact_version is not None:
raise typer.BadParameter( raise typer.BadParameter(
+1 -3
View File
@@ -553,9 +553,7 @@ def test_adapter_lowers_subgraph_step_without_resolving_artifact() -> None:
"input_schema": input_schema, "input_schema": input_schema,
"output_schema": output_schema, "output_schema": output_schema,
"input": [{"target": "topic", "path": "state.topic"}], "input": [{"target": "topic", "path": "state.topic"}],
"output": [ "output": [{"source": "report", "target": "state.report"}],
{"source": "report", "target": "state.report"}
],
"outcomes": ["ok", "error"], "outcomes": ["ok", "error"],
} }
} }
+28
View File
@@ -932,6 +932,34 @@ async def test_add_step_routes_incoming_and_outgoing_edges_atomically(
assert workspace["draft"]["routes"]["new_step"] == {"ok": "__end__"} assert workspace["draft"]["routes"]["new_step"] == {"ok": "__end__"}
@pytest.mark.asyncio
async def test_add_step_stale_revision_wins_over_content_preflight(
tmp_path: Path,
) -> None:
artifact_store = FileWorkflowArtifactStore(tmp_path / "draft_add_stale")
draft_api, _service, authoring = _draft_api(artifact_store, register_echo=True)
api = WorkflowApi(authoring.context)
await draft_api.create_draft_workspace(workspace_id="draft_ws", draft=_echo_draft())
before = await draft_api.get_draft_workspace(
workspace_id="draft_ws", include_draft=True
)
step = TypeAdapter(DraftStep).validate_python({"join": {}})
result = await api.add_step(
workspace_id="draft_ws",
revision=2,
step_id="echo",
step=step,
)
after = await draft_api.get_draft_workspace(
workspace_id="draft_ws", include_draft=True
)
assert result["status"] == "conflict"
assert result["diagnostics"][0]["code"] == "revision_conflict"
assert after == before
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_add_step_adds_missing_incoming_route_parent_atomically( async def test_add_step_adds_missing_incoming_route_parent_atomically(
tmp_path: Path, tmp_path: Path,
+32 -26
View File
@@ -233,6 +233,7 @@ def test_wf_draft_add_capability_help_explains_explicit_wiring() -> None:
assert "does not guess" in output assert "does not guess" in output
assert "projects its schemas and bindings" in output assert "projects its schemas and bindings" in output
assert "draft validate" in output assert "draft validate" in output
assert "wf draft add capability report_ws" in output
assert "Repeat the flag" in output assert "Repeat the flag" in output
assert "--input state.title=title --input state.summary=summary" in output assert "--input state.title=title --input state.summary=summary" in output
assert ( assert (
@@ -521,9 +522,7 @@ def test_wf_draft_add_join_and_end_build_concrete_steps(monkeypatch) -> None:
assert end_result.exit_code == 0, end_result.output assert end_result.exit_code == 0, end_result.output
assert calls[0]["step"].model_dump(mode="json") == {"join": {}} assert calls[0]["step"].model_dump(mode="json") == {"join": {}}
assert calls[0]["routes"] == {"done": "finish"} assert calls[0]["routes"] == {"done": "finish"}
assert calls[1]["step"].model_dump(mode="json") == { assert calls[1]["step"].model_dump(mode="json") == {"end": {"outcome": "completed"}}
"end": {"outcome": "completed"}
}
assert calls[1]["routes"] is None assert calls[1]["routes"] is None
@@ -667,7 +666,9 @@ def test_wf_draft_add_control_commands_reject_invalid_input_before_api_call(
assert "--bind-output" not in duplicate_resume.output assert "--bind-output" not in duplicate_resume.output
assert "Traceback" not in duplicate_resume.output assert "Traceback" not in duplicate_resume.output
assert missing_collect_target.exit_code == 2 assert missing_collect_target.exit_code == 2
assert "collect item error policy requires collect_to" in missing_collect_target.output assert (
"collect item error policy requires collect_to" in missing_collect_target.output
)
assert "Traceback" not in missing_collect_target.output assert "Traceback" not in missing_collect_target.output
assert end_route.exit_code == 2 assert end_route.exit_code == 2
assert "No such option" in end_route.output assert "No such option" in end_route.output
@@ -681,7 +682,9 @@ def test_wf_draft_add_control_command_help_is_type_specific() -> None:
join = runner.invoke(app, ["draft", "add", "join", "--help"]) join = runner.invoke(app, ["draft", "add", "join", "--help"])
end = runner.invoke(app, ["draft", "add", "end", "--help"]) end = runner.invoke(app, ["draft", "add", "end", "--help"])
assert interrupt.exit_code == foreach.exit_code == join.exit_code == end.exit_code == 0 assert (
interrupt.exit_code == foreach.exit_code == join.exit_code == end.exit_code == 0
)
assert "--request-schema-file" in interrupt.output assert "--request-schema-file" in interrupt.output
assert "--resume-schema-file" in interrupt.output assert "--resume-schema-file" in interrupt.output
assert "--request" in interrupt.output assert "--request" in interrupt.output
@@ -701,7 +704,9 @@ def test_wf_draft_add_control_command_help_is_type_specific() -> None:
assert "--route" not in end.output assert "--route" not in end.output
def test_wf_draft_add_decisions_build_ordered_typed_steps(monkeypatch, tmp_path) -> None: def test_wf_draft_add_decisions_build_ordered_typed_steps(
monkeypatch, tmp_path
) -> None:
calls: list[dict[str, Any]] = [] calls: list[dict[str, Any]] = []
class FakeHandlers: class FakeHandlers:
@@ -714,9 +719,7 @@ def test_wf_draft_add_decisions_build_ordered_typed_steps(monkeypatch, tmp_path)
"wf_cli.commands.draft_add.load_cli_context", lambda _ctx: context "wf_cli.commands.draft_add.load_cli_context", lambda _ctx: context
) )
condition_file = tmp_path / "condition.json" condition_file = tmp_path / "condition.json"
condition_file.write_text( condition_file.write_text('{"op":"exists","path":"state.report"}', encoding="utf-8")
'{"op":"exists","path":"state.report"}', encoding="utf-8"
)
clauses_file = tmp_path / "clauses.json" clauses_file = tmp_path / "clauses.json"
clauses_file.write_text( clauses_file.write_text(
'[{"if":{"op":"exists","path":"state.report"},"then":"publish"},' '[{"if":{"op":"exists","path":"state.report"},"then":"publish"},'
@@ -835,9 +838,7 @@ def test_wf_draft_add_decisions_reject_bad_files_and_generic_routes(
object_cases = tmp_path / "object.json" object_cases = tmp_path / "object.json"
object_cases.write_text("{}", encoding="utf-8") object_cases.write_text("{}", encoding="utf-8")
condition_file = tmp_path / "condition.json" condition_file = tmp_path / "condition.json"
condition_file.write_text( condition_file.write_text('{"op":"exists","path":"state.report"}', encoding="utf-8")
'{"op":"exists","path":"state.report"}', encoding="utf-8"
)
empty = runner.invoke( empty = runner.invoke(
app, app,
@@ -978,23 +979,15 @@ def test_wf_draft_add_subgraph_builds_name_and_artifact_contracts(
assert named.exit_code == 0, named.output assert named.exit_code == 0, named.output
assert artifact.exit_code == 0, artifact.output assert artifact.exit_code == 0, artifact.output
named_payload = calls[0]["step"].model_dump(mode="json", by_alias=True)[ named_payload = calls[0]["step"].model_dump(mode="json", by_alias=True)["subgraph"]
"subgraph"
]
assert named_payload["workflow"] == {"name": "child_workflow"} assert named_payload["workflow"] == {"name": "child_workflow"}
assert named_payload["desc"] == "Generate the child report." assert named_payload["desc"] == "Generate the child report."
assert named_payload["input_schema"]["properties"] == { assert named_payload["input_schema"]["properties"] == {"topic": {"type": "string"}}
"topic": {"type": "string"}
}
assert named_payload["output_schema"]["properties"] == { assert named_payload["output_schema"]["properties"] == {
"report": {"type": "string"} "report": {"type": "string"}
} }
assert named_payload["input"] == [ assert named_payload["input"] == [{"target": "topic", "path": "state.topic"}]
{"target": "topic", "path": "state.topic"} assert named_payload["output"] == [{"source": "report", "target": "state.report"}]
]
assert named_payload["output"] == [
{"source": "report", "target": "state.report"}
]
assert named_payload["outcomes"] == ["ok", "error"] assert named_payload["outcomes"] == ["ok", "error"]
assert calls[0]["routes"] == {"ok": "publish", "error": "revise"} assert calls[0]["routes"] == {"ok": "publish", "error": "revise"}
artifact_payload = calls[1]["step"].model_dump(mode="json", by_alias=True)[ artifact_payload = calls[1]["step"].model_dump(mode="json", by_alias=True)[
@@ -1024,7 +1017,14 @@ def test_wf_draft_add_subgraph_rejects_invalid_reference_combinations(
invalid_refs = [ invalid_refs = [
[], [],
["--workflow-name", " "], ["--workflow-name", " "],
["--workflow-name", "child", "--artifact-id", "saved", "--artifact-version", "1"], [
"--workflow-name",
"child",
"--artifact-id",
"saved",
"--artifact-version",
"1",
],
["--artifact-id", "saved"], ["--artifact-id", "saved"],
["--artifact-version", "1"], ["--artifact-version", "1"],
] ]
@@ -1056,7 +1056,9 @@ def test_wf_draft_add_decision_and_subgraph_help_is_type_specific() -> None:
match = runner.invoke(app, ["draft", "add", "match", "--help"]) match = runner.invoke(app, ["draft", "add", "match", "--help"])
subgraph = runner.invoke(app, ["draft", "add", "subgraph", "--help"]) subgraph = runner.invoke(app, ["draft", "add", "subgraph", "--help"])
assert when.exit_code == choose.exit_code == match.exit_code == subgraph.exit_code == 0 assert (
when.exit_code == choose.exit_code == match.exit_code == subgraph.exit_code == 0
)
assert "--condition-file" in when.output assert "--condition-file" in when.output
assert "--then" in when.output assert "--then" in when.output
assert "--route" not in when.output assert "--route" not in when.output
@@ -1073,6 +1075,10 @@ def test_wf_draft_add_decision_and_subgraph_help_is_type_specific() -> None:
assert "--output-schema-file" in subgraph.output assert "--output-schema-file" in subgraph.output
assert "--route" in subgraph.output assert "--route" in subgraph.output
for result in (when, choose, match, subgraph):
assert "Example:" in result.output
assert "draft validate WS" in result.output
def test_wf_draft_help_does_not_list_old_add_step_from_capability() -> None: def test_wf_draft_help_does_not_list_old_add_step_from_capability() -> None:
result = runner.invoke(app, ["draft", "--help"]) result = runner.invoke(app, ["draft", "--help"])
+5 -7
View File
@@ -1393,7 +1393,9 @@ def test_wf_draft_add_capability_uses_rpc_target(monkeypatch, tmp_path) -> None:
assert "workflow.draft_workspaces.add_step" not in rpc_methods assert "workflow.draft_workspaces.add_step" not in rpc_methods
def test_wf_draft_add_control_steps_use_generic_rpc_target(monkeypatch, tmp_path) -> None: def test_wf_draft_add_control_steps_use_generic_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)
rpc_methods: list[str] = [] rpc_methods: list[str] = []
@@ -1421,9 +1423,7 @@ def test_wf_draft_add_control_steps_use_generic_rpc_target(monkeypatch, tmp_path
encoding="utf-8", encoding="utf-8",
) )
condition_file = tmp_path / "condition.json" condition_file = tmp_path / "condition.json"
condition_file.write_text( condition_file.write_text('{"op":"exists","path":"state.value"}', encoding="utf-8")
'{"op":"exists","path":"state.value"}', encoding="utf-8"
)
clauses_file = tmp_path / "clauses.json" clauses_file = tmp_path / "clauses.json"
clauses_file.write_text( clauses_file.write_text(
'[{"if":{"op":"exists","path":"state.value"},"then":"call"}]', '[{"if":{"op":"exists","path":"state.value"},"then":"call"}]',
@@ -1586,9 +1586,7 @@ def test_wf_draft_add_control_steps_use_generic_rpc_target(monkeypatch, tmp_path
{ {
"workflow": {"name": "child"}, "workflow": {"name": "child"},
"input": [{"target": "value", "path": "input.value"}], "input": [{"target": "value", "path": "input.value"}],
"output": [ "output": [{"source": "value", "target": "state.child_value"}],
{"source": "value", "target": "state.child_value"}
],
"outcomes": ["ok"], "outcomes": ["ok"],
}, },
{"ok": "__end__"}, {"ok": "__end__"},
+7 -10
View File
@@ -952,9 +952,7 @@ def test_add_draft_step_params_preserve_typed_step_json() -> None:
"workspace_id": "ws", "workspace_id": "ws",
"revision": 1, "revision": 1,
"step_id": "child", "step_id": "child",
"step": { "step": {"subgraph": {"workflow": {"artifact_id": "child", "version": 2}}},
"subgraph": {"workflow": {"artifact_id": "child", "version": 2}}
},
} }
) )
assert subgraph.model_dump(mode="json", by_alias=True)["step"]["subgraph"][ assert subgraph.model_dump(mode="json", by_alias=True)["step"]["subgraph"][
@@ -983,7 +981,10 @@ def test_add_draft_step_params_reject_invalid_kind_and_route_source() -> None:
} }
) )
for incoming in ({"step_id": "", "outcome": "ok"}, {"step_id": "call", "outcome": ""}): for incoming in (
{"step_id": "", "outcome": "ok"},
{"step_id": "call", "outcome": ""},
):
with pytest.raises(ValidationError): with pytest.raises(ValidationError):
AddDraftStepParams.model_validate( AddDraftStepParams.model_validate(
{ {
@@ -1058,9 +1059,7 @@ async def test_rpc_draft_workspace_add_typed_step_round_trip(tmp_path) -> None:
assert "bad" not in fetched["result"]["draft"]["steps"] assert "bad" not in fetched["result"]["draft"]["steps"]
review = fetched["result"]["draft"]["steps"]["review"]["interrupt"] review = fetched["result"]["draft"]["steps"]["review"]["interrupt"]
assert review["request_schema"]["type"] == "object" assert review["request_schema"]["type"] == "object"
assert review["resume_schema"]["properties"]["selected"] == { assert review["resume_schema"]["properties"]["selected"] == {"type": "array"}
"type": "array"
}
async def test_rpc_draft_workspace_add_untyped_interrupt_preserves_null_schemas( async def test_rpc_draft_workspace_add_untyped_interrupt_preserves_null_schemas(
@@ -1105,9 +1104,7 @@ async def test_rpc_draft_workspace_add_untyped_interrupt_preserves_null_schemas(
interrupt = fetched["result"]["draft"]["steps"]["pause"]["interrupt"] interrupt = fetched["result"]["draft"]["steps"]["pause"]["interrupt"]
assert interrupt["request_schema"] is None assert interrupt["request_schema"] is None
assert interrupt["resume_schema"] is None assert interrupt["resume_schema"] is None
assert fetched["result"]["draft"]["steps"]["pause"] == { assert fetched["result"]["draft"]["steps"]["pause"] == {"interrupt": interrupt}
"interrupt": interrupt
}
async def test_rpc_diagnoses_source(tmp_path) -> None: async def test_rpc_diagnoses_source(tmp_path) -> None:
+7 -9
View File
@@ -721,9 +721,7 @@ async def test_rpc_client_draft_workspace_add_step_from_capability(tmp_path) ->
( (
"subgraph", "subgraph",
TypeAdapter(DraftStep).validate_python( TypeAdapter(DraftStep).validate_python(
{ {"subgraph": {"workflow": {"artifact_id": "child", "version": 2}}}
"subgraph": {"workflow": {"artifact_id": "child", "version": 2}}
}
), ),
{"workflow": {"artifact_id": "child", "version": 2}}, {"workflow": {"artifact_id": "child", "version": 2}},
), ),
@@ -769,12 +767,12 @@ async def test_rpc_client_add_step_preserves_all_typed_variants(
assert request["params"]["step"]["foreach"]["as"] == "item" assert request["params"]["step"]["foreach"]["as"] == "item"
assert "as_" not in request["params"]["step"]["foreach"] assert "as_" not in request["params"]["step"]["foreach"]
if step_id == "interrupt": if step_id == "interrupt":
assert request["params"]["step"]["interrupt"]["request_schema"][ assert (
"type" request["params"]["step"]["interrupt"]["request_schema"]["type"] == "object"
] == "object" )
assert request["params"]["step"]["interrupt"]["resume_schema"][ assert (
"type" request["params"]["step"]["interrupt"]["resume_schema"]["type"] == "object"
] == "object" )
if step_id == "subgraph": if step_id == "subgraph":
assert request["params"]["step"]["subgraph"]["workflow"] == { assert request["params"]["step"]["subgraph"]["workflow"] == {
"artifact_id": "child", "artifact_id": "child",