Automation drafts design

Saving incomplete setup without confusing runnable automations

PR 417 adds server-backed automation drafts so setup UIs can save partial work, validate it incrementally, and test-run complete drafts without letting those drafts participate in normal scheduling or event dispatch.

1

Setup is multi-step

Users may provide a name, prompt, trigger, model, repository list, plugin config, or tarball details over time. The service needs a place to persist incomplete work.

2

Runs require automations

automation_runs.automation_id is non-null, and the dispatcher reads executable fields from Automation. A draft cannot dispatch directly unless it first has a materialized automation row.

3

Disabled is not draft

A user-disabled automation and a materialized draft are both not scheduled. But they mean different things, so a boolean enabled flag is not expressive enough on its own.

Problem

What are we trying to solve?

The setup experience needs server-backed drafts that can be saved before they are valid automation creation requests. At the same time, the backend must preserve its existing invariants: schedulers and webhook dispatch should only see real active automations, while manual draft test runs still need a valid automation row to attach run history, telemetry, tarballs, sandbox state, and dispatcher execution config.

Key constraint: test-running a draft is not just validating JSON. It creates an AutomationRun, and that run must point at an automations.id.

Solution

The implementation we settled on

Save draft

Store partial setup state in automation_drafts.draft_body.

Shape-check

Validate the JSON against endpoint-specific partial Pydantic schemas.

Validate final form

Use the real creation schemas to decide whether the draft is dispatchable.

Materialize

Create or update a linked Automation row for test dispatch.

Dispatch manually

Create a manual run tied to the materialized draft automation.

The materialized row is marked lifecycle_status = DRAFT and enabled = false. That keeps it out of automatic scheduling and event matching while making it clear that the row exists to support draft testing, not because the user disabled a real automation.

Alternatives

Options explored and tradeoffs

Approach Upside Tradeoffs Decision
Store drafts directly in automations One table; draft test runs naturally point at an automation row. Requires many executable fields to become nullable or overloaded. Schedulers, dispatchers, serializers, and API responses must constantly handle incomplete automation rows. Rejected
Use only enabled = false No new lifecycle enum or state column. Cannot distinguish a user-disabled production automation from a materialized draft test automation. That ambiguity leaks into UI, dispatch, git sync, cleanup, analytics, and future state transitions. Rejected
Keep drafts only as untyped JSON Very flexible and easy for clients to save anything. Allows endpoint drift and arbitrary payloads. Errors surface late, and clients lose reliable field-level feedback for setup forms. Rejected
Dispatch drafts directly from JSON Avoids materializing a draft automation row. Conflicts with the current run model and dispatcher contract: runs require an automation_id, and execution config is loaded from Automation. Changing that path would be wider and riskier. Rejected
Separate drafts plus lifecycle materialization Keeps incomplete form state separate, preserves dispatcher invariants, supports manual test runs, and keeps disabled vs draft semantics explicit. Adds one lifecycle column and a materialization step when testing a draft. Chosen

Why this is the right compromise

Small schema change, clear semantics, fewer dispatcher changes

This implementation isolates incomplete setup state where it belongs while using a normal automation row only at the point where the existing system requires one: run creation and dispatch. The lifecycle column is not trying to make drafts live in the automations table. It describes the status of the materialized executable row and prevents us from pretending every non-enabled row has the same meaning.

  • automation_drafts answers “what is the user editing?”
  • automations.lifecycle_status answers “what kind of automation row is this?”
  • automations.enabled answers “can this row be triggered automatically?”