Skip to content

feat(platform): schedule automations with the repeat picker - #4619

Open
yannickmonney wants to merge 30 commits into
mainfrom
feat/automation-schedule-triggers
Open

yannickmonney wants to merge 30 commits into
mainfrom
feat/automation-schedule-triggers

Conversation

@yannickmonney

@yannickmonney yannickmonney commented Oct 9, 2026 •

Copy link
Copy Markdown
Contributor

What changes

Schedules move from a bare cron field to the repeat picker tasks use, the scan claims each schedule at its next due time instead of walking every schedule every minute, and every trigger kind gains a fixed input and readable skip reasons. One trigger contract (@tale/shared/schemas/automation-trigger) now serves the editor, REST, MCP and managed configuration.

Schedules

  • The trigger editor picks schedules with RecurrencePicker granularity="time" (presets, custom times, custom interval with an "only between" window, overnight windows included) and shows the next runs in the schedule's zone. Cron stays available as Advanced. A legacy cron opens in the picker only when the conversion is lossless, and is sent back as cron unless the schedule changed.
  • Storage (migrations 0170–0173): schedule_rule, catch_up, next_due_at_ms with partial indexes, last_skip_detail, run_input. A next-due reset trigger keeps a previous image's writes safe mid-roll, and blank zones are trimmed.
  • The scan claims due rows through the index, one short transaction per row with SKIP LOCKED, and is idempotent under overlapping scans and replicas.
  • RFC 5545-compatible DST. Missed occurrences are counted, never all run: catchUp: "latest" (the default) starts the most recent once; "skip" starts it only within 10 minutes.

Every trigger

  • Fixed input: a JSON object of at most 16 KiB that every run receives beside the trigger's own fields. Saves warn (TRIGGER_INPUT_MISMATCH, TRIGGER_INPUT_NOT_TEMPLATED) when the deployed version would refuse what the trigger sends, and "This run receives" previews it.
  • Skip reasons: the editor shows why a trigger started nothing, with the refusal behind "Technical details". missed_occurrences joins the skip ledger.
  • Deploy notice: a deploy answers the bound trigger, and the editor offers to turn on one that is off, or to review one whose runs would be refused.
  • Webhook panel: one URL per installed project, the token masked after its first showing with Rotate, a curl test request, and the recent deliveries.
  • Events: scoped to projects. An event an automation's run raised never starts that same automation, and a run an event started does not start other automations (bounded loops).
  • Seeded GitHub schedules: switched off where they never started a run (0173), and packs now seed their triggers off.

Contract 3.28.0

  • The PUT …/triggers body and Trigger come from the shared schemas; refusals are coded under data.issues.
  • MCP set_trigger takes the same trigger, deploy_automation answers the bound trigger beside previousVersion, and the triggers reference states repeat rules, catch-up and the fixed input.
  • MCP tool schemas now inline their definitions, so no tool advertises a $ref.
  • The shared REST/MCP error map names a tagged union's tags (must be one of "schedule", "webhook", "event").

Integration with main's MCP authoring (#4592)

This branch merged main after #4592 landed:

  • The shared trigger schema replaces PR-A's interim trigger-args schema, which was built for this swap.
  • The store keeps PR-A's audit rows and lock order, and its trigger audit state also records repeat, catchUp and that a fixed input is set (not its values).
  • Spec IDs follow main's: AUTO-R29 (DST), AUTO-R30 (event scope), AUTO-R31 (input warnings), AUTO-R32 (missed occurrences).

Main's slot wakes (#4608), merged in.

  • wakeOnSlotFreed stays outside the shared trigger contract; only managed configuration sends it.
  • setTrigger and assertTriggerValid read it, then hold the rest to the contract.
  • On a webhook or event trigger it is refused in the contract's own wording.
  • The managed schedule value carries it on cron and repeat-rule schedules alike.
  • A woken occurrence starts with the schedule's fixed input, through the same triggerRunInput builder the scan uses.
  • This branch's spec IDs moved past main's wake rules: AUTO-R34–R37.
  • checkStandingRoleWake + checkStandingRoleWakeScenarios: 107/107, twice, on real Postgres. These lanes need a pool of at least 4: W20 holds a barrier on a reserved connection.

