Skip to content

feat(core)!: recurring triggers per ADR-055 (one tick, per-installation timing) - #694

Open
seanspeaks wants to merge 6 commits into
claude/shared-conversation-8hgmbr-stack-2-definitions-runtimefrom
claude/shared-conversation-8hgmbr-stack-3-recurring-schedules
Open

seanspeaks wants to merge 6 commits into
claude/shared-conversation-8hgmbr-stack-2-definitions-runtimefrom
claude/shared-conversation-8hgmbr-stack-3-recurring-schedules

Conversation

@seanspeaks

@seanspeaks seanspeaks commented Oct 19, 2026 •

Copy link
Copy Markdown
Contributor

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.
  • Scheduler port. Adds upsertRecurring.
    • The EventBridge adapter translates to rate()/cron() and survives a create race.
    • It fills each firing's tick with { id, scheduledFor } so a handler can drop a redelivered firing.
    • The mock adapter mirrors it.
  • Commands. upsertRecurringJob and reconcileRecurringJobs.
  • IntegrationBase.syncSchedules. The default lifecycle calls it. Host state is recorded at config.runtime.schedules. A user choice that isn't allowed keeps the default and adds a warning.
  • devtools. Lambda may call UpdateSchedule.

Test plan

  • Same 31 pre-existing failures as next at this commit. Nothing new fails.
  • Definition tests ran on real PostgreSQL and MongoDB. 15 mutations were killed.

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 CRON events and the old “register compiled.schedules” gap with ADR-055 Definition.triggers: one {integration}Triggers Lambda 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-spread every due checks), RunTriggerTick (page ENABLED installs, queue FRIGG_TRIGGER_RUN with jitter/continuation), and RunTrigger (overlap lease via TRIGGER:<name> Process). IntegrationBase wires onTriggerRun / onTriggerTick, static triggersFor, and config warnings when user timing breaks floors.

Data-defined: compile emits triggers instead of schedules; polling syncs and schedule workflows support configurable install choices; shared class uses instanceTriggers: { tick: '5m' } and triggersFor from pinned definition + config; workflow dedupe uses tick:<event>:<scheduledFor>.

Infra & data access: TriggerBuilder provisions tick functions and EventBridge rate() / scheduler at events; findIntegrations gains cursor paging (Postgres/Mongo/DocumentDB + batchSize for Mongo); QueuerUtil.batchSend supports per-message DelaySeconds.

Docs and guides are updated to steer authors from CRON events to Definition.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

…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

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Code review skipped — your organization has no extra usage available to pay for this review.

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.

@seanspeaks seanspeaks added release Create a release when this pr is merged prerelease This change is available in a prerelease. labels Oct 19, 2026 — with Claude

@cursor cursor Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stale Bugbot comment from a previous run.

Comment thread packages/core/application/commands/scheduler-commands.js Outdated
Comment thread packages/core/integrations/integration-base.js Outdated
@seanspeaks

Copy link
Copy Markdown
Contributor Author

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

  • IntegrationBase.syncSchedules() + reconcileRecurringJobs create one EventBridge recurring schedule per installation per event (schedules.js scheduleJobId → <integration>-<installationId>-<event>), upserted from onCreate/onUpdate/onDelete. ADR-055 Decision ("No per-instance schedule exists") and Alternatives §1 reject exactly this (runtime AWS calls + IAM per lifecycle event, drift, ADR-032 cannot list them, per-account quota, separate local path).
  • ADR-063 §15a (added here) admits the drift case: status changes don't pass through onUpdate, so a DISABLED installation keeps its host schedule.
  • Two declaration keys for one concept: Definition.schedules (here) vs Definition.triggers (ADR-055 §1 / ADR-054 §1). Payloads differ too: { event, data: { integrationId, scheduleKey, tick } } vs FRIGG_TRIGGER_RUN.
  • Host neutrality: upsertRecurring is added to the port, but the Netlify adapter in feat: Netlify provider on the v2 stack (port of #545) (F7) #686 (and ADR-028's SchedulerAdapter) is one-time only, so Definition.schedules silently does nothing there. A tick is what every provider can run (Netlify's scheduled function already is one).
  • SchedulerBuilder.shouldExecute still only fires on scheduler.enable / webhooks, so a code integration that declares schedules gets no scheduler role/IAM on AWS.

Proposed resolution (recommended: 055 governs, this PR adapts)

  1. Rename Definition.schedules → Definition.triggers with ADR-055's shape; data definitions compile schedule triggers and poll syncs into the same entries.
  2. Keep recurrence.js / validateRecurrence / configurable.min — they become inputs to the tick's due check instead of to upsertRecurring. Per-installation cron+timezone becomes "due if a firing falls in (previous tick, this tick]", still stateless.
  3. Drop per-installation upsertRecurring and config.runtime.schedules; lifecycle hooks no longer touch the scheduler.
  4. ADR-055 amendment (on docs(adr): ADR-054 one-file integrations via module extensions, ADR-055 recurring triggers #669): per-instance calendar triggers and configurable floors, which removes its "arbitrary per-instance calendar times not supported" negative.

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 upsertRecurring.

Merge order: #669 (055 amended) → this PR rebased; coordinate infrastructure/scheduler/* and scheduler-commands.js with #686, which edits the same files.

… 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
Comment on lines +31 to +37
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}`);
}
};

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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`);
}
Suggested change
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

Fix in Graphite


Is this helpful? React 👍 or 👎 to let us know.

@sonarqubecloud

Copy link
Copy Markdown

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using high effort and found 2 potential issues.

Fix All in Cursor

❌ 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 });

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)
Fix in Cursor Fix in Web

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;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Failed lease update skips trigger retries

Medium Severity

When a trigger handler throws, the ERROR write is swallowed if it fails, leaving the TRIGGER:&lt;name&gt; 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.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit f66a64a. Configure here.

@seanspeaks seanspeaks changed the title feat(core): recurring per-installation schedules behind a host-neutral port feat(core)!: recurring triggers per ADR-055 (one tick, per-installation timing) Oct 19, 2026

Copy link
Copy Markdown
Contributor Author

Release review: blocker addressed. ADR-055 governs (d7b4f7f0, folded in at f66a64af).

  • Definition.schedules is now Definition.triggers, in ADR-055's shape (every, at + timezone, tick, when, overlap, handler).
  • No schedule is created per installation, and lifecycle hooks no longer touch the scheduler. syncSchedules, reconcileRecurringJobs, upsertRecurring and config.runtime.schedules are removed.
  • One {integration}Triggers function runs per integration, with one rate() rule per distinct tick. It pages through the ENABLED installations and sends FRIGG_TRIGGER_RUN { integrationId, trigger, scheduledFor } for each one that is due. Near the deadline it continues through the queue.
  • Due-ness is decided without stored state: every uses a hashed slot, cron is due when a firing falls in (previous tick, this tick] in its time zone, and DST is handled. recurrence.js, validateRecurrence and configurable.min feed that check.
  • Integration documents compile schedule workflows and polling syncs into the same trigger entries, via instanceTriggers and triggersFor.
  • TriggerBuilder.shouldExecute covers triggers. SchedulerBuilder is back to one-time jobs only, so Netlify parity holds.
  • Fixed along the way:
    • Mongo findIntegrations({ type }) threw on every call that filtered by type.
    • The DocumentDB paging cursor did not match ObjectIds.
  • 34 mutations killed. Core and devtools fail the same suites as next.

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

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

Labels

prerelease This change is available in a prerelease. release Create a release when this pr is merged

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants