docs: plan generic draft step authoring
This commit is contained in:
@@ -1,3 +1,10 @@
|
||||
# Issues
|
||||
|
||||
## no dedicated draft CLI subcommands for adding other step kinds
|
||||
## Draft authoring parity
|
||||
|
||||
- [ ] No dedicated draft CLI subcommands for adding non-capability step kinds.
|
||||
- [ ] `DraftInterruptPayload` cannot preserve `request_schema` or
|
||||
`resume_schema`, so typed interrupt contracts cannot be authored through the
|
||||
draft model.
|
||||
- [ ] `DraftStep` and its adapter have no subgraph representation even though
|
||||
`SubgraphNode` is a canonical core step.
|
||||
|
||||
@@ -0,0 +1,893 @@
|
||||
# Generic Draft Step Authoring Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Add typed draft-step insertion across the Python application API, JSON-RPC client/server, and a discoverable `wf draft add` CLI subgroup while closing interrupt and subgraph draft-model gaps.
|
||||
|
||||
**Architecture:** `wf_artifacts.drafts` remains the canonical persisted authoring model; the generic API accepts a validated `DraftStep` plus a separate map-key `step_id` and lowers one atomic semantic edit into JSON Patch. Python RPC carries the same typed shape, while type-specific Typer commands construct draft models and delegate through the protocol-neutral `WorkflowApiSurface`. Capability composition remains a specialized helper because it also projects schemas and bindings.
|
||||
|
||||
**Tech Stack:** Python 3.14, Pydantic v2, Typer, fastapi-jsonrpc, pytest/pytest-asyncio, Ruff, basedpyright.
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- Follow `docs/superpowers/specs/2026-07-20-generic-draft-add-step-design.md`.
|
||||
- Use `DraftStep`, not the core `Step` union, at draft/API/RPC boundaries.
|
||||
- Keep `step_id` separate because draft identifiers are keys in `WorkflowDraft.steps`.
|
||||
- Preserve one revision increment for step insertion plus requested incoming/outgoing routes.
|
||||
- Preserve `add_step_from_capability` behavior, especially schema projection and complete multi-outcome routing.
|
||||
- Remove `wf draft add-step`; do not add a compatibility alias without a real caller.
|
||||
- Do not add TypeScript/Effect RPC parity or code generation in this slice.
|
||||
- Structured conditions, clauses, cases, and schemas enter the CLI through JSON files.
|
||||
- Add docstrings/comments around alias serialization, route validation, and other non-obvious seams.
|
||||
- Use focused tests during tasks; run the full Python quality gate only in Task 7.
|
||||
|
||||
## File Map
|
||||
|
||||
- `src/wf_artifacts/drafts/models.py`: persisted draft variants, including typed interrupt contracts and subgraph payloads.
|
||||
- `src/wf_artifacts/drafts/adapter.py`: lower draft variants into core nodes without artifact loading.
|
||||
- `src/wf_api/draft_authoring.py`: generic semantic insertion, route validation, and `RouteSource`.
|
||||
- `src/wf_api/service.py`, `src/wf_api/surface.py`: concrete and protocol-neutral public API methods.
|
||||
- `src/wf_transport_rpc_http/models.py`: typed RPC parameter models.
|
||||
- `src/wf_transport_rpc_http/methods/drafts.py`: JSON-RPC method registration.
|
||||
- `src/wf_transport_rpc_http/client/drafts.py`: remote implementation of the same surface.
|
||||
- `src/wf_cli/commands/draft_options.py`: shared parsing helpers used by existing and new draft commands.
|
||||
- `src/wf_cli/commands/draft_add.py`: `wf draft add` Typer subgroup and variant construction.
|
||||
- `src/wf_cli/commands/drafts.py`: subgroup registration and removal of the flat command.
|
||||
- `tests/artifacts/test_draft_models.py`, `tests/artifacts/test_draft_adapter.py`: model/adapter parity.
|
||||
- `tests/wf_api/test_drafts_service.py`: semantic application behavior and atomicity.
|
||||
- `tests/wf_transport_rpc_http/test_app.py`, `tests/wf_transport_rpc_http/test_client.py`: transport round trips.
|
||||
- `tests/wf_cli/test_app.py`, `tests/wf_cli/test_remote_target.py`: command UX and local/remote delegation.
|
||||
- `docs/wf_cli.md`, `docs/wf_api_architecture.md`, `docs/current_roadmap.md`, `skills/wf-cli/SKILL.md`, `skills/wf-workflow/references/*.md`, `ISSUES.md`: live documentation and issue closure.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Complete Draft Interrupt And Subgraph Parity
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/wf_artifacts/drafts/models.py`
|
||||
- Modify: `src/wf_artifacts/drafts/adapter.py`
|
||||
- Modify: `src/wf_artifacts/drafts/__init__.py` if it exports individual variants
|
||||
- Test: `tests/artifacts/test_draft_models.py`
|
||||
- Test: `tests/artifacts/test_draft_adapter.py`
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: `DraftSubgraphPayload`, `DraftSubgraphStep`, expanded `DraftInterruptPayload`, and updated `DraftStep`.
|
||||
- Produces: adapter lowering to `InterruptNode` and `SubgraphNode` with contracts intact.
|
||||
|
||||
- [ ] **Step 1: Write failing model tests for typed interrupt contracts**
|
||||
|
||||
Add a draft containing:
|
||||
|
||||
```python
|
||||
"review": {
|
||||
"interrupt": {
|
||||
"kind": "issue_review",
|
||||
"request_schema": {
|
||||
"type": "object",
|
||||
"properties": {"issues": {"type": "array"}},
|
||||
"required": ["issues"],
|
||||
},
|
||||
"resume_schema": {
|
||||
"type": "object",
|
||||
"properties": {"selected": {"type": "array"}},
|
||||
"required": ["selected"],
|
||||
},
|
||||
"outcomes": ["submitted", "cancelled"],
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Assert the parsed fields and `model_dump(mode="json", by_alias=True)` preserve both schemas.
|
||||
|
||||
- [ ] **Step 2: Write failing model tests for subgraph boundaries**
|
||||
|
||||
Cover both workflow reference forms:
|
||||
|
||||
```python
|
||||
{"subgraph": {"workflow": {"name": "child"}, "outcomes": ["ok"]}}
|
||||
{"subgraph": {
|
||||
"workflow": {"artifact_id": "child_report", "version": 2},
|
||||
"input_schema": {"type": "object", "properties": {"topic": {"type": "string"}}},
|
||||
"output_schema": {"type": "object", "properties": {"report": {"type": "string"}}},
|
||||
"input": [{"target": "topic", "path": "state.topic"}],
|
||||
"output": [{"source": "report", "target": "state.report"}],
|
||||
"outcomes": ["ok", "error"],
|
||||
}}
|
||||
```
|
||||
|
||||
Assert `DraftSubgraphStep` is selected and aliases round-trip.
|
||||
|
||||
- [ ] **Step 3: Run model tests and confirm red**
|
||||
|
||||
Run: `uv run pytest tests/artifacts/test_draft_models.py -q`
|
||||
|
||||
Expected: failures for forbidden interrupt schema fields and unknown `subgraph` kind.
|
||||
|
||||
- [ ] **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`:
|
||||
|
||||
```python
|
||||
request_schema: SchemaRef | None = None
|
||||
resume_schema: SchemaRef | None = None
|
||||
```
|
||||
|
||||
Add a field validator that accepts `None` and rejects a supplied schema unless
|
||||
`schema.type == "object"`. This keeps untyped interrupts untyped while
|
||||
validating explicit contracts at draft parse time.
|
||||
|
||||
- [ ] **Step 5: Write failing adapter tests**
|
||||
|
||||
Assert `build_workflow_from_draft` produces:
|
||||
|
||||
```python
|
||||
assert review.request_schema == request_schema
|
||||
assert review.resume_schema == resume_schema
|
||||
assert child.workflow.artifact_id == "child_report"
|
||||
assert child.workflow.version == 2
|
||||
assert child.input_schema == input_schema
|
||||
assert child.output_schema == output_schema
|
||||
assert child.outcomes == ["ok", "error"]
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Implement adapter lowering**
|
||||
|
||||
Build interrupt keyword arguments so `request_schema`/`resume_schema` are
|
||||
omitted when `None`; passing object defaults would incorrectly set
|
||||
`has_explicit_contract`. For subgraphs, append a direct core node because
|
||||
adapting a draft must not resolve an artifact:
|
||||
|
||||
```python
|
||||
node = SubgraphNode(
|
||||
id=step_id,
|
||||
type="subgraph",
|
||||
**step.subgraph.model_dump(),
|
||||
)
|
||||
builder.nodes.append(node)
|
||||
return node
|
||||
```
|
||||
|
||||
Use an explicit `isinstance(step, DraftSubgraphStep)` branch before the final `TypeError`.
|
||||
|
||||
- [ ] **Step 7: Verify and commit**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
uv run pytest tests/artifacts/test_draft_models.py tests/artifacts/test_draft_adapter.py -q
|
||||
uv run basedpyright src/wf_artifacts tests/artifacts --level error
|
||||
```
|
||||
|
||||
Expected: both pass.
|
||||
|
||||
Commit:
|
||||
|
||||
```bash
|
||||
git add src/wf_artifacts/drafts tests/artifacts/test_draft_models.py tests/artifacts/test_draft_adapter.py
|
||||
git commit -m "feat: complete draft step model parity"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Add Atomic Generic Draft-Step Insertion
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/wf_api/draft_authoring.py`
|
||||
- Modify: `src/wf_api/service.py`
|
||||
- Modify: `src/wf_api/surface.py`
|
||||
- Modify: `src/wf_api/__init__.py` if `RouteSource` is publicly exported
|
||||
- Test: `tests/wf_api/test_drafts_service.py`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `DraftStep` including `DraftSubgraphStep` from Task 1.
|
||||
- Produces: `RouteSource` and `WorkflowApiSurface.add_step(*, workspace_id, revision, step_id, step, incoming, routes)`.
|
||||
|
||||
- [ ] **Step 1: Rename the internal route value object**
|
||||
|
||||
Replace `DraftOutcomeRef` with:
|
||||
|
||||
```python
|
||||
@dataclass(frozen=True)
|
||||
class RouteSource:
|
||||
"""One source step/outcome pair used for atomic route edits."""
|
||||
|
||||
step_id: str
|
||||
outcome: str = DEFAULT_OK_OUTCOME
|
||||
```
|
||||
|
||||
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**
|
||||
|
||||
Parameterize the nine payloads (`use`, `foreach`, `interrupt`, `join`, `end`, `when`, `choose`, `match`, `subgraph`). For each, call:
|
||||
|
||||
```python
|
||||
step_adapter = TypeAdapter(DraftStep)
|
||||
result = await api.add_step(
|
||||
workspace_id="draft_ws",
|
||||
revision=1,
|
||||
step_id="new_step",
|
||||
step=step_adapter.validate_python(step_payload),
|
||||
)
|
||||
```
|
||||
|
||||
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**
|
||||
|
||||
Cover:
|
||||
|
||||
- `incoming=RouteSource("existing", "ok")` plus outgoing routes in one revision;
|
||||
- unknown incoming source;
|
||||
- duplicate id;
|
||||
- unknown route outcome;
|
||||
- routes supplied for `end`, `when`, `choose`, and `match`;
|
||||
- incomplete but valid route subsets accepted;
|
||||
- each failure leaves revision and draft bytes unchanged.
|
||||
|
||||
- [ ] **Step 4: Implement declared-outcome validation**
|
||||
|
||||
Add a private helper with exhaustive `isinstance` branches:
|
||||
|
||||
```python
|
||||
def _draft_step_route_outcomes(self, step: DraftStep) -> set[str] | None:
|
||||
if isinstance(step, DraftUseStep):
|
||||
return set(self._outcomes_for_capability(step.use) or ("ok",))
|
||||
if isinstance(step, DraftForeachStep):
|
||||
outcomes = {"loop", "done"}
|
||||
if step.foreach.item_error.action in {"skip", "collect"}:
|
||||
outcomes.add("completed_with_errors")
|
||||
return outcomes
|
||||
if isinstance(step, DraftInterruptStep):
|
||||
return set(step.interrupt.outcomes)
|
||||
if isinstance(step, DraftJoinStep):
|
||||
return {"done"}
|
||||
if isinstance(step, DraftSubgraphStep):
|
||||
return set(step.subgraph.outcomes)
|
||||
if isinstance(step, (DraftEndStep, DraftWhenStep, DraftChooseStep, DraftMatchStep)):
|
||||
return None
|
||||
raise TypeError(f"unsupported draft step {type(step)!r}")
|
||||
```
|
||||
|
||||
`None` means top-level routes are forbidden, not unknown.
|
||||
|
||||
- [ ] **Step 5: Implement `WorkflowDraftAuthoringApi.add_step`**
|
||||
|
||||
Build a patch only after all checks pass:
|
||||
|
||||
```python
|
||||
patch = [{
|
||||
"op": "add",
|
||||
"path": f"/steps/{escape_json_pointer(step_id)}",
|
||||
"value": step.model_dump(mode="json", by_alias=True),
|
||||
}]
|
||||
if routes is not None:
|
||||
patch.append({
|
||||
"op": "add",
|
||||
"path": f"/routes/{escape_json_pointer(step_id)}",
|
||||
"value": routes,
|
||||
})
|
||||
if incoming is not None:
|
||||
source_routes = draft_routes.get(incoming.step_id)
|
||||
if source_routes is None:
|
||||
# JSON Patch cannot add a nested outcome until its parent route map exists.
|
||||
patch.append({
|
||||
"op": "add",
|
||||
"path": f"/routes/{escape_json_pointer(incoming.step_id)}",
|
||||
"value": {incoming.outcome: step_id},
|
||||
})
|
||||
else:
|
||||
patch.append({
|
||||
"op": "add",
|
||||
"path": (
|
||||
f"/routes/{escape_json_pointer(incoming.step_id)}/"
|
||||
f"{escape_json_pointer(incoming.outcome)}"
|
||||
),
|
||||
"value": step_id,
|
||||
})
|
||||
return await self.drafts.patch_draft_workspace(
|
||||
workspace_id=workspace_id,
|
||||
revision=revision,
|
||||
patch=patch,
|
||||
)
|
||||
```
|
||||
|
||||
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**
|
||||
|
||||
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**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
uv run pytest tests/wf_api/test_drafts_service.py -q
|
||||
uv run basedpyright src/wf_api tests/wf_api/test_drafts_service.py --level error
|
||||
```
|
||||
|
||||
Commit:
|
||||
|
||||
```bash
|
||||
git add src/wf_api tests/wf_api/test_drafts_service.py
|
||||
git commit -m "feat: add atomic generic draft step insertion"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Expose Generic Insertion Through Python JSON-RPC
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/wf_transport_rpc_http/models.py`
|
||||
- Modify: `src/wf_transport_rpc_http/methods/drafts.py`
|
||||
- Modify: `src/wf_transport_rpc_http/client/drafts.py`
|
||||
- Test: `tests/wf_transport_rpc_http/test_app.py`
|
||||
- Test: `tests/wf_transport_rpc_http/test_client.py`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `WorkflowApiSurface.add_step`, `DraftStep`, and `RouteSource` from Task 2.
|
||||
- Produces: method `workflow.draft_workspaces.add_step` and remote client parity.
|
||||
|
||||
- [ ] **Step 1: Write failing RPC parameter tests**
|
||||
|
||||
Add:
|
||||
|
||||
```python
|
||||
class RouteSourceParams(RpcParamsModel):
|
||||
step_id: str = Field(min_length=1)
|
||||
outcome: str = Field(default="ok", min_length=1)
|
||||
|
||||
|
||||
class AddDraftStepParams(RpcParamsModel):
|
||||
workspace_id: str = Field(min_length=1)
|
||||
revision: int = Field(ge=1)
|
||||
step_id: str = Field(min_length=1)
|
||||
step: DraftStep
|
||||
incoming: RouteSourceParams | None = None
|
||||
routes: dict[str, str] | None = None
|
||||
```
|
||||
|
||||
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**
|
||||
|
||||
Import `DraftStep` from `wf_artifacts.drafts`. Assert:
|
||||
|
||||
```python
|
||||
dumped = params.model_dump(mode="json", by_alias=True)
|
||||
assert dumped["step"]["foreach"]["as"] == "item"
|
||||
assert "as_" not in dumped["step"]["foreach"]
|
||||
```
|
||||
|
||||
- [ ] **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.
|
||||
|
||||
- [ ] **Step 4: Register the method**
|
||||
|
||||
Add to `methods/drafts.py`:
|
||||
|
||||
```python
|
||||
@entrypoint.method(
|
||||
name="workflow.draft_workspaces.add_step",
|
||||
errors=[WorkflowRpcError],
|
||||
)
|
||||
async def workflow_draft_workspaces_add_step(
|
||||
params: AddDraftStepParams = RpcParams(),
|
||||
) -> dict[str, Any]:
|
||||
try:
|
||||
incoming = (
|
||||
None
|
||||
if params.incoming is None
|
||||
else RouteSource(
|
||||
step_id=params.incoming.step_id,
|
||||
outcome=params.incoming.outcome,
|
||||
)
|
||||
)
|
||||
return await server.api.add_step(
|
||||
workspace_id=params.workspace_id,
|
||||
revision=params.revision,
|
||||
step_id=params.step_id,
|
||||
step=params.step,
|
||||
incoming=incoming,
|
||||
routes=params.routes,
|
||||
)
|
||||
except (ValueError, KeyError, LookupError, FileNotFoundError) as exc:
|
||||
raise_workflow_rpc_error(exc)
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Write failing client request-shape tests**
|
||||
|
||||
Use the existing recording transport fixture. Assert exact method name and payload:
|
||||
|
||||
```python
|
||||
assert request["method"] == "workflow.draft_workspaces.add_step"
|
||||
assert request["params"]["step"]["when"]["if"]["op"] == "exists"
|
||||
assert request["params"]["incoming"] == {"step_id": "lookup", "outcome": "ok"}
|
||||
```
|
||||
|
||||
Parameterize all nine variants so alias/schema/reference fields cannot be dropped.
|
||||
|
||||
- [ ] **Step 6: Implement the client method**
|
||||
|
||||
The client accepts typed values and dumps aliases explicitly:
|
||||
|
||||
```python
|
||||
async def add_step(
|
||||
self: RpcCaller,
|
||||
*,
|
||||
workspace_id: str,
|
||||
revision: int,
|
||||
step_id: str,
|
||||
step: DraftStep,
|
||||
incoming: RouteSource | None = None,
|
||||
routes: dict[str, str] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
return await self._call(
|
||||
"workflow.draft_workspaces.add_step",
|
||||
{
|
||||
"workspace_id": workspace_id,
|
||||
"revision": revision,
|
||||
"step_id": step_id,
|
||||
"step": step.model_dump(mode="json", by_alias=True),
|
||||
"incoming": (
|
||||
None
|
||||
if incoming is None
|
||||
else {"step_id": incoming.step_id, "outcome": incoming.outcome}
|
||||
),
|
||||
"routes": routes,
|
||||
},
|
||||
)
|
||||
```
|
||||
|
||||
- [ ] **Step 7: Verify and commit**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
uv run pytest tests/wf_transport_rpc_http/test_app.py tests/wf_transport_rpc_http/test_client.py -q
|
||||
uv run basedpyright src/wf_transport_rpc_http tests/wf_transport_rpc_http --level error
|
||||
```
|
||||
|
||||
Commit:
|
||||
|
||||
```bash
|
||||
git add src/wf_transport_rpc_http tests/wf_transport_rpc_http
|
||||
git commit -m "feat: expose generic draft steps over rpc"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Establish The `wf draft add` Command Boundary
|
||||
|
||||
**Files:**
|
||||
- Create: `src/wf_cli/commands/draft_options.py`
|
||||
- Create: `src/wf_cli/commands/draft_add.py`
|
||||
- Modify: `src/wf_cli/commands/drafts.py`
|
||||
- Test: `tests/wf_cli/test_app.py`
|
||||
- Test: `tests/wf_cli/test_remote_target.py`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `WorkflowApiSurface.add_step` and existing `add_step_from_capability`.
|
||||
- Produces: `draft_add.app` registered as `wf draft add` and migrated `capability` command.
|
||||
|
||||
- [ ] **Step 1: Write failing command-tree tests**
|
||||
|
||||
Assert:
|
||||
|
||||
```python
|
||||
result = runner.invoke(app, ["draft", "add", "--help"])
|
||||
assert result.exit_code == 0
|
||||
for name in ("capability", "interrupt", "foreach", "join", "end", "when", "choose", "match", "subgraph"):
|
||||
assert name in result.output
|
||||
|
||||
removed = runner.invoke(app, ["draft", "add-step", "--help"])
|
||||
assert removed.exit_code != 0
|
||||
```
|
||||
|
||||
- [ ] **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:
|
||||
|
||||
```python
|
||||
def parse_json_file(path: Path, *, option_name: str) -> Any:
|
||||
"""Read one structured CLI value and report file/JSON failures as option errors."""
|
||||
try:
|
||||
return json.loads(path.read_text(encoding="utf-8"))
|
||||
except OSError as exc:
|
||||
raise typer.BadParameter(f"{option_name}: cannot read {path}: {exc}") from exc
|
||||
except json.JSONDecodeError as exc:
|
||||
raise typer.BadParameter(f"{option_name}: invalid JSON in {path}: {exc.msg}") from exc
|
||||
|
||||
|
||||
def route_source(from_step: str | None, from_outcome: str | None) -> RouteSource | None:
|
||||
if from_step is None:
|
||||
if from_outcome is not None:
|
||||
raise typer.BadParameter("--from-outcome requires --from-step")
|
||||
return None
|
||||
return RouteSource(step_id=from_step, outcome=from_outcome or "ok")
|
||||
```
|
||||
|
||||
Keep imports updated so existing draft commands retain identical parsing.
|
||||
|
||||
- [ ] **Step 3: Create and register the subgroup**
|
||||
|
||||
In `draft_add.py`:
|
||||
|
||||
```python
|
||||
app = typer.Typer(
|
||||
name="add",
|
||||
help="Add one typed step to a draft workspace.",
|
||||
no_args_is_help=True,
|
||||
)
|
||||
```
|
||||
|
||||
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**
|
||||
|
||||
Register the existing body as `@app.command("capability")`. Keep all current
|
||||
capability options and call `context.handlers.add_step_from_capability` with
|
||||
`workspace_id`, `revision`, `step_id`, `capability_name`, incoming route
|
||||
fields, parsed routes, input map, and output bindings exactly as the removed
|
||||
command does. Its docstring must state that it also projects schemas/bindings
|
||||
and recommend `wf draft validate`.
|
||||
|
||||
- [ ] **Step 5: Verify local and remote capability behavior**
|
||||
|
||||
Update old CLI tests from:
|
||||
|
||||
```text
|
||||
wf draft add-step WORKSPACE --revision REVISION --step STEP --capability QUALIFIED_NAME
|
||||
```
|
||||
|
||||
to:
|
||||
|
||||
```text
|
||||
wf draft add capability WORKSPACE --revision REVISION --step STEP --capability QUALIFIED_NAME
|
||||
```
|
||||
|
||||
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**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
uv run pytest tests/wf_cli/test_app.py tests/wf_cli/test_remote_target.py -q
|
||||
uv run basedpyright src/wf_cli tests/wf_cli --level error
|
||||
```
|
||||
|
||||
Commit:
|
||||
|
||||
```bash
|
||||
git add src/wf_cli/commands tests/wf_cli/test_app.py tests/wf_cli/test_remote_target.py
|
||||
git commit -m "feat: group draft add commands"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Add Interrupt, Foreach, Join, And End Commands
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/wf_cli/commands/draft_add.py`
|
||||
- Test: `tests/wf_cli/test_app.py`
|
||||
- Test: `tests/wf_cli/test_remote_target.py`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: generic `add_step`, parsing helpers, and concrete draft models.
|
||||
- Produces: four type-specific commands with local/remote parity.
|
||||
|
||||
- [ ] **Step 1: Add a private command dispatcher and failing delegation tests**
|
||||
|
||||
Use one helper so every command has identical transport behavior:
|
||||
|
||||
```python
|
||||
def _submit_step(
|
||||
ctx: typer.Context,
|
||||
*,
|
||||
workspace_id: str,
|
||||
revision: int,
|
||||
step_id: str,
|
||||
step: DraftStep,
|
||||
from_step: str | None,
|
||||
from_outcome: str | None,
|
||||
routes: dict[str, str] | None,
|
||||
) -> None:
|
||||
context = load_cli_context(ctx)
|
||||
emit_json(run_cli_operation(
|
||||
context,
|
||||
context.handlers.add_step(
|
||||
workspace_id=workspace_id,
|
||||
revision=revision,
|
||||
step_id=step_id,
|
||||
step=step,
|
||||
incoming=route_source(from_step, from_outcome),
|
||||
routes=routes,
|
||||
),
|
||||
))
|
||||
```
|
||||
|
||||
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**
|
||||
|
||||
Construct:
|
||||
|
||||
```python
|
||||
DraftInterruptStep(interrupt=DraftInterruptPayload(
|
||||
kind=kind,
|
||||
request_schema=(
|
||||
SchemaRef.model_validate(parse_json_file(request_schema_file, option_name="--request-schema-file"))
|
||||
if request_schema_file else None
|
||||
),
|
||||
resume_schema=(
|
||||
SchemaRef.model_validate(parse_json_file(resume_schema_file, option_name="--resume-schema-file"))
|
||||
if resume_schema_file else None
|
||||
),
|
||||
request=[
|
||||
InputPathBinding(path=source, target=target)
|
||||
for source, target in parse_map_flags(request).items()
|
||||
],
|
||||
resume=[
|
||||
OutputBinding(source=source, target=target)
|
||||
for source, target in parse_output_map_flags(resume).items()
|
||||
],
|
||||
outcomes=outcomes or ["submitted"],
|
||||
))
|
||||
```
|
||||
|
||||
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**
|
||||
|
||||
Construct `DraftForeachPayload` from `--over`, `--as`, `--mode`, and:
|
||||
|
||||
```python
|
||||
item_error = ForeachItemErrorPolicy(action=item_error, collect_to=collect_to)
|
||||
concurrent = (
|
||||
ForeachConcurrentPolicy(
|
||||
max_active=max_active,
|
||||
max_outstanding=max_outstanding,
|
||||
)
|
||||
if max_active is not None or max_outstanding is not None
|
||||
else None
|
||||
)
|
||||
```
|
||||
|
||||
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`**
|
||||
|
||||
`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**
|
||||
|
||||
For each command assert `--help` lists its own fields and does not list unrelated fields. Specifically:
|
||||
|
||||
- interrupt has schema/request/resume/outcome flags, not foreach policy flags;
|
||||
- foreach has concurrency flags, not schema flags;
|
||||
- join has only common routing flags;
|
||||
- end has `--outcome` but no `--route`.
|
||||
|
||||
- [ ] **Step 6: Verify and commit**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
uv run pytest tests/wf_cli/test_app.py tests/wf_cli/test_remote_target.py -q
|
||||
uv run ruff check src/wf_cli/commands/draft_add.py tests/wf_cli
|
||||
uv run basedpyright src/wf_cli/commands/draft_add.py tests/wf_cli --level error
|
||||
```
|
||||
|
||||
Commit:
|
||||
|
||||
```bash
|
||||
git add src/wf_cli/commands/draft_add.py tests/wf_cli
|
||||
git commit -m "feat: add draft control step commands"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 6: Add Decision And Subgraph Commands
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/wf_cli/commands/draft_add.py`
|
||||
- Test: `tests/wf_cli/test_app.py`
|
||||
- Test: `tests/wf_cli/test_remote_target.py`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `_submit_step`, JSON-file parsing, and draft models from prior tasks.
|
||||
- Produces: `when`, `choose`, `match`, and `subgraph` commands.
|
||||
|
||||
- [ ] **Step 1: Write failing `when` tests and implement the command**
|
||||
|
||||
Given `condition.json`:
|
||||
|
||||
```json
|
||||
{"op":"exists","path":"state.report"}
|
||||
```
|
||||
|
||||
Invoke `wf draft add when WORKSPACE --revision 1 --step decide --condition-file condition.json --then publish --otherwise revise`. Parse with `Condition.model_validate(parse_json_file(condition_file, option_name="--condition-file"))`, then construct:
|
||||
|
||||
```python
|
||||
DraftWhenStep(when=DraftWhenPayload(
|
||||
if_=condition,
|
||||
then=then,
|
||||
otherwise=otherwise,
|
||||
))
|
||||
```
|
||||
|
||||
The command must not expose `--route` because targets are embedded.
|
||||
|
||||
- [ ] **Step 2: Write failing `choose` tests and implement the command**
|
||||
|
||||
`--clauses-file` contains a JSON array. Validate with
|
||||
`TypeAdapter(list[DraftChooseClause]).validate_python(value)`, then construct
|
||||
`DraftChooseStep(choose=DraftChoosePayload(clauses=clauses, default=default))`.
|
||||
Tests cover ordered clauses, canonical `if` alias output, an empty array, a
|
||||
non-array document, and no generic routes.
|
||||
|
||||
- [ ] **Step 3: Write failing `match` tests and implement the command**
|
||||
|
||||
`--cases-file` contains a JSON array. Validate with
|
||||
`TypeAdapter(list[DraftMatchCase])`, then construct:
|
||||
|
||||
```python
|
||||
DraftMatchStep(match=DraftMatchPayload(
|
||||
value=value,
|
||||
cases=cases,
|
||||
default=default,
|
||||
))
|
||||
```
|
||||
|
||||
Tests preserve scalar `equals` values (`str`, `int`, `bool`, `None`) and ordered targets.
|
||||
|
||||
- [ ] **Step 4: Write failing subgraph reference tests**
|
||||
|
||||
Cover:
|
||||
|
||||
- `--workflow-name child`;
|
||||
- `--artifact-id child_report --artifact-version 2`;
|
||||
- neither reference form;
|
||||
- both forms;
|
||||
- artifact id without version and version without artifact id.
|
||||
|
||||
All invalid combinations must fail before `add_step` is called.
|
||||
|
||||
- [ ] **Step 5: Implement subgraph construction**
|
||||
|
||||
Build the reference explicitly:
|
||||
|
||||
```python
|
||||
if workflow_name is not None:
|
||||
if artifact_id is not None or artifact_version is not None:
|
||||
raise typer.BadParameter(
|
||||
"--workflow-name cannot be combined with --artifact-id/--artifact-version"
|
||||
)
|
||||
workflow = WorkflowRef(name=workflow_name)
|
||||
else:
|
||||
if artifact_id is None or artifact_version is None:
|
||||
raise typer.BadParameter(
|
||||
"use --workflow-name or both --artifact-id and --artifact-version"
|
||||
)
|
||||
workflow = WorkflowRef(artifact_id=artifact_id, version=artifact_version)
|
||||
```
|
||||
|
||||
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**
|
||||
|
||||
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.
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
uv run pytest tests/wf_cli/test_app.py tests/wf_cli/test_remote_target.py -q
|
||||
uv run ruff check src/wf_cli/commands tests/wf_cli
|
||||
uv run basedpyright src/wf_cli/commands tests/wf_cli --level error
|
||||
```
|
||||
|
||||
- [ ] **Step 7: Commit**
|
||||
|
||||
```bash
|
||||
git add src/wf_cli/commands/draft_add.py tests/wf_cli
|
||||
git commit -m "feat: add draft decision and subgraph commands"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 7: Migrate Live Documentation And Close The Slice
|
||||
|
||||
**Files:**
|
||||
- Modify: `docs/wf_cli.md`
|
||||
- Modify: `docs/wf_api_architecture.md`
|
||||
- Modify: `docs/current_roadmap.md`
|
||||
- Modify: `skills/wf-cli/SKILL.md`
|
||||
- Modify: `skills/wf-workflow/references/draft-workspaces.md`
|
||||
- Modify: `skills/wf-workflow/references/workflow-lifecycle.md`
|
||||
- Modify: `ISSUES.md`
|
||||
- Move after all checks pass: `docs/superpowers/plans/2026-07-20-generic-draft-add-step.md` to `docs/historical/superpowers/plans/2026-07-20-generic-draft-add-step.md`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: all implemented commands and method names.
|
||||
- Produces: accurate live docs and a clean, archived implementation record.
|
||||
|
||||
- [ ] **Step 1: Search live references before editing**
|
||||
|
||||
Run:
|
||||
|
||||
```powershell
|
||||
rg -n -F 'wf draft add-step' docs skills README.md ISSUES.md --glob '!docs/historical/**'
|
||||
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.
|
||||
|
||||
- [ ] **Step 2: Update user-facing CLI and skill documentation**
|
||||
|
||||
Document the command tree and at least these complete examples:
|
||||
|
||||
```bash
|
||||
wf draft add capability report_ws --revision 1 --step render \
|
||||
--capability local.report.render --route ok=__end__
|
||||
|
||||
wf draft add interrupt report_ws --revision 2 --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
|
||||
|
||||
wf draft add when report_ws --revision 3 --step decide \
|
||||
--condition-file has-report.json --then publish --otherwise revise
|
||||
```
|
||||
|
||||
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**
|
||||
|
||||
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**
|
||||
|
||||
Change the three items in `ISSUES.md` to checked entries only if tests prove:
|
||||
|
||||
```markdown
|
||||
- [x] Dedicated draft CLI subcommands cover every draft step kind.
|
||||
- [x] Draft interrupts preserve request and resume schemas.
|
||||
- [x] Draft subgraphs preserve workflow references and boundary contracts.
|
||||
```
|
||||
|
||||
Add any newly discovered out-of-scope defects as unchecked, reproducible statements.
|
||||
|
||||
- [ ] **Step 5: Run focused regression suites**
|
||||
|
||||
```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
|
||||
```
|
||||
|
||||
Expected: all pass.
|
||||
|
||||
- [ ] **Step 6: Run the repository quality gate**
|
||||
|
||||
```bash
|
||||
uv run ruff check
|
||||
uv run ruff format --check
|
||||
uv run basedpyright --level error
|
||||
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.
|
||||
|
||||
- [ ] **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.
|
||||
|
||||
- [ ] **Step 8: Commit documentation and issue closure**
|
||||
|
||||
```bash
|
||||
git add docs skills ISSUES.md
|
||||
git commit -m "docs: complete generic draft step authoring"
|
||||
```
|
||||
@@ -1,53 +1,96 @@
|
||||
# Generic Draft Step Authoring Design
|
||||
|
||||
**Status:** Proposed
|
||||
**Status:** Approved
|
||||
|
||||
**Date:** 2026-07-20
|
||||
|
||||
## Problem
|
||||
|
||||
Draft authoring has a focused helper for capability-backed `node` steps, but
|
||||
the other canonical workflow step kinds have no equivalent application or RPC
|
||||
operation. Callers can patch raw draft JSON, but that bypasses the product's
|
||||
typed authoring vocabulary and forces agents to hand-build JSON Patch paths.
|
||||
Draft workspaces expose a composed helper for capability-backed `use` steps,
|
||||
but no typed application or RPC operation can insert the other draft step
|
||||
variants. Callers must patch raw draft JSON, which bypasses the semantic
|
||||
authoring boundary and forces agents to construct JSON Pointer paths.
|
||||
|
||||
The affected canonical step kinds are:
|
||||
The CLI has the same gap. Its flat `wf draft add-step --capability NAME`
|
||||
command only supports capability steps. Adding a `--type` switch would produce
|
||||
one conditional form whose required options change by step kind.
|
||||
|
||||
- `subgraph`
|
||||
- `condition`
|
||||
- `foreach`
|
||||
- `join`
|
||||
- `end`
|
||||
- `interrupt`
|
||||
Two model gaps also prevent full draft/core parity:
|
||||
|
||||
The CLI also exposes only the flat `wf draft add-step --capability ...`
|
||||
command. Extending that command with a `--type` switch would create one large
|
||||
conditional form whose required and valid options change by step kind.
|
||||
- `DraftInterruptPayload` cannot preserve `request_schema` or `resume_schema`.
|
||||
- `DraftStep` cannot represent a canonical `SubgraphNode` boundary.
|
||||
|
||||
## Goals
|
||||
|
||||
- Add one generic, typed Python application operation for inserting any
|
||||
canonical `Step` into a draft workspace.
|
||||
- Expose the operation through Python JSON-RPC and the Python RPC client.
|
||||
- Replace the flat capability-only CLI command with a discoverable
|
||||
`wf draft add` command group.
|
||||
- Preserve the capability helper's composed schema projection and binding
|
||||
behavior under `wf draft add capability`.
|
||||
- Make each control-step command validate only the options relevant to that
|
||||
step kind.
|
||||
- Keep one optimistic revision increment for the inserted step and any route
|
||||
wiring requested in the same command.
|
||||
- Record implementation discoveries in `ISSUES.md`: strike resolved issues and
|
||||
add concrete bugs or missing product behavior found during the work.
|
||||
- Add one generic typed Python application operation for inserting any
|
||||
`DraftStep` into a workspace.
|
||||
- Preserve step identifiers as keys in `WorkflowDraft.steps` rather than
|
||||
duplicating them inside step payloads.
|
||||
- Add typed interrupt contracts and subgraph boundaries to the draft model and
|
||||
adapter.
|
||||
- Expose generic insertion through Python JSON-RPC and the Python RPC client.
|
||||
- Replace the flat capability command with a discoverable `wf draft add`
|
||||
subgroup covering every draft authoring variant.
|
||||
- Preserve capability schema projection, binding, and route behavior under
|
||||
`wf draft add capability`.
|
||||
- Apply the inserted step and requested route wiring in one optimistic
|
||||
revision.
|
||||
- Keep `ISSUES.md` accurate as parity gaps are resolved or discovered.
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- TypeScript or Effect RPC parity.
|
||||
- RPC code generation.
|
||||
- Web workflow authoring UI.
|
||||
- New workflow step kinds or runtime semantics.
|
||||
- Compatibility aliases for unused command shapes.
|
||||
- Replacing the existing capability-composition helper with a raw node insert.
|
||||
- New runtime semantics.
|
||||
- Resolving or loading saved subgraph artifacts while parsing a draft.
|
||||
- Compatibility aliases for the unused `wf draft add-step` shape.
|
||||
- Replacing capability composition with raw `DraftUseStep` insertion.
|
||||
|
||||
## Canonical Draft Model
|
||||
|
||||
The operation consumes `DraftStep`, not the core `Step` union. Drafts have a
|
||||
deliberate authoring vocabulary that is later lowered by
|
||||
`build_workflow_from_draft`:
|
||||
|
||||
- `DraftUseStep`
|
||||
- `DraftForeachStep`
|
||||
- `DraftInterruptStep`
|
||||
- `DraftJoinStep`
|
||||
- `DraftEndStep`
|
||||
- `DraftWhenStep`
|
||||
- `DraftChooseStep`
|
||||
- `DraftMatchStep`
|
||||
- `DraftSubgraphStep`
|
||||
|
||||
`DraftSubgraphStep` mirrors the declarative boundary fields of
|
||||
`SubgraphNode`, excluding core-owned `id` and `type`:
|
||||
|
||||
```python
|
||||
class DraftSubgraphPayload(BaseModel):
|
||||
workflow: WorkflowRef
|
||||
desc: str | None = None
|
||||
input_schema: SchemaRef = Field(default_factory=lambda: SchemaRef(type="object"))
|
||||
output_schema: SchemaRef = Field(default_factory=lambda: SchemaRef(type="object"))
|
||||
input: list[InputBinding] = Field(default_factory=list)
|
||||
output: list[OutputBinding] = Field(default_factory=list)
|
||||
outcomes: list[str] = Field(default_factory=lambda: ["ok"], min_length=1)
|
||||
|
||||
|
||||
class DraftSubgraphStep(BaseModel):
|
||||
subgraph: DraftSubgraphPayload
|
||||
```
|
||||
|
||||
The draft adapter constructs `SubgraphNode` directly from this payload. It
|
||||
does not load the referenced child artifact; artifact resolution remains a
|
||||
platform concern.
|
||||
|
||||
`DraftInterruptPayload` gains nullable `request_schema` and `resume_schema`
|
||||
fields. Supplied schemas must be valid JSON object schemas. `None` preserves
|
||||
the distinction between legacy untyped interrupts and explicit contracts; the
|
||||
adapter passes only authored schema fields to `WorkflowBuilder.interrupt` so
|
||||
typed contracts survive draft parsing, validation, artifact creation, and
|
||||
execution without falsely marking every interrupt typed.
|
||||
|
||||
## Application API
|
||||
|
||||
@@ -58,35 +101,65 @@ async def add_step(
|
||||
*,
|
||||
workspace_id: str,
|
||||
revision: int,
|
||||
step: Step,
|
||||
route_from_step: str | None = None,
|
||||
route_from_outcome: str = "ok",
|
||||
step_id: str,
|
||||
step: DraftStep,
|
||||
incoming: RouteSource | None = None,
|
||||
routes: dict[str, str] | None = None,
|
||||
) -> dict[str, Any]: ...
|
||||
) -> dict[str, Any]:
|
||||
"""Insert one typed draft step and optional route wiring atomically."""
|
||||
```
|
||||
|
||||
`step.id` is the canonical identifier. The operation does not accept a second
|
||||
`step_id` that could disagree with the model.
|
||||
`step_id` is separate because draft step identifiers are map keys. The
|
||||
`DraftStep` payload contains no second identifier that can disagree.
|
||||
|
||||
`RouteSource` keeps an incoming edge internally consistent:
|
||||
|
||||
```python
|
||||
@dataclass(frozen=True)
|
||||
class RouteSource:
|
||||
step_id: str
|
||||
outcome: str = "ok"
|
||||
```
|
||||
|
||||
For example, `RouteSource(step_id="draft_issues", outcome="ok")` wires
|
||||
`draft_issues --ok--> <new step>`. RPC supplies the same two-field shape. CLI
|
||||
commands project `--from-step` and `--from-outcome` into it only after rejecting
|
||||
`--from-outcome` without `--from-step`.
|
||||
|
||||
The operation:
|
||||
|
||||
1. parses and validates the discriminated `Step` union before mutation;
|
||||
2. rejects an existing step id;
|
||||
3. inserts the canonical serialized step into the draft's `steps` object;
|
||||
4. optionally routes one existing step outcome into the new step;
|
||||
5. optionally records outgoing routes supplied for the new step; and
|
||||
6. applies the complete change through one revision-checked draft patch.
|
||||
1. receives an already parsed `DraftStep`;
|
||||
2. rejects an existing `step_id`;
|
||||
3. checks that `incoming.step_id` exists when supplied;
|
||||
4. validates supplied top-level route outcomes against the inserted step kind;
|
||||
5. inserts the canonical `DraftStep.model_dump(mode="json", by_alias=True)`;
|
||||
6. optionally wires `incoming` to `step_id`;
|
||||
7. optionally stores outgoing top-level routes; and
|
||||
8. applies the entire patch through one revision check.
|
||||
|
||||
Draft workspaces intentionally support invalid intermediate states. Therefore,
|
||||
`add_step` does not require every declared outcome to be routed immediately.
|
||||
When routes are supplied, it rejects outcomes that the inserted step cannot
|
||||
emit. End steps reject outgoing routes. Full graph completeness remains the
|
||||
responsibility of draft validation.
|
||||
Draft workspaces intentionally permit invalid intermediate graphs. Generic
|
||||
insertion therefore allows omitted or incomplete outgoing routes. When routes
|
||||
are supplied, their keys must be a subset of the step's declared outcomes.
|
||||
`DraftEndStep` and decision steps reject top-level routes because end has no
|
||||
outgoing edge and `when`/`choose`/`match` embed targets in their own payloads.
|
||||
|
||||
The existing capability-composition operation remains a distinct application
|
||||
helper because it resolves a capability, projects schemas, constructs a
|
||||
`NodeUse`, and creates bindings. It may reuse the generic insertion mechanics
|
||||
internally, but its public behavior must not regress.
|
||||
Declared top-level outcomes are:
|
||||
|
||||
- `use`: capability-declared outcomes when resolvable, otherwise `ok`;
|
||||
- `foreach`: `loop`, `done`, plus `completed_with_errors` when the item-error
|
||||
policy is `skip` or `collect`;
|
||||
- `interrupt`: `interrupt.outcomes`;
|
||||
- `join`: `done`;
|
||||
- `subgraph`: `subgraph.outcomes`.
|
||||
|
||||
The capability helper remains distinct because it resolves a capability,
|
||||
projects schemas, creates bindings, and currently requires complete routes for
|
||||
multi-outcome capabilities. It may share private insertion mechanics, but its
|
||||
public behavior must not regress.
|
||||
|
||||
Rename the internal `DraftOutcomeRef` value object to `RouteSource` and reuse
|
||||
it for both generic incoming wiring and existing handle operations. This is a
|
||||
clean internal migration; no compatibility alias is required.
|
||||
|
||||
## JSON-RPC And Python Client
|
||||
|
||||
@@ -96,120 +169,140 @@ Add:
|
||||
workflow.draft_workspaces.add_step
|
||||
```
|
||||
|
||||
Its parameter model mirrors the application operation. The `step` field uses
|
||||
the canonical discriminated `Step` union rather than an unvalidated
|
||||
`dict[str, Any]`. RPC errors continue through the existing
|
||||
`WorkflowRpcError` translation boundary.
|
||||
Its parameter model contains `workspace_id`, `revision`, `step_id`, a typed
|
||||
`DraftStep`, optional `RouteSourceParams`, and optional routes. Pydantic must
|
||||
reject malformed or ambiguous step objects before dispatching to the API.
|
||||
|
||||
The Python RPC client implements the same method on the workflow API surface.
|
||||
Round-trip tests cover every step variant so the transport cannot silently
|
||||
drop aliases, schemas, policies, bindings, outcomes, or workflow references.
|
||||
The Python RPC client implements the same method on `WorkflowApi`. Client and
|
||||
server serialize steps with aliases so fields such as foreach `as` and when
|
||||
`if` retain their canonical wire names. Round-trip tests cover all nine step
|
||||
variants, including interrupt schemas and subgraph workflow references.
|
||||
|
||||
## CLI Shape
|
||||
|
||||
Create a Typer subgroup beneath `wf draft`:
|
||||
Register a focused Typer application beneath `wf draft`:
|
||||
|
||||
```text
|
||||
wf draft add capability
|
||||
wf draft add interrupt
|
||||
wf draft add condition
|
||||
wf draft add foreach
|
||||
wf draft add join
|
||||
wf draft add end
|
||||
wf draft add when
|
||||
wf draft add choose
|
||||
wf draft add match
|
||||
wf draft add subgraph
|
||||
```
|
||||
|
||||
The existing `wf draft add-step` command is removed rather than retained as a
|
||||
ghost alias. Repository-owned docs, tests, skills, examples, and scripts are
|
||||
migrated to `wf draft add capability`.
|
||||
The old `wf draft add-step` command is removed. Live docs, tests, skills,
|
||||
examples, and scripts migrate to `wf draft add capability`.
|
||||
|
||||
Every command shares these routing options where meaningful:
|
||||
All commands accept the workspace id, `--revision`, `--step`, and optional
|
||||
`--from-step`/`--from-outcome`. Commands whose steps use top-level routes also
|
||||
accept repeatable `--route OUTCOME=TARGET`.
|
||||
|
||||
- workspace id argument;
|
||||
- `--revision`;
|
||||
- `--step`;
|
||||
- optional `--from-step` and `--from-outcome`; and
|
||||
- repeatable `--route OUTCOME=TARGET` for step kinds with outgoing outcomes.
|
||||
Variant-specific options are:
|
||||
|
||||
The commands then expose only their own model fields:
|
||||
- `capability`: `--capability`, existing `--input`, and existing
|
||||
`--bind-output` flags;
|
||||
- `interrupt`: `--kind`, optional request/resume schema JSON files,
|
||||
repeatable `--request SOURCE=LOCAL_TARGET`, repeatable
|
||||
`--resume LOCAL_SOURCE=STATE_TARGET`, and repeatable `--outcome`;
|
||||
- `foreach`: `--over`, `--as`, `--mode`, `--item-error`, optional
|
||||
`--collect-to`, `--max-active`, and `--max-outstanding`;
|
||||
- `join`: no variant-specific options;
|
||||
- `end`: `--outcome` and no `--route`;
|
||||
- `when`: `--condition-file`, `--then`, and `--otherwise`;
|
||||
- `choose`: `--clauses-file` containing the ordered clause array and
|
||||
`--default`;
|
||||
- `match`: `--value`, `--cases-file` containing the ordered case array, and
|
||||
`--default`;
|
||||
- `subgraph`: exactly one of `--workflow-name` or
|
||||
`--artifact-id` plus `--artifact-version`, optional input/output schema JSON
|
||||
files, repeatable `--input`, repeatable `--bind-output`, repeatable
|
||||
`--outcome`, and optional `--description`.
|
||||
|
||||
- `capability`: capability name plus existing input and output binding flags;
|
||||
- `interrupt`: kind, request/resume schema files, request/resume bindings, and
|
||||
repeatable outcomes;
|
||||
- `condition`: a JSON condition document;
|
||||
- `foreach`: source path, item context name, serial/concurrent mode, item-error
|
||||
policy, and concurrent limits;
|
||||
- `join`: no additional step fields;
|
||||
- `end`: workflow outcome and no outgoing routes; and
|
||||
- `subgraph`: workflow reference, boundary schema files, bindings, and
|
||||
repeatable outcomes.
|
||||
Structured conditions, clauses, cases, and schemas use JSON files rather than
|
||||
dense inline JSON. Binding flags retain the existing path conventions. CLI
|
||||
help gives one valid example per command and tells users to run
|
||||
`wf draft validate` after editing.
|
||||
|
||||
Compound model values use JSON files rather than dense inline JSON. Existing
|
||||
map-style flags are reused for simple path bindings when their direction is
|
||||
unambiguous. CLI help includes one valid example per command and directs users
|
||||
to `wf draft validate` after editing.
|
||||
The subgroup belongs in a focused `wf_cli.commands.draft_add` module. Shared
|
||||
route/binding/JSON-file parsing helpers should move only when both command
|
||||
modules need them; avoid a broad CLI refactor.
|
||||
|
||||
## Validation And Errors
|
||||
|
||||
- Pydantic owns step-shape validation; CLI and RPC do not duplicate the core
|
||||
model rules.
|
||||
- CLI parsing errors identify the invalid flag or file before making an API
|
||||
call.
|
||||
- Application errors identify duplicate ids, missing incoming source steps,
|
||||
unsupported route outcomes, and forbidden end-step routes.
|
||||
- Revision conflicts preserve the existing draft-workspace behavior.
|
||||
- No command guesses missing routes or silently invents bindings.
|
||||
- Pydantic owns draft-step shape validation.
|
||||
- CLI validates flag relationships and JSON file contents before API dispatch.
|
||||
- Generic application errors identify duplicate ids, missing incoming source
|
||||
steps, unsupported route outcomes, and forbidden top-level routes.
|
||||
- Revision conflicts preserve existing workspace behavior.
|
||||
- Failed requests do not mutate the draft or increment its revision.
|
||||
- No command guesses missing routes, targets, contracts, or bindings.
|
||||
|
||||
## Tests
|
||||
|
||||
### Draft Model And Adapter
|
||||
|
||||
- Typed interrupt schemas parse, dump, and lower to `InterruptNode`.
|
||||
- Subgraph payloads parse, dump, and lower to `SubgraphNode` without loading an
|
||||
artifact.
|
||||
- Unknown or mixed step-kind keys remain rejected.
|
||||
|
||||
### Application
|
||||
|
||||
- Parameterized insertion for every `Step` variant.
|
||||
- Atomic incoming and outgoing route wiring.
|
||||
- Duplicate id rejection without mutation.
|
||||
- Unknown outcome and end-route rejection without mutation.
|
||||
- Parameterized insertion covers every `DraftStep` variant.
|
||||
- Incoming and outgoing route wiring is atomic.
|
||||
- Duplicate ids, missing incoming sources, unknown outcomes, and forbidden
|
||||
routes fail without mutation.
|
||||
- Invalid intermediate drafts remain persistable and validate diagnostically.
|
||||
- Capability insertion preserves existing schema projection and complete-route
|
||||
behavior.
|
||||
|
||||
### RPC And Client
|
||||
|
||||
- Parameter model rejects malformed discriminators and variant fields.
|
||||
- App round trip for every step kind.
|
||||
- Client method emits the exact method name and canonical payload.
|
||||
- Parameter parsing rejects malformed step discriminators and fields.
|
||||
- App round trips cover every step kind.
|
||||
- Client payloads use the exact method name and canonical aliases.
|
||||
- RPC failures occur before draft mutation.
|
||||
|
||||
### CLI
|
||||
|
||||
- The `wf draft add` help lists all seven commands.
|
||||
- `wf draft add --help` lists all nine commands.
|
||||
- Per-command help exposes only relevant options.
|
||||
- Each command constructs the expected canonical step and route payload.
|
||||
- `add capability` preserves existing composed authoring behavior.
|
||||
- Removed `add-step` references are absent from live docs and tests.
|
||||
- Every command builds the expected `DraftStep`, incoming source, and routes.
|
||||
- Invalid flag combinations fail before calling the API.
|
||||
- Local and `--target` execution use the same handler method.
|
||||
- `add capability` preserves existing composed behavior.
|
||||
- The removed `add-step` command and live references are absent.
|
||||
|
||||
## Documentation And Issue Tracking
|
||||
|
||||
- Update CLI docs, agent skills, examples, and roadmap references to the new
|
||||
command shape.
|
||||
- Mark the dedicated-step-authoring issue in `ISSUES.md` resolved when all six
|
||||
non-capability commands are covered.
|
||||
- Add newly discovered defects to `ISSUES.md` only when they are concrete,
|
||||
reproducible, and outside this slice. Fix in-scope defects instead of merely
|
||||
documenting them.
|
||||
- Update `docs/wf_cli.md`, `docs/wf_api_architecture.md`, current roadmap
|
||||
wording, `skills/wf-cli`, and `skills/wf-workflow` references.
|
||||
- Update other live references discovered by a fixed-string search; do not
|
||||
rewrite historical plans or thesis prose solely to rename an old command.
|
||||
- Mark all three draft-authoring parity issues resolved when implementation and
|
||||
focused verification pass.
|
||||
- Add newly discovered defects to `ISSUES.md` only when concrete,
|
||||
reproducible, and outside this slice. Fix in-scope defects directly.
|
||||
|
||||
## Deferred Work
|
||||
|
||||
A later parity slice may expose the full Python JSON-RPC suite through the
|
||||
TypeScript Effect RPC package. That work should first add a machine-checked
|
||||
method parity manifest. Whether schemas are generated should be decided from
|
||||
the canonical Python registry and schema-export capabilities, not by generating
|
||||
from duplicate handwritten TypeScript definitions.
|
||||
A later parity slice may expose the Python JSON-RPC suite through the
|
||||
TypeScript Effect RPC package. That slice should start with a machine-checked
|
||||
method parity manifest. Code generation should be evaluated from the canonical
|
||||
Python registry and schema export rather than duplicate handwritten
|
||||
TypeScript definitions.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- Every canonical workflow step kind can be added through the application API,
|
||||
Python JSON-RPC, Python RPC client, and a type-specific CLI command.
|
||||
- Every draft step variant can be added through the application API, Python
|
||||
JSON-RPC, Python client, and a dedicated CLI command.
|
||||
- Typed interrupt schemas and subgraph contracts survive draft adaptation.
|
||||
- One generic `add_step` operation owns raw typed insertion.
|
||||
- Capability-backed insertion retains schema projection and binding behavior.
|
||||
- CLI vocabulary is grouped under `wf draft add` with no unneeded compatibility
|
||||
alias.
|
||||
- Invalid requests fail before mutation and revision semantics remain atomic.
|
||||
- Focused tests, type checking, formatting, and documentation checks pass.
|
||||
- Capability insertion retains its composed projection and binding behavior.
|
||||
- CLI vocabulary is grouped under `wf draft add` without a ghost alias.
|
||||
- Invalid requests fail atomically and preserve revision semantics.
|
||||
- Focused tests, Ruff, basedpyright, and documentation checks pass.
|
||||
|
||||
Reference in New Issue
Block a user