Repository navigation
feat(core)!: recurring triggers per ADR-055 (one tick, per-installation timing) - #694
Conversation
…l port
Integrations can declare recurring jobs (Definition.schedules, or a
data-defined schedule trigger / polling sync). Core keeps one host
schedule per installation in step through create, update, upgrade and
delete. A developer can let users choose their own timing, with a floor.
- recurrence.js: host-neutral { every } / five-field { cron, timezone },
validated by what an expression really does (min gap, never fires,
dom+dow, time zones).
- Scheduler port gains upsertRecurring; the EventBridge adapter
translates to rate()/cron() (weekday names, `?`), updates or creates,
survives a create race, and fills each firing's tick from context
attributes; the mock adapter mirrors it.
- Commands: upsertRecurringJob and reconcileRecurringJobs (writes only
what changed; a refused write stays recorded as before and retries).
- IntegrationBase.syncSchedules, called by the default lifecycle,
records host state at config.runtime.schedules; invalid user choices
keep the default and add a warning message.
- Data-defined: compile emits schedule entries, validation and JSON
Schemas accept configurable timing, the configure form offers it,
versioning treats removing configurability as major and upgrades
drop choices the new version disallows, ticks dedupe on their
scheduled time.
- devtools: Lambda may UpdateSchedule.
136 data-defined tests pass on real PostgreSQL and MongoDB; 15
mutations killed. Core's full suite fails the same 16 suites / 65
tests as before (pre-existing).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NGC9SnFuqdjX2xH2qeu8vw
There was a problem hiding this comment.
If your organization's extra usage balance is empty, an organization admin can add extra usage credits at claude.ai/admin-settings/usage. If its monthly spend limit was reached, an admin can raise it on the same page. If neither applies, contact Anthropic support.
Once extra usage is available, someone with write access to this repository can comment @claude review on this pull request to trigger a review.
|
ADR reconciliation needed after Stack A/B — drafted with Claude Code at Sean's request Severity: blocker — this PR builds the mechanism ADR-055 (#669) explicitly rejected. Conflict
Proposed resolution (recommended: 055 governs, this PR adapts)
If per-installation host schedules are kept instead, ADR-055 must be amended to accept them with answers to each Alternatives §1 objection, ADR-032 (#641) must list them, and the Netlify adapter needs Merge order: #669 (055 amended) → this PR rebased; coordinate |
… is built here - Section 15a: ADR-055 governs recurring work (Definition.triggers, one tick, due check); the per-installation host schedule is being reworked. - Section 17: the EventBridge row no longer says registration is not built. - Do not name a specific integration's crons. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NGC9SnFuqdjX2xH2qeu8vw
…schedules
ADR-055 governs: no schedule exists per installation. The per-installation
EventBridge mechanism from the previous commit (IntegrationBase.syncSchedules,
reconcileRecurringJobs, upsertRecurring on the scheduler port, and
config.runtime.schedules) is removed, and lifecycle hooks no longer touch the
scheduler.
- Definition.triggers (ADR-055 §1) replaces Definition.schedules: every
(constant or a function of config), at + timezone, tick, when, overlap and
a handler (function or method name). Amendment: configurable: true | { min }
lets an installation choose its timing at config.triggers.<name>, and a
five-field cron + timezone is a calendar trigger that may differ per
installation, due when it fired in (previous tick, this tick].
- integrations/triggers.js: validation, provisioningOf, resolveTrigger and the
stateless due check (every: slot mod n == hash(id) mod n, a chosen interval
rounded up to whole ticks). recurrence.js gains lastFiringIn (time-zone
aware); validateRecurrence and the configurable floor are unchanged.
- Tick: RunTriggerTick pages ENABLED installations 500 at a time
(findIntegrations gains limit/after on PostgreSQL, MongoDB and DocumentDB),
sends { event: 'FRIGG_TRIGGER_RUN', data: { integrationId, trigger,
scheduledFor } } with batchSend and DelaySeconds jitter, and hands its
cursor to the queue as FRIGG_TRIGGER_TICK near the deadline. Handler:
handlers/workers/integration-triggers.js.
- Run: IntegrationBase.onTriggerRun via the RunTrigger use case. Not ENABLED
is discarded; overlap 'skip' leases on one TRIGGER:<name> Process record
per installation; a failed run is marked ERROR and rethrown.
- Data-defined: compile emits the same trigger entries (compiled.triggers);
the class declares instanceTriggers: { tick: '5m' } and resolves each
installation's triggers from its pinned definition and config at the tick.
Configurable timing, floors, the form, versioning (dropping a disallowed
choice on upgrade) and the tick:<event>:<scheduledFor> dedupe keep working.
- MongoDB findIntegrations({ type }) used a JSON path filter the Prisma
MongoDB connector refuses; it now reads matching ids with findRaw.
- devtools: TriggerBuilder (runs when any integration declares triggers)
emits {integration}Triggers with one rate() schedule per distinct tick
(its firing time as input) and one method: scheduler event per at
trigger; the framework adds the invoke permissions, and the tick needs no
runtime scheduler IAM. getIntegrationFunctionNames includes the function
so a scoped queue URL reaches it. SchedulerBuilder is back to one-time jobs.
- Docs: ADR-063 §15a, CLAUDE.md and INTEGRATION-PATTERNS.md describe
triggers instead of per-installation schedules and CRON events.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NGC9SnFuqdjX2xH2qeu8vw
…-2, admin-routes-2, neutral-names-2) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NGC9SnFuqdjX2xH2qeu8vw
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NGC9SnFuqdjX2xH2qeu8vw
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NGC9SnFuqdjX2xH2qeu8vw
| const checkSchedule = (recurrence, configurable, path) => { | ||
| const where = recurrence.every !== undefined ? `${path}.every` : `${path}.cron`; | ||
| for (const m of validateRecurrence(recurrence)) err(where, m); | ||
| if (configurable && typeof configurable === 'object' && configurable.min !== undefined) { | ||
| for (const m of validateRecurrence(recurrence, { min: configurable.min })) if (!validateRecurrence(recurrence).includes(m)) err(`${path}.configurable.min`, `The default schedule breaks its own minimum: ${m}`); | ||
| } | ||
| }; |
There was a problem hiding this comment.
checkSchedule accepts any every that validateRecurrence accepts, including values over one day such as 2d or 7d. The JSON schema pattern ^\d+\s*(m|h|d)$ accepts them too.
At runtime resolveTrigger in triggers.js rejects any every above 1440 minutes with every "2d" is longer than 1d. It then sets active: false, inactive: 'invalid'.
So a definition with trigger: { type: 'schedule', every: '2d' }, or a poll sync with ongoing.every: '7d', publishes as valid. Nothing ever runs for it; the only trace is a warning message on each installation update. Hand-written triggers do reject this case in validateTrigger, so the two paths are inconsistent.
Fix by adding the same maximum check to checkSchedule:
if (recurrence.every !== undefined && parseEvery(recurrence.every) > 1440) {
err(where, `every "${recurrence.every}" is longer than 1d; use cron`);
}| const checkSchedule = (recurrence, configurable, path) => { | |
| const where = recurrence.every !== undefined ? `${path}.every` : `${path}.cron`; | |
| for (const m of validateRecurrence(recurrence)) err(where, m); | |
| if (configurable && typeof configurable === 'object' && configurable.min !== undefined) { | |
| for (const m of validateRecurrence(recurrence, { min: configurable.min })) if (!validateRecurrence(recurrence).includes(m)) err(`${path}.configurable.min`, `The default schedule breaks its own minimum: ${m}`); | |
| } | |
| }; | |
| const checkSchedule = (recurrence, configurable, path) => { | |
| const where = recurrence.every !== undefined ? `${path}.every` : `${path}.cron`; | |
| for (const m of validateRecurrence(recurrence)) err(where, m); | |
| if (recurrence.every !== undefined && parseEvery(recurrence.every) > 1440) { | |
| err(where, `every "${recurrence.every}" is longer than 1d; use cron`); | |
| } | |
| if (configurable && typeof configurable === 'object' && configurable.min !== undefined) { | |
| for (const m of validateRecurrence(recurrence, { min: configurable.min })) if (!validateRecurrence(recurrence).includes(m)) err(`${path}.configurable.min`, `The default schedule breaks its own minimum: ${m}`); | |
| } | |
| }; | |
Spotted by Graphite
Is this helpful? React 👍 or 👎 to let us know.
|
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using high effort and found 2 potential issues.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit f66a64a. Configure here.
| } else if (mode === 'poll') { | ||
| const n = add(eventName('SYNC', s.id, 'ONGOING'), { type: 'CRON', ref: { kind: 'sync', id: s.id, phase: 'ongoing' } }); | ||
| schedules.push({ event: n, every: s.ongoing.every || '15m' }); | ||
| triggers.push({ name: n, every: s.ongoing.every || '15m', configurable: s.ongoing.configurable || false, choice: installation.syncs?.[s.id]?.schedule }); |
There was a problem hiding this comment.
Poll sync triggers invoke unimplemented runSync
Medium Severity
Polling syncs now compile into recurring triggers and are queued on every tick, but the handler still dispatches to runSync, which throws. A definition with ongoing.mode of poll (valid in the schema) will error on each due run, mark the process ERROR, and retry until the DLQ. Previously those schedules were compiled and never registered.
Additional Locations (2)
Reviewed by Cursor Bugbot for commit f66a64a. Configure here.
| return out; | ||
| } catch (error) { | ||
| await this.processRepository.update(record.id, { state: 'ERROR', results: { scheduledFor: payload.scheduledFor, error: error.message } }).catch(() => {}); | ||
| throw error; |
There was a problem hiding this comment.
Failed lease update skips trigger retries
Medium Severity
When a trigger handler throws, the ERROR write is swallowed if it fails, leaving the TRIGGER:<name> process in RUNNING. The queue retry then sees a non-terminal record touched inside the lease (twice the tick) and returns skipped, so the failure is not retried until that lease expires.
Reviewed by Cursor Bugbot for commit f66a64a. Configure here.
|
Release review: blocker addressed. ADR-055 governs (
The ADR-055 amendment text (configurable timing, per-installation calendar triggers, per-installation trigger sets, run identity) is ready to post on #669. Generated by Claude Code |





Stack 3 of 9. Stacked on #693.
Summary
An integration can declare recurring jobs: either
Definition.schedules, or a definition's schedule trigger or polling sync. Core keeps one host schedule per installation and updates it on create, update, upgrade and delete. A developer can let users pick their own timing, down to a floor the developer sets.recurrence.js. Recurrences are{ every }or a five-field{ cron, timezone }. They are validated by what the expression actually does: minimum gap, never fires, day-of-month plus day-of-week, and time zones.upsertRecurring.rate()/cron()and survives a create race.tickwith{ id, scheduledFor }so a handler can drop a redelivered firing.upsertRecurringJobandreconcileRecurringJobs.IntegrationBase.syncSchedules. The default lifecycle calls it. Host state is recorded atconfig.runtime.schedules. A user choice that isn't allowed keeps the default and adds a warning.UpdateSchedule.Test plan
nextat this commit. Nothing new fails.Generated by Claude Code
Note
High Risk
Changes how all recurring integration work is scheduled and executed (tick paging, due logic, overlap leases, and queue volume); mis-timed or duplicate runs could affect production syncs and digests at scale.
Overview
Replaces per-installation
CRONevents and the old “registercompiled.schedules” gap with ADR-055Definition.triggers: one{integration}TriggersLambda per integration, shared tick schedules, and queue fan-out—no lifecycle scheduler calls for recurring work.Core runtime: Adds host-neutral
recurrence.js(interval/cron validation, floors,lastFiringIn),integrations/triggers.js(declare, provision ticks, resolve per-installation timing, hash-spreadeverydue checks),RunTriggerTick(page ENABLED installs, queueFRIGG_TRIGGER_RUNwith jitter/continuation), andRunTrigger(overlap lease viaTRIGGER:<name>Process).IntegrationBasewiresonTriggerRun/onTriggerTick, statictriggersFor, and config warnings when user timing breaks floors.Data-defined:
compileemitstriggersinstead ofschedules; polling syncs and schedule workflows supportconfigurableinstall choices; shared class usesinstanceTriggers: { tick: '5m' }andtriggersForfrom pinned definition + config; workflow dedupe usestick:<event>:<scheduledFor>.Infra & data access:
TriggerBuilderprovisions tick functions and EventBridgerate()/ scheduleratevents;findIntegrationsgains cursor paging (Postgres/Mongo/DocumentDB +batchSizefor Mongo);QueuerUtil.batchSendsupports per-messageDelaySeconds.Docs and guides are updated to steer authors from
CRONevents toDefinition.triggers.Reviewed by Cursor Bugbot for commit f66a64a. Bugbot is set up for automated code reviews on this repo. Configure here.
📦 Published PR as canary version:
2.0.0--canary.694.f66a64a.0✨ Test out this PR locally via:
npm install @friggframework/admin-scripts@2.0.0--canary.694.f66a64a.0 npm install @friggframework/core@2.0.0--canary.694.f66a64a.0 npm install @friggframework/devtools@2.0.0--canary.694.f66a64a.0 npm install @friggframework/eslint-config@2.0.0--canary.694.f66a64a.0 npm install @friggframework/prettier-config@2.0.0--canary.694.f66a64a.0 npm install @friggframework/schemas@2.0.0--canary.694.f66a64a.0 npm install @friggframework/serverless-plugin@2.0.0--canary.694.f66a64a.0 npm install @friggframework/test@2.0.0--canary.694.f66a64a.0 npm install @friggframework/ui@2.0.0--canary.694.f66a64a.0 # or yarn add @friggframework/admin-scripts@2.0.0--canary.694.f66a64a.0 yarn add @friggframework/core@2.0.0--canary.694.f66a64a.0 yarn add @friggframework/devtools@2.0.0--canary.694.f66a64a.0 yarn add @friggframework/eslint-config@2.0.0--canary.694.f66a64a.0 yarn add @friggframework/prettier-config@2.0.0--canary.694.f66a64a.0 yarn add @friggframework/schemas@2.0.0--canary.694.f66a64a.0 yarn add @friggframework/serverless-plugin@2.0.0--canary.694.f66a64a.0 yarn add @friggframework/test@2.0.0--canary.694.f66a64a.0 yarn add @friggframework/ui@2.0.0--canary.694.f66a64a.0