Verification

  • Typecheck clean.
  • Platform server + pii: 85,461 pass. Platform UI (automations, settings): 2,467 pass. @tale/shared: 828 pass.
  • lint:manual, lint:links and knip clean.
  • Real Postgres with MinIO, after merging main: 223/224 checks across 19 lanes, PR-A's MCP lanes included (checkMcp, checkMcpAuthoringParity, checkMcpDiscovery, checkMcpResourcesPrompts, checkMcpEras), plus trigger delivery, pause after failures, lock order, managed config, provisioning, seeded schedules off, and event scope and isolation. The one red is main's LLM-budget wording check: on this machine the refusal is worded by another path (Request limit reached for this monthly period…) than in CI. This branch touches no budget code.
  • Earlier slices: S3 157/157 on real Postgres, plus DST oracle tests.

Notes

  • Not done: the design-detail polish slice (S6: the full light/dark × locale × viewport browser matrix and VoiceOver pass), and the three adversarial reviews. The weekly agent limit stopped them, so they will follow in a polish PR.
  • Screenshot manifest entries (schedule picker, webhook deliveries, skip notice) are declared; images are captured in the next docs-shot run.
  • Release notes: a blank timezone and cron together with repeat are now refused. A monthly 09:00 missed during an outage now runs once, late (catchUp: latest), where it used to be dropped.

Each schedule now keeps the instant it is next due (0170), and the scan
claims only the schedules whose instant has come, by a partial index, plus
the ones whose instant is not computed yet. Each is decided in its own
short transaction on its freshly locked row (FOR UPDATE SKIP LOCKED): the
latest occurrence starts once, however late ("latest", the default) or only
within ten minutes ("skip"), and the rest are counted as
missed_occurrences instead of being dropped after an hour. The run, the
stamps, the claim cursor and the next instant commit together; a stopping
process ends the scan between schedules.

- 0170: schedule_rule, catch_up, next_due_at_ms, two partial indexes, a
  BEFORE UPDATE trigger that drops a stale instant whenever any writer (the
  previous image, the pause, an orphan retire) changes what a schedule is,
  and the trim of blank or padded zones.
- 0171: last_skip_detail (code, version, problems, missed counts) and the
  missed_occurrences reason.
- The store parses every trigger with the shared schema
  (@tale/shared/schemas/automation-trigger) and refuses a blank zone, a
  rule without one, and cron plus repeat, with coded issues; it stores the
  zone canonical and computes the next instant at the bind.
- AUTO-R27 (daylight saving) and AUTO-R28 (missed occurrences); AUTO-R10
  and AUTO-R13 amended. dueOccurrence and its one-hour lookback are gone.
- Integration probes for the old-image insert and edit, the catch-up, the
  Zurich spring-forward, the zone trim and the index plan.

A parallel legacy walk is not needed: cron rows run on the same index
path, and the compatibility trigger makes the previous image's writes
safe. Release note: a blank time zone and cron together with a repeat
rule are now refused.
A trigger may now carry a fixed input (0172): values every run it starts
receives, under the trigger's own fields, so the GitHub packs can be told
their owner and repo. The three doors build the run input with one
builder, triggerRunInput, now the engine's (lib/engine/core/slots.ts);
with no fixed input it is byte-identical to the literals it replaces.

Saving a trigger and deploying a version check what the trigger sends
against the inputs of the version that runs (AUTO-R29): a refusal comes
back as TRIGGER_INPUT_MISMATCH, a template in the fixed input as the new
TRIGGER_INPUT_NOT_TEMPLATED, and the trigger is saved either way. The
deploy answer names the bound trigger ({kind, enabled, nextRunAt,
warnings}) for the editor's "turn on the trigger" notice.

- The analyzer seam is now StoreAdapter.triggerInput (it replaces P1's
  triggerKinds): the host's sample covers webhooks (their body set
  aside) and events, not schedules only, through the same builder the
  save and deploy use (triggerInputWarnings).
- AUTOMATION_INPUT_INVALID carries the refusing version in its data.
- A skip detail is fitted to its 8 KiB column (problems dropped first) so
  a long multi-byte refusal can no longer fail the scan's write.
- The issue catalog (en/de/fr) words the new code and names each kind in
  the mismatch.
The trigger's write and read shapes now come from one zod source,
@tale/shared/schemas/automation-trigger: the app route, the REST PUT,
MCP set_trigger (its JSON Schema rendered from the same schema), the
pack manifest and the OpenAPI document all take the same body, and a
rule the trigger breaks answers AUTOMATION_TRIGGER_INVALID with each
problem coded under data.issues instead of an INVALID_BODY beside it.

- Reads (REST Trigger, MCP list_triggers, the app contract) carry
  repeat, startDate, catchUp, input, nextRunAt and lastSkipDetail;
  lastSkipReason gains missed_occurrences; the listing's trigger adds
  nextRunAt. The engine's open TriggerView is held to the shared read
  shape by a type test.
