Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 42 additions & 0 deletions backend/drizzle/0114_schedule_zones.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
-- fast-deploy: expansion-safe
-- Every schedule names the zone its cron runs in. One saved without a zone ran
-- on its backend's own clock. The backend migrates at boot, and the release
-- script runs under the backend's env, so the pool applying this names that
-- clock's zone in useagent.backend_time_zone (src/db/client.ts): the clock
-- such a schedule would read after the swap anyway. Each gets that zone here
-- and keeps firing at the times it did. Applied without the setting (a scratch
-- database, a tool), this refuses any schedule that needs a zone rather than
-- guess one.
--
-- The previous backend serves until the swap and still saves a schedule sent
-- without a zone, or one cleared, with none. The trigger gives that write the
-- same zone, so the column can be NOT NULL now.
--
-- The table lock comes first and is the strongest this needs (SET NOT NULL's):
-- holding nothing while it waits, the migration never deadlocks with a writer
-- that locked a row and has its UPDATE still to run, and once held no write
-- slips in between the backfill and the constraint.
LOCK TABLE "schedules" IN ACCESS EXCLUSIVE MODE;
--> statement-breakpoint
DO $$
DECLARE
zone text := nullif(current_setting('useagent.backend_time_zone', true), '');
BEGIN
IF zone IS NULL AND EXISTS (SELECT 1 FROM "schedules" WHERE "timezone" IS NULL) THEN
RAISE EXCEPTION 'schedules saved without a zone ran on the backend''s clock: apply migrations through the backend, which names that zone in useagent.backend_time_zone';
END IF;
EXECUTE format(
'CREATE OR REPLACE FUNCTION "schedules_timezone_fill"() RETURNS trigger LANGUAGE plpgsql AS %L',
format('BEGIN NEW."timezone" := %L; RETURN NEW; END', zone)
);
END
$$;
--> statement-breakpoint
DROP TRIGGER IF EXISTS "schedules_timezone_fill" ON "schedules";
--> statement-breakpoint
CREATE TRIGGER "schedules_timezone_fill" BEFORE INSERT OR UPDATE OF "timezone" ON "schedules"
FOR EACH ROW WHEN (NEW."timezone" IS NULL) EXECUTE FUNCTION "schedules_timezone_fill"();
--> statement-breakpoint
UPDATE "schedules" SET "timezone" = current_setting('useagent.backend_time_zone', true) WHERE "timezone" IS NULL;
--> statement-breakpoint
ALTER TABLE "schedules" ALTER COLUMN "timezone" SET NOT NULL;
7 changes: 7 additions & 0 deletions backend/drizzle/meta/_journal.json
Original file line number Diff line number Diff line change
Expand Up @@ -757,6 +757,13 @@
"when": 1792800000023,
"tag": "0113_runtime_artifact_verifications",
"breakpoints": true
},
{
"idx": 109,
"version": "7",
"when": 1792800000024,
"tag": "0114_schedule_zones",
"breakpoints": true
}
]
}
6 changes: 4 additions & 2 deletions backend/src/bots/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ import type { BotRow } from "../db/schema";
import { asOrgAdmin } from "../provider-connections/repo";
import { memberDisplayNames } from "../team/member-names";
import { botsEnabled } from "./rollout";
import { listFirings, markFired } from "../schedules/repo";
import { listFirings, markFired, nextRunAt } from "../schedules/repo";
import {
createScheduleForOrg,
deleteScheduleForOrg,
Expand Down Expand Up @@ -265,7 +265,7 @@ function routineView(row: {
id: string;
name: string;
cron: string;
timezone: string | null;
timezone: string;
prompt: string;
enabled: boolean;
lastFiredAt: Date | null;
Expand All @@ -279,6 +279,8 @@ function routineView(row: {
prompt: row.prompt,
enabled: row.enabled,
lastFiredAt: row.lastFiredAt ? row.lastFiredAt.toISOString() : null,
// The minute the scheduler fires it next (schedules/repo.ts nextRunAt).
nextDueAt: nextRunAt(row, new Date())?.toISOString() ?? null,
createdAt: row.createdAt.toISOString(),
};
}
Expand Down
8 changes: 6 additions & 2 deletions backend/src/db/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,14 @@ import { drizzle } from "drizzle-orm/postgres-js";
import postgres from "postgres";
import * as schema from "./schema";

// Single shared connection pool for the whole process.
// Single shared connection pool for the whole process. Its connections name
// the zone this process's clock runs in, which a migration applied here reads
// (drizzle/0114_schedule_zones.sql): the backend migrates at boot and the
// release script runs under the backend's own env, so it is the clock the
// backend reads local times in.
export const client = postgres(
process.env.DATABASE_URL ?? "postgres://postgres@localhost:5432/useagent",
{ max: 10 },
{ max: 10, connection: { "useagent.backend_time_zone": Intl.DateTimeFormat().resolvedOptions().timeZone } },
);
export const db = drizzle(client, { schema });

Expand Down
12 changes: 6 additions & 6 deletions backend/src/db/schema/schedules.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,13 +32,13 @@ export const schedules = pgTable(
orgId: text("org_id").notNull(),
userId: text("user_id"),
name: text("name").notNull(),
// 5-field cron expression ("min hour dom month dow"). Evaluated in
// `timezone` (below) when set, else in the scheduler's server-local time.
// 5-field cron expression ("min hour dom month dow"), evaluated in `timezone`.
cron: text("cron").notNull(),
// IANA timezone the cron is evaluated in ("America/New_York"). Null = server
// local time (the pre-timezone behavior). Cloudflare-Scheduler parity: a
// schedule's wall-clock intent is explicit, not tied to where the box runs.
timezone: text("timezone"),
// IANA timezone the cron is evaluated in ("America/New_York"). Every schedule
// names one, so its wall-clock intent never depends on where the box runs;
// drizzle/0114_schedule_zones.sql gave the ones saved without a zone the zone
// their backend's clock read.
timezone: text("timezone").notNull(),
prompt: text("prompt").notNull(),
engine: text("engine").$type<EngineId>().notNull().default("mock"),
model: text("model").notNull().default("claude-opus-5"),
Expand Down
4 changes: 2 additions & 2 deletions backend/src/knowledge/gateway/automation-tool-catalog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ export const AUTOMATION_TOOLS = [
properties: {
name: { type: "string", description: "Human-readable automation name." },
cron: { type: "string", description: "Five-field cron expression." },
timezone: { type: "string", description: "Optional IANA timezone, for example Asia/Kolkata." },
timezone: { type: "string", description: "IANA timezone the cron runs in, for example Asia/Kolkata. UTC when omitted." },
prompt: { type: "string", description: "Prompt to run when the automation fires." },
engine: { type: "string", description: "Optional UseAgent engine id." },
model: { type: "string", description: "Optional model id allowed for the selected engine." },
Expand Down Expand Up @@ -84,7 +84,7 @@ export const AUTOMATION_TOOLS = [
id: { type: "string", description: "Automation id returned by automation_list/create." },
name: { type: "string" },
cron: { type: "string" },
timezone: { type: ["string", "null"], description: "IANA timezone, or null/empty string to clear." },
timezone: { type: "string", description: "IANA timezone the cron runs in." },
prompt: { type: "string" },
engine: { type: "string" },
model: { type: "string" },
Expand Down
3 changes: 2 additions & 1 deletion backend/src/knowledge/gateway/automation-tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import {
AUTOMATION_TOOLS as AUTOMATION_TOOL_CATALOG,
} from "./automation-tool-catalog";
import { getRunForOrg } from "../../runs/repo";
import { getScheduleForOrg, listFirings, listSchedules, type ApiFiring, type ApiSchedule, type ScheduleRecord } from "../../schedules/repo";
import { getScheduleForOrg, listFirings, listSchedules, nextRunAt, type ApiFiring, type ApiSchedule, type ScheduleRecord } from "../../schedules/repo";
import {
createScheduleForOrg,
deleteScheduleForOrg,
Expand Down Expand Up @@ -161,6 +161,7 @@ function automationSummary(schedule: ApiSchedule | ScheduleRecord): Record<strin
last_fired_at: api
? schedule.last_fired_at
: schedule.lastFiredAt?.toISOString() ?? null,
next_due_at: api ? schedule.next_due_at : nextRunAt(schedule, new Date())?.toISOString() ?? null,
created_at: api ? schedule.created_at : schedule.createdAt.toISOString(),
updated_at: api ? schedule.updated_at : schedule.updatedAt.toISOString(),
};
Expand Down
Loading
Loading