docs: plan generic draft step authoring
This commit is contained in:
@@ -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