Actions Developer Guide

Scheduled Actions

Run actions on calendar schedules with cron expressions, timezones, and graph-derived schedule data.

Scheduled Actions

A schedule turns an action into a calendar trigger: the controller computes due instants and queues an ordinary execution at each one. Runners are never aware of calendars — a scheduled occurrence is a normal queue entry that the existing dispatcher assigns to a capable runner, exactly like a manual POST /api/actions/:module/:action trigger.

# actions/nightly_backup.toml
name = "nightly_backup"
description = "Creates a database snapshot every night at 03:30"
schedule = "30 3 * * *"
exec = [["python3", "backup.py"]]

[runtime]
engine = "nix"
flake = "./flake.nix"
files = ["./backup.py"]

Syntax

The schedule field accepts two forms:

# String form: cron expression evaluated in UTC.
schedule = "30 3 * * *"

# Table form: cron expression plus an explicit IANA timezone.
schedule = { cron = "30 3 * * *", timezone = "Europe/Zurich" }
  • cron (required): a Croner pattern with five fields, or six fields when seconds are given ("15 30 3 * * *" fires at second 15). Nicknames such as @daily, @hourly, @weekly, and @monthly are supported.
  • timezone (optional, table form only): an IANA timezone name such as Europe/Zurich. Defaults to UTC in both forms.

Templated schedules

Both fields are rendered with the same Tera context as the action name and constraints during action expansion. A schedule value may therefore be derived from graph data or module parameters:

# Module parameter supplies the timezone; every expanded instance shares the cron.
name = "report_{{ origin_resource.name }}"
description = "Monthly report for {{ origin_resource.name }}"
origin_resource = "application"
schedule = { cron = "0 8 1 * *", timezone = "{{ params.report_timezone }}" }
exec = [["python3", "report.py"]]
# Each matching application carries its own audit_schedule property, so every
# expanded action instance runs on its own calendar.
name = "audit_{{ origin_resource.name }}"
description = "Audit {{ origin_resource.name }}"
origin_resource = "application"
schedule = "{{ origin_resource.audit_schedule }}"
exec = [["python3", "audit.py"]]
# Relation-bound actions read the schedule from the matching edge.
name = "sync_{{ origin_resource.name }}"
description = "Sync via target"
origin_resource = "application"
relation = "SYNCED_WITH"
schedule = { cron = "{{ relation.properties.sync_schedule }}", timezone = "UTC" }
exec = [["python3", "sync.py"]]

Because a dynamic action expands to one instance per matching resource (or per matching relation edge), each expanded instance becomes an independent schedule binding. Two applications audited by the same action file can run at completely different times, and a slow audit of one application never delays the other.

Cron Patterns

Expressions are parsed with Croner — similar to Vixie Cron, with a few additions:

// ┌──────────────── (optional) second (0 - 59)
// │ ┌────────────── minute (0 - 59)
// │ │ ┌──────────── hour (0 - 23)
// │ │ │ ┌────────── day of month (1 - 31)
// │ │ │ │ ┌──────── month (1 - 12, JAN-DEC)
// │ │ │ │ │ ┌────── day of week (0 - 6, SUN-SAT)
// │ │ │ │ │ │       (0 to 6 are Sunday to Saturday; 7 is Sunday, the same as 0)
// │ │ │ │ │ │
// * * * * * *
Field Required Allowed values Allowed special characters Remarks
Seconds Optional 0-59 * , - / Only in six-field form. A fixed second is allowed; schedules that repeat more often than once per minute are rejected.
Minutes Yes 0-59 * , - /
Hours Yes 0-23 * , - /
Day of Month Yes 1-31 * , - / ? L W
Month Yes 1-12 or JAN-DEC * , - /
Day of Week Yes 0-7 or SUN-SAT * , - / ? # L + 7 is Sunday, the same as 0. # selects the nth weekday of the month.