- The PUT 200 adds nextRunAt and warnings. get_docs and the tool
  description tell callers to send startDate and input back: the PUT
  is a full replace.
- Managed schedules take a cron or a repeat rule (with catchUp skip and
  a fixed input only when set), so a cron schedule hashes as before.
- REST contract 3.21.0 (ScheduleRule, TriggerSkipDetail and
  TriggerWarning components). Newly refused, as fixes: a blank timezone
  and cron together with repeat.
- The repo ledger's "a webhook bind does not say whether the deployed
  inputs schema admits a delivery" is paid down by the bind's warnings.
scheduleTriggerInput has had no importer since triggerRunInput became the
one builder of a trigger's run input; knip reads the export as unused.
The trigger panel's recent deliveries need the runs a trigger started
and how each delivery was recognised. GET
/api/app/automations/{name}/trigger/runs answers them newest first (ten
by default, at most 50), leaving out runs of projects the member cannot
read, each with the webhook door's lane while the delivery's ledger row
lives: the delivery-id header it named, or the body. A schedule's or an
event's runs, and a delivery whose identity has expired, carry none.

The read walks the automation's own runs by automation_runs_org_name and
each run's ledger row by the expiry index, bounded by the run's start;
the integration lane reads real header and body deliveries back and
EXPLAINs the store's own query (no sequential scan on automation_runs),
so no new index is added. App-only: REST and MCP do not carry it.
The managed-configuration probe now expects nextRunAt to move with the cron it changes; the due-walk probe rules out a sort so only the partial index can give the walk its order; the zone-trim probe scans once at the trim and once a minute later, since a trimmed schedule resumes from now. 157/157 checks across the nine trigger, automation, REST and MCP lanes on fresh Postgres.
The run dialog's JSON parse and its inputs-schema check move into
use-json-input-draft, so the trigger's fixed input reads and refuses text
the same way. The hook also lists the required top-level fields an input
lacks with the type each is declared as, and the placeholder a missing
field starts from. The run dialog's behaviour and tests are unchanged.
The General tab's trigger section and the Blank wizard's second step now
edit one TriggerForm. A schedule is a repeat rule picked in the design
system's picker (times of day, intervals, windows) or, under Cron
(advanced), a cron expression; the time zone sits outside the popover and
defaults to the reader's own; Missed runs sets the catch-up policy; and
Next runs lists the starts the scan's own evaluator will fire, with the
header and line for each state (unsaved, would run while off or
undeployed, nothing for an unreadable cron or zone).

A stored cron a repeat rule says exactly opens in Repeat with a note and
is sent back as the same cron until the schedule itself changes; a
rule's start date goes back until its frequency or interval changes, and
a rule converted from a stored cron starts on January 1, so a time-only
edit of "0 9 5 */3 *" keeps its months. A draft the store would refuse
holds Save, and a refused save says each coded problem in its field's
words. A failed read shows "Couldn't load the trigger" with Try again
instead of an empty form.

The cron preview and its hook are gone, and with them the app's
cron-parser dependency. The legacy cron matcher, read only by the
evaluator's oracle tests since the scan moved to the evaluator, moves to
tests/utils. Keys en/de/fr with de-CH overrides; the manual boxes that
cited the removed keys and the General-tab screenshot's ready check
follow the new section.
The General tab's trigger section now says how the trigger is doing: its
last run with that run's state and a link, or that it has not started one;
and, when its last word is a skip, why — one notice per reason (no version
deployed, the named version refusing the input, a project that refused the
start, any other refusal, an unreadable schedule, runs missed while Tale
was down) with the earlier missed runs, the fix and a way there, and the
raw facts under a closed Technical details. The failure streak moves in
with it unchanged.

A trigger can carry a fixed input: JSON every run gets under the
trigger's own fields, folded until it has a value or the deployed inputs
need a field the trigger does not send. Add the missing fields writes a
typed placeholder for each and puts the caret in the first; the skip
notice of a refused input offers the same.

