Connector Tasks
Overview
trigger/connectors.ts owns every scheduled sync that pulls external data into
SABLE's Supabase tables: Fathom meeting notes, Granola notes, Asana tasks,
Toggl hours, Google Calendar free/busy, Google Drive documents, and inbound
email. Each sync is a thin Trigger.dev wrapper around one SABLE Supabase edge
function.
trigger/approvals.ts's executeApproval is grouped here too, even though it
lives in a different file: it is the write-back half of the same connector
surface. Where the tasks below pull data in, executeApproval pushes a
human-approved action out to Asana or Google Calendar (asana-act /
calendar-act), so it belongs next to the read-side connectors rather than in
any of the other three pages.
How it works
Every connector task funnels through runConnector(), a shared helper that:
- Builds a payload carrying a
triggerenvelope (task_id,scheduled_at,schedule_id). - Opens a
trigger_runsrow viacreateTriggerRunbefore doing any work, so every invocation is auditable even if it later fails. - Calls the target Supabase edge function through
invokeSableEdge(trigger/lib/edge.ts), which wraps the HTTP call inretry.fetchwithtimeout.maxAttempts: 5andconnectionError.maxAttempts: 5. - Marks the
trigger_runsrowcompletedorfailedviacompleteTriggerRun/failTriggerRun.
Three connectors — Fathom, email, and (via a separate always-on task) Drive —
don't stop after one edge-function call. Fathom, Granola-style backlogs can
outrun a single 110-second edge-function budget, so fathomReconcile and
emailDrain loop, re-invoking the same edge function while its response says
stopped_early: true, up to a bounded pass count and a wall-clock run budget.
driveRefreshContinuation does the equivalent by chaining a new task run
(.trigger(..., { delay: "30s" })) rather than looping in place. All three
treat "some work failed, but the run made net progress" as a warning to log,
not a reason to fail the whole run — only a run with zero cumulative
successes is fatal. This distinction (see driveBatchOutcome in
trigger/lib/drive-outcome.ts, re-exported from connectors.ts) exists
because a single failing file inside a batch of one (batchSize: 1) always
looks like a 100%-failed call in isolation, before the edge function's own
retry-then-skip budget gets a chance to run.
Two connectors carry an unusual, undocumented-elsewhere requirement in their
payload: asanaSync must pass workspaceGid (the Asana PAT can see more than
one workspace and the edge function refuses to guess without it) and
togglHours must pass hours: true (without it toggl-sync runs its drift
check and writes no hours at all). Both traps are called out in code comments
because a payload that looks complete, and a run that reports success, can
still silently skip the actual work.
Tasks
| Task | Trigger / cron | What it does |
|---|---|---|
fathomReconcile | schedule, cron 15 */4 * * * | Drains the Fathom meeting backlog. Loops up to 8 passes (FATHOM_MAX_PASSES) inside a ~25-minute budget, calling fathom-sync with limit: 25, lookbackDays: 3 each pass. Logs (does not fail) when it is still behind after all passes. |
granolaPoll | schedule, cron 5,35 * * * * | Polls Granola for new notes, limit: 25, lookbackHours: 72, single call to granola-sync. |
asanaSync | schedule, cron 25 */2 * * * | Refreshes Asana tasks with confirm: true, limit: 50, scoped to ASANA_WORKSPACE_GID. Runs independently of board generation so Asana no longer goes stale on weekends. |
togglHours | schedule, cron 55 */4 * * * | Refreshes Toggl hours (hours: true) so account/project screens and chat answers about burn stay current even though the board itself no longer cites Toggl. |
calendarFreebusy | schedule, cron 40 * * * * | Refreshes calendar free/busy and events, days: 14, via calendar-sync. |
driveRefresh / driveRefreshContinuation | no cron — plain task, invoked by hand or by the continuation chain | Batched Drive folder scan and ingest (batchSize: 1, up to 40 batches per invocation). Currently dormant by design: Drive ingestion is off (project folders are link-only), so drive-sync returns from its paused branch immediately. Kept invokable because credentials and retry logic are tested and the decision may reverse. driveRefreshContinuation is the exact same run function, exposed as a distinct task id so it can be scheduled/queued separately from a manual driveRefresh call. |
emailIngest | schedule, cron */10 * * * * | Ingests new inbound email, limit: 200, maxNew: 5, single attempt (retry: { maxAttempts: 1 }) — the schedule itself is the retry, deliberately, after a 546 edge-worker-ceiling incident on 2026-09-09 where task-level retries tripled a pile-up (25 invocations in 23 minutes). |
emailDrain | schedule, cron 35 */2 * * * | Bounded drain of whatever emailIngest's ten-minute cap can't clear: up to 6 passes (EMAIL_MAX_PASSES), same stopped_early loop pattern as Fathom. |
executeApproval (approvals.ts) | event — triggered when the app dispatches an approved action | Claims an approvals row, stamps it with the Trigger run id, then calls asana-act or calendar-act (selected by payload.edgeFunction) to actually perform the approved write. Runs on connectorWriteQueue. |
What can go wrong / how to investigate
- A connector looks "stale" on the board even though it's running. Check
whether it's mid-drain:
fathomReconcileandemailDrainintentionally report partial coverage and pick up where they left off next cron tick. Look at the task'sstill_behind/fathom_still_behindoutput and thefathom_passes/email_passesmetadata rather than assuming a failure. - Asana sync 500s on every run. Almost always a missing or stale
ASANA_WORKSPACE_GIDenv var — the edge function refuses to guess which workspace to scope to. - Toggl hours "succeed" but nothing changes on the account screen. Confirm
the payload actually carries
hours: true; a valid-looking payload without it runs the drift check only. - A batch of Drive or email work looks 100% failed. Check
driveBatchOutcome's fatal-vs-partial distinction before treating a single failing file as an outage — a poison file exhausting its own retry budget inside the edge function is expected and is logged, not thrown, as long as the run's cumulative totals include at least one success. sable.email.ingestruns are piling up. This connector deliberately has no task-level retry; if you see repeated failures, look at the edge function's own health (worker resource ceiling / 546s) rather than Trigger.dev retry configuration — the fix here was removing retries, not adding them.- An approval never executes. Check the
approvalstable row'strigger_run_id(set byexecuteApprovalright after it claims the row) and the correspondingtrigger_runsentry for the actualasana-act/calendar-actfailure reason.
Where the code lives
sable-agents-demo/trigger/connectors.ts— all scheduled sync tasks.sable-agents-demo/trigger/approvals.ts—executeApproval(connector write-back).sable-agents-demo/trigger/lib/edge.ts—invokeSableEdge, the shared Supabase edge-function caller with retry/timeout policy.sable-agents-demo/trigger/lib/drive-outcome.ts—driveBatchOutcomefatal/partial classification.sable-agents-demo/trigger/lib/env.ts—requireEnv(used forASANA_WORKSPACE_GID).sable-agents-demo/trigger/lib/supabase.ts—createTriggerRun/completeTriggerRun/failTriggerRun/serviceClient.sable-agents-demo/trigger/queues.ts—connectorQueue(concurrencyLimit: 2) andconnectorWriteQueue(concurrencyLimit: 1), shared across most tasks on this page.- Supabase: the
trigger_runstable (migration0041_trigger_runs.sql) is the audit trail for every connector invocation; theapprovalstable gained atrigger_run_idcolumn in the same migration so an approval's execution can be traced back to its Trigger run.