Skip to content

Commit acfae7c

Browse files
authored
chore: move scheduled jobs to github actions (#45)
1 parent aa850a7 commit acfae7c

7 files changed

Lines changed: 117 additions & 82 deletions

File tree

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
name: Reconcile Google Calendar
2+
3+
on:
4+
schedule:
5+
- cron: "*/30 * * * *"
6+
workflow_dispatch:
7+
8+
jobs:
9+
reconcile-google-calendar:
10+
runs-on: ubuntu-latest
11+
timeout-minutes: 5
12+
steps:
13+
- name: Trigger protected reconciliation route
14+
env:
15+
APP_BASE_URL: ${{ secrets.APP_BASE_URL }}
16+
CRON_SECRET: ${{ secrets.CRON_SECRET }}
17+
run: |
18+
test -n "$APP_BASE_URL"
19+
test -n "$CRON_SECRET"
20+
curl --fail-with-body --silent --show-error \
21+
-X POST \
22+
-H "authorization: Bearer $CRON_SECRET" \
23+
"$APP_BASE_URL/api/cron/reconcile-google-calendar"
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
name: Send Follow-Ups
2+
3+
on:
4+
schedule:
5+
- cron: "*/15 * * * *"
6+
workflow_dispatch:
7+
8+
jobs:
9+
send-followups:
10+
runs-on: ubuntu-latest
11+
timeout-minutes: 5
12+
steps:
13+
- name: Trigger protected follow-up route
14+
env:
15+
APP_BASE_URL: ${{ secrets.APP_BASE_URL }}
16+
CRON_SECRET: ${{ secrets.CRON_SECRET }}
17+
run: |
18+
test -n "$APP_BASE_URL"
19+
test -n "$CRON_SECRET"
20+
curl --fail-with-body --silent --show-error \
21+
-X POST \
22+
-H "authorization: Bearer $CRON_SECRET" \
23+
"$APP_BASE_URL/api/cron/send-followups"

README.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -106,7 +106,11 @@ Atlas no longer exposes public planner/debug mutation routes. The intended exter
106106
- `GET /api/google-calendar/oauth/callback`
107107
- protected cron routes that require `Authorization: Bearer $CRON_SECRET`
108108

109-
The production deployment schedules follow-up dispatch on Vercel every 10 minutes via `GET /api/cron/send-followups`.
109+
The production deployment serves protected cron routes from Vercel, but GitHub Actions owns the schedules:
110+
- [send-followups.yml](/Users/maxlin/Code/Atlas/.github/workflows/send-followups.yml) calls `POST /api/cron/send-followups` every 15 minutes
111+
- [reconcile-google-calendar.yml](/Users/maxlin/Code/Atlas/.github/workflows/reconcile-google-calendar.yml) calls `POST /api/cron/reconcile-google-calendar` every 30 minutes
112+
113+
Both workflows use the `APP_BASE_URL` and `CRON_SECRET` repository secrets.
110114

111115
## How We Work
112116

docs/workflows/production-deploy-checklist.md

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -109,11 +109,13 @@ curl "https://api.telegram.org/bot$TELEGRAM_BOT_TOKEN/getWebhookInfo"
109109

110110
### Cron protection
111111

112-
1. Confirm Vercel Cron Jobs includes `/api/cron/send-followups` on the production deployment.
113-
2. Call the follow-up cron route without `Authorization: Bearer $CRON_SECRET` and confirm it is rejected.
114-
3. Call the follow-up cron route with the correct bearer token and confirm it succeeds.
115-
4. Call the reconcile route without `Authorization: Bearer $CRON_SECRET` and confirm it is rejected.
116-
5. Call the reconcile route with the correct bearer token and confirm it succeeds.
112+
1. Confirm the GitHub Actions workflows [`.github/workflows/send-followups.yml`](/Users/maxlin/Code/Atlas/.github/workflows/send-followups.yml) and [`.github/workflows/reconcile-google-calendar.yml`](/Users/maxlin/Code/Atlas/.github/workflows/reconcile-google-calendar.yml) are enabled with `APP_BASE_URL` and `CRON_SECRET` repository secrets.
113+
2. Confirm `send-followups.yml` runs every 15 minutes and `reconcile-google-calendar.yml` runs every 30 minutes.
114+
3. Confirm both workflows expose manual `workflow_dispatch` for smoke tests.
115+
4. Call the follow-up cron route without `Authorization: Bearer $CRON_SECRET` and confirm it is rejected.
116+
5. Call the follow-up cron route with the correct bearer token and confirm it succeeds.
117+
6. Call the reconcile route without `Authorization: Bearer $CRON_SECRET` and confirm it is rejected.
118+
7. Call the reconcile route with the correct bearer token and confirm it succeeds.
117119

118120
### Basic safety checks
119121

docs/workflows/vercel-telegram-webhook.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,7 @@ curl "https://api.telegram.org/bot$TELEGRAM_BOT_TOKEN/getWebhookInfo"
7272

7373
## Notes
7474

75+
- Vercel only hosts the protected cron endpoints. Scheduled follow-up dispatch and Google Calendar reconciliation now come from GitHub Actions via [`.github/workflows/send-followups.yml`](/Users/maxlin/Code/Atlas/.github/workflows/send-followups.yml) and [`.github/workflows/reconcile-google-calendar.yml`](/Users/maxlin/Code/Atlas/.github/workflows/reconcile-google-calendar.yml), not from Vercel Cron Jobs.
7576
- Do not log the webhook secret in Vercel function logs or error output.
7677
- Keep the route handler thin and continue moving persistence and planning behavior into workspace packages.
7778
- After the first Vercel smoke test, the next backend milestone is replacing the `processInboxItem` stub with planner-owned persistence that reads canonical `inbox_items`, creates validated `tasks`, and records `planner_runs` as operational audit state.

packages/db/src/planner.ts

Lines changed: 58 additions & 70 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { randomUUID } from "node:crypto";
22

33
import type { InboxItem, ScheduleBlock, Task, UserProfile } from "@atlas/core";
44
import { buildDefaultUserProfile, buildScheduleBlocksFromTasks } from "@atlas/core";
5-
import { eq } from "drizzle-orm";
5+
import { and, eq, isNotNull, isNull, lte } from "drizzle-orm";
66
import { drizzle } from "drizzle-orm/postgres-js";
77
import postgres from "postgres";
88

@@ -160,6 +160,8 @@ export interface FollowUpRuntimeStore {
160160
markFollowUpReminderSent(taskIds: string[], sentAt: string): Promise<void>;
161161
}
162162

163+
const FOLLOWUP_REMINDER_DELAY_MS = 2 * 60 * 60 * 1000;
164+
163165
type StoredInboxItem = InboxItem;
164166

165167
type StoredTask = Omit<Task, "createdAt"> & {
@@ -598,7 +600,7 @@ class InMemoryInboxProcessingStore implements InboxProcessingStore, FollowUpRunt
598600
task.lifecycleState === "awaiting_followup" &&
599601
task.lastFollowupAt &&
600602
!task.followupReminderSentAt &&
601-
Date.parse(task.lastFollowupAt) + 2 * 60 * 60 * 1000 <= Date.parse(now)
603+
Date.parse(task.lastFollowupAt) + FOLLOWUP_REMINDER_DELAY_MS <= Date.parse(now)
602604
) {
603605
due.push({ ...task, dueType: "reminder" });
604606
}
@@ -1310,59 +1312,36 @@ export class PostgresInboxProcessingStore implements InboxProcessingStore, Follo
13101312
}
13111313

