Repository navigation
feat(platform): schedule automations with the repeat picker - #4619
Open
yannickmonney wants to merge 30 commits into
Open
yannickmonney wants to merge 30 commits into
yannickmonney wants to merge 30 commits into
Conversation
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.
yannickmonney
enabled auto-merge
October 9, 2026 05:23
yannickmonney
disabled auto-merge
October 9, 2026 05:27
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).
yannickmonney
enabled auto-merge
October 9, 2026 06:14
yannickmonney
disabled auto-merge
October 9, 2026 07:13
yannickmonney
enabled auto-merge
October 9, 2026 07:14
This branch has not been deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
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.0170–0173):schedule_rule,catch_up,next_due_at_mswith 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.SKIP LOCKED, and is idempotent under overlapping scans and replicas.catchUp: "latest"(the default) starts the most recent once;"skip"starts it only within 10 minutes.Every trigger
TRIGGER_INPUT_MISMATCH,TRIGGER_INPUT_NOT_TEMPLATED) when the deployed version would refuse what the trigger sends, and "This run receives" previews it.missed_occurrencesjoins the skip ledger.0173), and packs now seed their triggers off.Contract 3.28.0
PUT …/triggersbody andTriggercome from the shared schemas; refusals are coded underdata.issues.set_triggertakes the same trigger,deploy_automationanswers the bound trigger besidepreviousVersion, and the triggers reference states repeat rules, catch-up and the fixed input.$ref.must be one of "schedule", "webhook", "event").Integration with main's MCP authoring (#4592)
This branch merged main after #4592 landed:
trigger-argsschema, which was built for this swap.repeat,catchUpand that a fixed input is set (not its values).AUTO-R29(DST),AUTO-R30(event scope),AUTO-R31(input warnings),AUTO-R32(missed occurrences).Main's slot wakes (#4608), merged in.
wakeOnSlotFreedstays outside the shared trigger contract; only managed configuration sends it.setTriggerandassertTriggerValidread it, then hold the rest to the contract.triggerRunInputbuilder the scan uses.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
@tale/shared: 828 pass.lint:manual,lint:linksand knip clean.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.Notes
timezoneandcrontogether withrepeatare now refused. A monthly 09:00 missed during an outage now runs once, late (catchUp: latest), where it used to be dropped.