Additional modifiers:

  • ? — in the day-of-month or day-of-week field it behaves like * (legacy cron compatibility).
  • L — last day of the month in the day-of-month field (0 0 L * *). In the day-of-week field combined with # it selects the last specific weekday of the month (0 0 * * 5#L = last Friday).
  • # — the “nth” occurrence of a weekday within a month: 5#2 is the second Friday; ranges are supported (MON-FRI#2).
  • W — the weekday (Mon-Fri) closest to a given day of the month, without crossing month boundaries: 15W matches the 14th if the 15th is a Saturday, the 16th if it is a Sunday.
  • + — prefixing the day-of-week field makes day-of-month and day-of-week a logical AND instead of the default OR: 0 0 1 * +MON fires only when the 1st of the month is also a Monday.

Weekday and month names are case-insensitive (MON and mon both work).

Nicknames can be used as the whole pattern:

Nickname Description
@yearly Run once a year — 0 0 1 1 *
@annually Run once a year — 0 0 1 1 *
@monthly Run once a month — 0 0 1 * *
@weekly Run once a week — 0 0 * * 0
@daily Run once a day — 0 0 * * *
@hourly Run once an hour — 0 * * * *

Semantics

Scheduled actions are bindings — one expanded action definition plus its rendered cron expression and timezone — and each firing produces one occurrence:

  • Missed occurrences are skipped. If the controller is offline (or the graph is being rebuilt) when an occurrence falls due, that occurrence is not replayed on startup. The next future occurrence runs normally.
  • Overlap is per binding. While an earlier execution of the same binding is still pending, assigned, or running, the next occurrence is skipped and recorded as a skip. A different binding of the same action — for example another application — is not affected.
  • No capable runner at due time: the occurrence is queued and waits for a matching runner. Later occurrences of the same binding are skipped while it waits.
  • Minimum interval: schedules may not repeat more often than once per minute. Faster schedules are rejected with a diagnostic.
  • Daylight-saving time: a local time that does not exist (spring forward) resolves to the next existing instant, so the binding still fires exactly once on that date. A local time that occurs twice (fall back) fires once, at the first instant.
  • Required inputs: a scheduled occurrence carries no caller payload. Every required [[input]] must declare a default, otherwise the binding is disabled with a diagnostic. Inputs with defaults are filled in automatically.

Rebuilds and schedule changes

Bindings are recomputed on every graph rebuild from the same snapshot as the actions:

  • A new or changed schedule starts producing occurrences after the next successful rebuild.
  • When a binding disappears or its cron/timezone changes, future occurrences stop immediately and its pending occurrences are cancelled. Executions already claimed by a runner run to completion.
  • A binding whose rendered schedule is invalid (bad cron, unknown timezone, too frequent, missing input defaults) does not fail the graph build. It stays visible with a diagnostic and produces no occurrences until the data is fixed.

Static validation (rescile-ce validate) additionally rejects:

  • a schedule that is neither a string nor a table with cron (+ optional timezone),
  • unknown keys inside the schedule table,
  • schedule combined with auto_execute = true (conflicting trigger modes), and
  • schedule on run_mode = "service" actions (services use auto_execute).

Observing Schedules

  • GET /api/schedules lists every binding with its module, action, rendered cron, timezone, origin resource, next due time (UTC), diagnostic, and last skip reason.
  • GET /api/action-queue entries created by the scheduler carry a schedule object (binding_id, scheduled_at, cron, timezone) and a trigger_condition: {"Scheduled": "<instant>"} so scheduled executions are distinguishable from manual ones.
  • The /actions UI page shows a schedule badge on each action card plus the next run time, binding diagnostics, and recent skip reasons.

What Scheduling Does Not Do (Yet)

  • No catch-up or replay of missed occurrences (skip is the only policy today).
  • No enabled flag on schedules — remove the schedule (or the graph property driving it) to disable a binding.
  • No scheduling of run_mode = "service" actions.
  • Occurrence history is currently surfaced via the queue and the schedules API; there is no dedicated long-term schedule audit log.