13121314
async listDueFollowUpTasks(now = new Date().toISOString()): Promise<FollowUpDueTask[]> {
1313-
const taskRows = await this.db
1315+
const initialRows = await this.db
13141316
.select()
13151317
.from(tasks)
1316-
.where(eq(tasks.lifecycleState, "scheduled"));
1318+
.where(
1319+
and(
1320+
eq(tasks.lifecycleState, "scheduled"),
1321+
lte(tasks.scheduledEndAt, new Date(now))
1322+
)
1323+
);
13171324

1318-
const awaitingRows = await this.db
1325+
const reminderRows = await this.db
13191326
.select()
13201327
.from(tasks)
1321-
.where(eq(tasks.lifecycleState, "awaiting_followup"));
1328+
.where(
1329+
and(
1330+
eq(tasks.lifecycleState, "awaiting_followup"),
1331+
isNull(tasks.followupReminderSentAt),
1332+
isNotNull(tasks.lastFollowupAt),
1333+
lte(tasks.lastFollowupAt, new Date(Date.parse(now) - FOLLOWUP_REMINDER_DELAY_MS))
1334+
)
1335+
);
13221336

13231337
const due: FollowUpDueTask[] = [];
13241338

1325-
for (const task of [...taskRows, ...awaitingRows]) {
1326-
const parsedTask = {
1327-
id: task.id,
1328-
userId: task.userId,
1329-
sourceInboxItemId: task.sourceInboxItemId,
1330-
lastInboxItemId: task.lastInboxItemId,
1331-
title: task.title,
1332-
lifecycleState: task.lifecycleState as Task["lifecycleState"],
1333-
externalCalendarEventId: task.externalCalendarEventId,
1334-
externalCalendarId: task.externalCalendarId,
1335-
scheduledStartAt: task.scheduledStartAt?.toISOString() ?? null,
1336-
scheduledEndAt: task.scheduledEndAt?.toISOString() ?? null,
1337-
calendarSyncStatus: task.calendarSyncStatus as Task["calendarSyncStatus"],
1338-
calendarSyncUpdatedAt: task.calendarSyncUpdatedAt?.toISOString() ?? null,
1339-
rescheduleCount: task.rescheduleCount,
1340-
lastFollowupAt: task.lastFollowupAt?.toISOString() ?? null,
1341-
followupReminderSentAt: task.followupReminderSentAt?.toISOString() ?? null,
1342-
completedAt: task.completedAt?.toISOString() ?? null,
1343-
archivedAt: task.archivedAt?.toISOString() ?? null,
1344-
priority: task.priority as Task["priority"],
1345-
urgency: task.urgency as Task["urgency"],
1346-
createdAt: task.createdAt.toISOString()
1347-
};
1348-
1349-
if (
1350-
parsedTask.lifecycleState === "scheduled" &&
1351-
parsedTask.scheduledEndAt !== null &&
1352-
Date.parse(parsedTask.scheduledEndAt) <= Date.parse(now)
1353-
) {
1354-
due.push({ ...parsedTask, dueType: "initial" });
1355-
continue;
1356-
}
1339+
for (const task of initialRows) {
1340+
due.push({ ...toPersistedTask(task), dueType: "initial" });
1341+
}
13571342

1358-
if (
1359-
parsedTask.lifecycleState === "awaiting_followup" &&
1360-
parsedTask.lastFollowupAt !== null &&
1361-
parsedTask.followupReminderSentAt === null &&
1362-
Date.parse(parsedTask.lastFollowupAt) + 2 * 60 * 60 * 1000 <= Date.parse(now)
1363-
) {
1364-
due.push({ ...parsedTask, dueType: "reminder" });
1365-
}
1343+
for (const task of reminderRows) {
1344+
due.push({ ...toPersistedTask(task), dueType: "reminder" });
13661345
}
13671346

13681347
return due.sort((left, right) => {
@@ -1376,32 +1355,16 @@ export class PostgresInboxProcessingStore implements InboxProcessingStore, Follo
13761355
const taskRows = await this.db
13771356
.select()
13781357
.from(tasks)
1379-
.where(eq(tasks.userId, userId));
1358+
.where(
1359+
and(
1360+
eq(tasks.userId, userId),
1361+
eq(tasks.lifecycleState, "awaiting_followup"),
1362+
isNotNull(tasks.lastFollowupAt)
1363+
)
1364+
);
13801365

13811366
return taskRows
1382-
.filter((task) => task.lifecycleState === "awaiting_followup" && task.lastFollowupAt !== null)
1383-
.map((task) => ({
1384-
id: task.id,
1385-
userId: task.userId,
1386-
sourceInboxItemId: task.sourceInboxItemId,
1387-
lastInboxItemId: task.lastInboxItemId,
1388-
title: task.title,
1389-
lifecycleState: task.lifecycleState as Task["lifecycleState"],
1390-
externalCalendarEventId: task.externalCalendarEventId,
1391-
externalCalendarId: task.externalCalendarId,
1392-
scheduledStartAt: task.scheduledStartAt?.toISOString() ?? null,
1393-
scheduledEndAt: task.scheduledEndAt?.toISOString() ?? null,
1394-
calendarSyncStatus: task.calendarSyncStatus as Task["calendarSyncStatus"],
1395-
calendarSyncUpdatedAt: task.calendarSyncUpdatedAt?.toISOString() ?? null,
1396-
rescheduleCount: task.rescheduleCount,
1397-
lastFollowupAt: task.lastFollowupAt?.toISOString() ?? null,
1398-
followupReminderSentAt: task.followupReminderSentAt?.toISOString() ?? null,
1399-
completedAt: task.completedAt?.toISOString() ?? null,
1400-
archivedAt: task.archivedAt?.toISOString() ?? null,
1401-
priority: task.priority as Task["priority"],
1402-
urgency: task.urgency as Task["urgency"],
1403-
createdAt: task.createdAt.toISOString()
1404-
}))
1367+
.map((task) => toPersistedTask(task))
14051368
.sort(
14061369
(left, right) =>
14071370
Date.parse(left.lastFollowupAt ?? left.scheduledEndAt ?? new Date(0).toISOString()) -
@@ -1499,6 +1462,31 @@ export class PostgresInboxProcessingStore implements InboxProcessingStore, Follo
14991462
}
15001463
}
15011464

1465+
function toPersistedTask(task: typeof tasks.$inferSelect): Task {
1466+
return {
1467+
id: task.id,
1468+
userId: task.userId,
1469+
sourceInboxItemId: task.sourceInboxItemId,
1470+
lastInboxItemId: task.lastInboxItemId,
1471+
title: task.title,
1472+
lifecycleState: task.lifecycleState as Task["lifecycleState"],
1473+
externalCalendarEventId: task.externalCalendarEventId,
1474+
externalCalendarId: task.externalCalendarId,
1475+
scheduledStartAt: task.scheduledStartAt?.toISOString() ?? null,
1476+
scheduledEndAt: task.scheduledEndAt?.toISOString() ?? null,
1477+
calendarSyncStatus: task.calendarSyncStatus as Task["calendarSyncStatus"],
1478+
calendarSyncUpdatedAt: task.calendarSyncUpdatedAt?.toISOString() ?? null,
1479+
rescheduleCount: task.rescheduleCount,
1480+
lastFollowupAt: task.lastFollowupAt?.toISOString() ?? null,
1481+
followupReminderSentAt: task.followupReminderSentAt?.toISOString() ?? null,
1482+
completedAt: task.completedAt?.toISOString() ?? null,
1483+
archivedAt: task.archivedAt?.toISOString() ?? null,
1484+
priority: task.priority as Task["priority"],
1485+
urgency: task.urgency as Task["urgency"],
1486+
createdAt: task.createdAt.toISOString()
1487+
};
1488+
}
1489+
15021490
const defaultInMemoryStore = new InMemoryInboxProcessingStore();
15031491
attachGoogleCalendarConnectionStoreToTasks({
15041492
getTasks: () => defaultInMemoryStore.listTasks(),

vercel.json

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,5 @@
11
{
22
"$schema": "https://openapi.vercel.sh/vercel.json",
3-
"crons": [
4-
{
5-
"path": "/api/cron/send-followups",
6-
"schedule": "*/10 * * * *"
7-
}
8-
],
93
"buildCommand": "pnpm build",
104
"installCommand": "pnpm install --frozen-lockfile"
115
}

0 commit comments

Comments
 (0)