"This run receives" shows the input the trigger starts a run with, from
the sample the save's own check reads, and whether the deployed version
takes it (the last save's warning wins while the form is what it saved).
Run now starts the live version once with the stored trigger's input and
no project named, as the trigger would; a schedule asks first, a webhook
or an event opens the run dialog, which now edits a handed sample even
without an inputs schema. It waits for a deployed version and saved edits,
and says under the button what happened.

The skip sentences use the long date with a preposition ("It came due on
…", "Er war am … fällig", "Il était prévu le …"), since the smart date's
bare time read as "It came due 9:00 AM". Keys en/de/fr with the de-CH
quote override; trigger.lastFired is gone.
A webhook trigger now lists one URL per project it is installed in (the
route's project first), never a literal <projectId>; the organization's
URL only when it is installed in none. A minted token shows each URL once,
copyable; afterwards the token is masked and Rotate token sits beside it.
A curl test request (Idempotency-Key, {"example": true}) and the recent
deliveries the webhook started, with how each was recognised and its run,
follow. The blank wizard's webhook URL names the project it was created in.

An event trigger picks its event from a grouped, searchable list: each
event by name, with its id and one sentence of when it is raised, repeated
under the field once picked.
useFocusHandoff watched its element from a layout effect keyed on the
node, so it handed focus on only when its host component unmounted with
the element. When the element alone left (an error row a retry's answer
replaced inside a host that stays, an action giving way to its status),
the cleanup ran after React had removed it, the focus was already on the
body, and nothing was handed on: the webhook deliveries' Try again lost
its reader's place that way.

The hook now returns a callback ref whose cleanup runs as React detaches
the element, before removing it, so both cases hand focus on. A ref
without state also serves several elements at once.
A pack's trigger is created off, so a deploy could leave a version live
that nothing starts. The deploy answer names the trigger; when it is off,
the editor (in its alert band) and the upload dialog (which now stays open
instead of closing) say so. Turn on the trigger saves the stored trigger
exactly as it is, switched on, and the notice then says it is on; a
refusal is said beside the action, once. When the version would refuse
what the trigger sends, or a webhook has no URL yet, the action is Review
the trigger instead: a link to the General tab's trigger section.

Deploy and Deploy now are busy, not disabled, while they work, so their
focus survives until they leave the page, when it goes to the notice.
The store's local TriggerSkipReason alias lost its last reader when the
read shape moved onto the shared trigger schema, which already names the
closed set; the workspace lint refused the unused type.
An event an automation's run raises now names that run. The origin rides
an AsyncLocalStorage scope entered at the two doors a run's writes pass
through: the connector action host for a workflow step, and the
workspace-tool door for a workflow_run session. emitEvent reads it, so no
producer passes it, and its payload is typed per event (EventPayloads).

Event dispatch follows the bounded loop rule (owner call Q1 = B): an
event a run raised starts the other listening automations but never the
one whose run raised it, and nothing at all when that run was itself
started by an event. A raising run that cannot be read starts nothing.
The old strict branch was never reached, since every event said
'platform'. AUTO-R12, its tests and the triggers docs (EN/DE/FR) state
the bounded rule.
An event of a project (task, comment, project) now starts only the
automations installed in that project or in none, and their runs start
in that project; one installed only elsewhere does not hear it. An event
of no project (contact, conversation) starts an automation installed in
exactly one project in that project. The installations of every
listening automation are read once per dispatch.

Each listening trigger starts in a savepoint of its own. A start its
version or its project refuses is stamped start_refused, with the code,
on that trigger alone; the other listeners keep their runs. Before, one
refusal rolled back the whole dispatch, and every listener lost its run
with only a server-log line to show for it. A database fault still rolls
the dispatch back.

Because the dispatch now names the project, an archived one refuses the
event's run (PROJECT_ARCHIVED, owner call Q3). Schedules keep their
unchecked sole-installation inference, still undecided.

AUTO-R30 states the scope and the isolation, AUTO-R8 gains events, the
Not yet entries narrow, EVENT-R2 notes the trigger record, and the
triggers docs (EN/DE/FR) say which events start an automation. A new
backend:integration lane drives it through real producers: a person's
run filing a task through the task connector, an event-started run
doing the same, and a contact the mail ingest mints.
A shipped automation is seeded with nothing deployed, yet its trigger was
bound switched on: every seeded schedule skipped as not_deployed every
occurrence in every organization, and the two GitHub schedules, whose
inputs require owner and repo, were refused at every start once someone
deployed them. The provisioning now binds a pack's trigger switched off
unless the pack says otherwise (PROVN-R7), so deploying a version offers
Turn on the trigger, or Review the trigger when its input would be
refused. The default lives at the bind, not in the pack schema, because a
native release compares the parsed manifest with the one written.

The pack manifests move to repeat rules, each the lossless conversion of
its cron (minutely 5, hourly 6 at :00, daily 07:00, minutely 30, UTC).
The GitHub manifests declare enabled: false. A new packs guard holds
every pack trigger to what a save checks: it starts runs its inputs
take, or it ships off and every problem is a required top-level field
its schema declares — what a person adds as the trigger's fixed input.
The GitHub warning names owner and repo.

Migration 0173 (owner call Q2) switches off the GitHub schedules the
provisioning bound switched on that never started a run; one an
organization got running keeps its state. A new backend:integration
lane replays it twice over planted rows, and the provisioning lane
checks the seeded trigger is off and stored as a rule. The built-in
automations docs (EN/DE/FR) say the schedules start off and how to give
the GitHub ones their repository.
Under the event field, an event trigger now says which events reach it
and why it cannot loop, beside the picked event's description:

- an automation of the organization: matching events in every project,
  and those of no project;
- one installed in projects: matching events in those projects, named,
  and those of no project, such as contacts (AUTO-R30);
- always: its own runs never start it again, and a run an event started
  starts no other automation (AUTO-R12, the bounded rule, owner call
  Q1 = B).

The General tab reads the stored installations; while they or their
names are not read yet, the scope line is left out rather than wrong.
The Blank wizard names the project it creates the automation in.
New keys trigger.events.scopeOrg, scopeProjects and loopBounded in
en/de/fr; no de-CH override is needed.
jsonb stores an object's keys shortest first, so the provisioning probe's JSON.stringify comparison of the seeded repeat rule would have failed on a correct row. It now checks the frequency and interval themselves.
The selftest store mirrors the host's trigger rule, but still demanded a
cron expression for a schedule, so the analysis corpus could no longer
bind the shipped packs once their manifests moved to repeat rules. It now
takes a repeat rule or a cron expression, never both or neither, as the
host does, and lists the rule back.

The corpus's reviewed warnings lose the two GitHub mismatches: their
shipped schedules are off now, and the analysis, like the host, checks
only an enabled trigger. The packs guard holds that mismatch instead,
and a deploy still reports it, so the editor offers Review the trigger.
Rewrite the triggers page in EN, DE and FR for the trigger section the
General tab now has: the repeat picker with its presets, custom times and
custom interval, the timezone, next runs, daylight-saving changes and
missed runs; cron as the advanced format, and how stored cron schedules
open; webhook URLs per project, the test request and recent deliveries;
the event list with its payloads and project scope; what a run receives,
the fixed input, Run now; turning a trigger on after deploy; and the
skip notices with their fixes in place of the code-only table.

Bring the API reference, the MCP tool table, the webhooks reference,
the concepts page and the built-in automations note in line: repeat
rules, startDate, catchUp, the fixed input, nextRunAt, warnings,
lastSkipDetail and missed_occurrences.
Append AUTO-F93 to AUTO-F105 and AUTO-A17/A18 for what the trigger
section's specs leave to a person: presets, custom times and intervals
with their hours, a stored cron's note, next runs in another zone and
across a clock change, each skip notice with its fix, Run now, project
webhook URLs and recent deliveries, the event list, missed runs, the
fixed input's missing fields, the deploy notice, the time fields by
keyboard and screen reader, and the picker at phone width and 200 %
zoom. The IDs continue after the ones other open branches took.

AUTO-F28 and AUTO-F47 cited a key the webhook panel no longer has; they
now judge the masked address and its hint. Three coverage rows say what
the webhook panel, event field and deploy notice tests own.
The triggers page said that after an outage the Trigger section counts
the missed runs either way. With the default, the run that starts for
the latest missed time is not counted, so a daily schedule that missed
one start reports nothing; only Skip them reports it. Say so in EN, DE
and FR, and that the default counts the earlier times.
Main now holds the MCP authoring tools. The trigger contract this branch
built becomes set_trigger's argument (lib/mcp/trigger-args), the triggers
reference states repeat rules, catch-up, fixed input and project-scoped
events, and deploy answers previousVersion beside the bound trigger. MCP
tool schemas inline their definitions, so a client that resolves no
reference reads the fixed input too; the house error map names a tagged
union's tags. This branch's spec IDs follow main's: AUTO-R29 (DST),
AUTO-R30 (event scope), AUTO-R31 (input warnings), AUTO-R32 (missed
occurrences); contract 3.28.0.
Main's slot-wake opt-in (#4540) rides beside the shared trigger contract:
managed configuration alone sends wakeOnSlotFreed, setTrigger and
assertTriggerValid take it off before the strict parse and refuse it on
another kind, and the managed schedule value carries it on cron and rule
schedules alike. A woken occurrence starts with the schedule's fixed input,
as any occurrence does. This branch's spec IDs move past main's wake rules:
AUTO-R34 (DST), AUTO-R35 (event scope), AUTO-R36 (input warnings), AUTO-R37
(missed occurrences).

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant