Skip to content
Open
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
2 changes: 1 addition & 1 deletion apps/mobile/src/features/keyboard/CommandPalette.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -329,7 +329,7 @@ export function CommandPalette(props: {
key: "scheduledTasks",
kind: "action",
title: "Scheduled tasks",
searchTerms: ["schedule", "automations", "recurring"],
searchTerms: ["schedule", "automations", "recurring", "cron"],
run: () => openSettings(navigation, "SettingsScheduledTasks"),
},
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
MAX_WEBHOOK_DELIVERY_AGE_MINUTES,
MAX_WEBHOOK_FILTERS,
resolveEnvironmentMachineKind,
scheduledTaskCronExpressionError,
} from "@t3tools/contracts";
import type { MenuAction } from "@react-native-menu/menu";
import { DateTimePicker } from "@expo/ui/community/datetime-picker";
Expand Down Expand Up @@ -109,6 +110,7 @@ const DAYS = [
function describeSchedule(task: ScheduledTask): string {
if (task.schedule.type === "webhook") return "On webhook";
if (task.schedule.type === "interval") return formatScheduledTaskInterval(task.schedule.everyMs);
if (task.schedule.type === "cron") return `Cron ${task.schedule.expression}`;
const days = task.schedule.weekdays?.length ? repeatLabel(task.schedule.weekdays) : "Every day";
return `${days} at ${formatTime(task.schedule.timeOfDay)}`;
}
Expand Down Expand Up @@ -149,6 +151,8 @@ function FormField(props: {
readonly value: string;
readonly onChange: (value: string) => void;
readonly keyboardType?: "decimal-pad" | "number-pad";
readonly autoCapitalize?: "none";
readonly autoCorrect?: boolean;
readonly disabled?: boolean;
readonly placeholder?: string;
readonly borderTop?: boolean;
Expand All @@ -167,6 +171,8 @@ function FormField(props: {
onChangeText={props.onChange}
textAlignVertical="center"
keyboardType={props.keyboardType}
autoCapitalize={props.autoCapitalize}
autoCorrect={props.autoCorrect}
placeholder={props.placeholder}
placeholderTextColorClassName="accent-foreground-muted"
className="min-h-8 font-sans text-base text-foreground"
Expand Down Expand Up @@ -684,6 +690,11 @@ function TaskForm({
const environmentUnavailable = !availableTargets.some(
(target) => target.environmentId === environmentId,
);
const cronSupported = config?.environment.capabilities.scheduledTaskCron === true;
const cronError =
draft.schedule.mode === "cron"
? scheduledTaskCronExpressionError(draft.schedule.cronExpression)
: null;

const failure = (title: string, result: AtomCommandResult<unknown, unknown>) => {
if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) {
Expand Down Expand Up @@ -730,6 +741,17 @@ function TaskForm({
);
return;
}
if (draft.schedule.mode === "cron" && !cronSupported) {
Dialog.alert(
"Custom schedules unavailable",
"Update T3 Code on this environment to use custom schedules.",
);
return;
}
if (cronError !== null) {
Dialog.alert("Invalid cron expression", cronError);
return;
}
if (
!draft.title.trim() ||
!draft.prompt.trim() ||
Expand Down Expand Up @@ -955,6 +977,9 @@ function TaskForm({
options={[
{ value: "fixed_time", label: "At a time" },
{ value: "interval", label: "Interval" },
...(cronSupported || draft.schedule.mode === "cron"
? [{ value: "cron" as const, label: "Custom" }]
: []),
{ value: "webhook", label: "On webhook" },
]}
selected={draft.schedule.mode}
Expand Down Expand Up @@ -1115,6 +1140,28 @@ function TaskForm({
window from the first delivery closes. Empty keys run normally.
</Text>
</>
) : draft.schedule.mode === "cron" ? (
<>
<FormField
label="Cron expression"
value={draft.schedule.cronExpression}
placeholder="0 9 * * 1-5"
autoCapitalize="none"
autoCorrect={false}
disabled={saving}
borderTop
onChange={(cronExpression) =>
setDraft({ ...draft, schedule: { ...draft.schedule, cronExpression } })
}
/>
{cronError !== null && draft.schedule.cronExpression.trim() ? (
<Text className="px-4 pb-3 text-sm text-danger-foreground">{cronError}</Text>
) : (
<Text className="px-4 pb-3 text-sm text-foreground-muted">
minute hour day month weekday
</Text>
)}
</>
) : (
<>
<FormField
Expand Down Expand Up @@ -1151,7 +1198,7 @@ function TaskForm({
/>
</View>
</SettingsSection>
{draft.schedule.mode === "fixed_time" ? (
{draft.schedule.mode === "fixed_time" || draft.schedule.mode === "cron" ? (
<Text className="px-2 text-sm text-foreground-muted">
Time uses the environment's time zone, which may differ from your phone's.
</Text>
Expand Down
31 changes: 31 additions & 0 deletions apps/mobile/src/features/settings/scheduledTaskDraft.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,9 @@ import {
} from "@t3tools/contracts";
import {
scheduledTaskDefaultModel,
createDraft,
DEFAULT_SCHEDULE,
hasScheduledTaskDraftChanges,
scheduleDraftForTask,
scheduleFromDraft,
} from "./scheduledTaskDraft";
Expand All @@ -20,6 +22,17 @@ describe("scheduleDraftForTask", () => {
expect(scheduleFromDraft(draft)).toEqual({ type: "interval", everyMs: 60_000 });
});

it("opens a cron task as cron and saves its expression with whitespace collapsed", () => {
const expression = "17 0,4,8-20 * * *";
const draft = scheduleDraftForTask({ schedule: { type: "cron", expression } });
expect(draft.mode).toBe("cron");
expect(scheduleFromDraft(draft)).toEqual({ type: "cron", expression });
expect(scheduleFromDraft({ ...draft, cronExpression: " 0 9 * *\t1-5\n" })).toEqual({
type: "cron",
expression: "0 9 * * 1-5",
});
});

it("round-trips dedupe settings and rejects invalid windows", () => {
const dedupe = {
replayKey: "{{headers.delivery-id}}",
Expand Down Expand Up @@ -96,6 +109,13 @@ describe("scheduleFromDraft", () => {
everyMs,
});
});

it.each(["", "* * * *", "0 0 9 * * 1", "@daily", "60 * * * *", "0 0 30 2 *"])(
"rejects the cron expression %j",
(cronExpression) => {
expect(scheduleFromDraft({ ...DEFAULT_SCHEDULE, mode: "cron", cronExpression })).toBeNull();
},
);
});

describe("scheduled task model defaults", () => {
Expand Down Expand Up @@ -196,3 +216,14 @@ it("rejects dedupe keys without valid request placeholders", () => {
scheduleFromDraft({ ...DEFAULT_SCHEDULE, mode: "webhook", coalesceKey: "{{body.bad" }),
).toBeNull();
});

it("counts an edit to only the cron expression as an unsaved change", () => {
const draft = createDraft(null, null);
const cron = { ...draft, schedule: { ...draft.schedule, mode: "cron" as const } };
expect(
hasScheduledTaskDraftChanges(cron, {
...cron,
schedule: { ...cron.schedule, cronExpression: "0 10 * * 1-5" },
}),
).toBe(true);
});
15 changes: 13 additions & 2 deletions apps/mobile/src/features/settings/scheduledTaskDraft.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { isWebhookDedupeTemplate } from "@t3tools/contracts";
import { isWebhookDedupeTemplate, scheduledTaskCronExpressionError } from "@t3tools/contracts";
import type {
ModelSelection,
ServerConfig,
Expand Down Expand Up @@ -51,10 +51,11 @@ export function scheduledTaskDefaultModel(
}

export type ScheduleDraft = {
readonly mode: "fixed_time" | "interval" | "webhook";
readonly mode: "fixed_time" | "interval" | "cron" | "webhook";
readonly timeOfDay: string;
readonly weekdays: ReadonlyArray<number>;
readonly intervalMinutes: string;
readonly cronExpression: string;
readonly signature: ScheduledTaskWebhookSignature | null;
readonly maxDeliveryAgeMinutes: string;
readonly replayKey: string;
Expand All @@ -70,6 +71,7 @@ export const DEFAULT_SCHEDULE: ScheduleDraft = {
timeOfDay: "09:00",
weekdays: [1, 2, 3, 4, 5],
intervalMinutes: "15",
cronExpression: "0 9 * * 1-5",
signature: null,
maxDeliveryAgeMinutes: "",
replayKey: "",
Expand All @@ -95,6 +97,8 @@ export function scheduleDraftForTask(task: Pick<ScheduledTask, "schedule">): Sch
mode: "interval",
intervalMinutes: String(Math.max(1, task.schedule.everyMs / 60_000)),
};
case "cron":
return { ...DEFAULT_SCHEDULE, mode: "cron", cronExpression: task.schedule.expression };
case "webhook":
return {
...DEFAULT_SCHEDULE,
Expand Down Expand Up @@ -167,6 +171,12 @@ export function scheduleFromDraft(draft: ScheduleDraft): ScheduledTaskUpsertSche
const everyMs = Math.round(minutes * 60_000);
return minutes >= 1 && Number.isSafeInteger(everyMs) ? { type: "interval", everyMs } : null;
}
if (draft.mode === "cron") {
const expression = draft.cronExpression.trim().split(/\s+/).join(" ");
return scheduledTaskCronExpressionError(expression) === null
? { type: "cron", expression }
: null;
}
const weekdays = [...new Set(draft.weekdays)].sort((a, b) => a - b);
if (
!/^([01]?\d|2[0-3]):[0-5]\d$/.test(draft.timeOfDay) ||
Expand Down Expand Up @@ -213,6 +223,7 @@ function draftSignature(draft: ScheduledTaskDraft): string {
draft.schedule.timeOfDay,
[...draft.schedule.weekdays].sort((a, b) => a - b),
draft.schedule.intervalMinutes,
draft.schedule.cronExpression,
draft.schedule.maxDeliveryAgeMinutes,
draft.schedule.replayKey,
draft.schedule.coalesceKey,
Expand Down
1 change: 1 addition & 0 deletions apps/server/src/environment/ServerEnvironment.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -224,6 +224,7 @@ it.layer(NodeServices.layer)("ServerEnvironmentLive", (it) => {
expect(second.capabilities.threadPullRequestLinking).toBe(true);
expect(second.capabilities.serverResolvedCommandContext).toBe(true);
expect(second.capabilities.projectProviderInstanceOverrides).toBe(true);
expect(second.capabilities.scheduledTaskCron).toBe(true);
expect(second.capabilities.agentActivityPublishing).toBe(false);
}),
);
Expand Down
1 change: 1 addition & 0 deletions apps/server/src/environment/ServerEnvironment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -274,6 +274,7 @@ export const make = Effect.gen(function* () {
peerLinks: true,
mcpModeLimitHeader: true,
linkFence: true,
scheduledTaskCron: true,
},
};

Expand Down
2 changes: 1 addition & 1 deletion apps/server/src/provider/agentContextBudget.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"claudeSystemAppendBytes": 5135,
"codexAdditionalContextBytes": 6865,
"mcpToolListJsonBytes": 46007
"mcpToolListJsonBytes": 46148
}
59 changes: 58 additions & 1 deletion apps/server/src/scheduledTasks/Schedule.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { describe, expect, it } from "@effect/vitest";
import * as DateTime from "effect/DateTime";

import { isSameSchedule, nextScheduledRunAt } from "./Schedule.ts";
import { isMissedCalendarRun, isSameSchedule, nextScheduledRunAt } from "./Schedule.ts";

describe("scheduled task schedule calculation", () => {
it("skips to the next matching fixed-time weekday", () => {
Expand Down Expand Up @@ -76,4 +76,61 @@ describe("scheduled task schedule calculation", () => {
),
).toBe(false);
});

it("finds the next cron occurrence in the given zone", () => {
const from = DateTime.makeZonedUnsafe(
{ year: 2026, month: 7, day: 3, hour: 10, minute: 0, second: 0, millisecond: 0 },
{ timeZone: "America/Los_Angeles", adjustForTimeZone: true },
);
const weekdays = nextScheduledRunAt({ type: "cron", expression: "0 9 * * MON-FRI" }, from);
expect(weekdays ? DateTime.formatIso(DateTime.toUtc(weekdays)) : null).toBe(
"2026-07-06T16:00:00.000Z",
);
const hourly = nextScheduledRunAt({ type: "cron", expression: "17 0,4,8-20 * * *" }, from);
const parts = hourly ? DateTime.toParts(hourly) : null;
expect(parts?.day).toBe(3);
expect(parts?.hour).toBe(10);
expect(parts?.minute).toBe(17);
});

it.each(["0 0 9 * * *", "0 9 L * *", "0 0 30 2 *"])(
"has no next run for cron %s",
(expression) => {
const from = DateTime.makeZonedUnsafe(
{ year: 2026, month: 7, day: 3, hour: 10, minute: 0, second: 0, millisecond: 0 },
{ timeZone: "UTC", adjustForTimeZone: true },
);
expect(nextScheduledRunAt({ type: "cron", expression }, from)).toBeNull();
},
);

it("compares cron schedules by the times they run", () => {
expect(
isSameSchedule(
{ type: "cron", expression: "0 9 * * MON-FRI" },
{ type: "cron", expression: "0 9 * * 1-5" },
),
).toBe(true);
expect(
isSameSchedule(
{ type: "cron", expression: "0 9 * * MON-FRI" },
{ type: "cron", expression: "0 10 * * MON-FRI" },
),
).toBe(false);
expect(
isSameSchedule(
{ type: "cron", expression: "0 9 * * *" },
{ type: "fixed_time", timeOfDay: "09:00" },
),
).toBe(false);
});

it("skips calendar runs missed by more than the grace period", () => {
const dueAt = DateTime.makeUnsafe("2026-07-03T09:00:00.000Z");
const late = DateTime.add(dueAt, { minutes: 11 });
const cron = { type: "cron", expression: "0 9 * * *" } as const;
expect(isMissedCalendarRun(cron, dueAt, late)).toBe(true);
expect(isMissedCalendarRun(cron, dueAt, DateTime.add(dueAt, { minutes: 5 }))).toBe(false);
expect(isMissedCalendarRun({ type: "interval", everyMs: 60_000 }, dueAt, late)).toBe(false);
});
});
Loading
Loading