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
146 changes: 106 additions & 40 deletions apps/mobile/src/features/settings/SettingsScheduledTasksRouteScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
MAX_WEBHOOK_DELIVERY_AGE_MINUTES,
MAX_WEBHOOK_FILTERS,
resolveEnvironmentMachineKind,
scheduledTaskCronError,
scheduledTaskThreadMode,
} from "@t3tools/contracts";
import { useSelectWorkspaceDetail } from "../layout/workspace/useSelectWorkspaceDetail";
Expand Down Expand Up @@ -55,7 +56,6 @@ import {
import { Menu } from "../../components/ui/Menu";
import type { ComposerEditorSelection } from "../../components/ComposerEditor";
import { ScreenScrollView as ScrollView } from "../../components/ui/ScreenScrollView";
import { SegmentedControl } from "../../components/ui/SegmentedControl";
import { ThemedSwitch } from "../../components/ui/ThemedSwitch";
import { webhookAddress } from "@t3tools/client-runtime/webhook-address";
import { tryCopyTextWithHaptic } from "../../lib/copyTextWithHaptic";
Expand All @@ -74,6 +74,7 @@ import { SettingsSection } from "./components/SettingsSection";
import { useSettingsEnvironmentFilter, type SettingsTarget } from "./settings-environment-filter";
import {
changeDraftProject,
DEFAULT_CRON_EXPRESSION,
editDraft,
parseIntervalMinutes,
scheduledTaskDefaultModel,
Expand Down Expand Up @@ -125,9 +126,11 @@ function describeSchedule(task: ScheduledTask, timeZone?: string): string {
return `Once, ${formatRunAtDate(runAt)} at ${formatRunAtTime(runAt)}`;
}
if (task.schedule.type === "interval") return formatScheduledTaskInterval(task.schedule.everyMs);
const days = task.schedule.weekdays?.length ? repeatLabel(task.schedule.weekdays) : "Every day";
const zone = scheduleTimeZoneLabel(timeZone, task.nextRunAt);
return `${days} at ${formatTime(task.schedule.timeOfDay)}${zone === undefined ? "" : ` ${zone}`}`;
const suffix = zone === undefined ? "" : ` ${zone}`;
if (task.schedule.type === "cron") return `Cron ${task.schedule.expression}${suffix}`;
const days = task.schedule.weekdays?.length ? repeatLabel(task.schedule.weekdays) : "Every day";
return `${days} at ${formatTime(task.schedule.timeOfDay)}${suffix}`;
}

function formatTime(value: string): string {
Expand Down Expand Up @@ -169,6 +172,14 @@ function repeatLabel(weekdays: ReadonlyArray<number>): string {
);
}

const cronHelp = (example: string) =>
`Minute, hour, day of month, month, weekday. ${example} runs every 30 minutes from 9:00 to 16:30 on weekdays.`;
const CRON_HELP = cronHelp(DEFAULT_CRON_EXPRESSION);
// Word joiners and no-break spaces keep the example on one line; screen readers get CRON_HELP.
const CRON_HELP_VISIBLE = cronHelp(
[...DEFAULT_CRON_EXPRESSION.replaceAll(" ", "\u00a0")].join("\u2060"),
);

function FormField(props: {
readonly label: string;
readonly value: string;
Expand All @@ -177,6 +188,9 @@ function FormField(props: {
readonly disabled?: boolean;
readonly placeholder?: string;
readonly borderTop?: boolean;
/** For code-like values that capitalization and autocorrect would change. */
readonly verbatim?: boolean;
readonly hint?: string;
}) {
return (
<View
Expand All @@ -187,13 +201,15 @@ function FormField(props: {
<Text className="text-sm text-foreground-muted">{props.label}</Text>
<RNTextInput
accessibilityLabel={props.label}
accessibilityHint={props.hint}
value={props.value}
readOnly={props.disabled}
onChangeText={props.onChange}
textAlignVertical="center"
keyboardType={props.keyboardType}
placeholder={props.placeholder}
placeholderTextColorClassName="accent-foreground-muted"
{...(props.verbatim ? { autoCapitalize: "none", autoCorrect: false } : {})}
className="min-h-8 font-sans text-base text-foreground"
/>
</View>
Expand Down Expand Up @@ -700,6 +716,25 @@ function TaskForm({
const offersOnce =
config?.environment.capabilities.oneTimeSchedules === true ||
draft.task?.schedule.type === "once";
// Older servers reject cron schedules on save.
const offersCron =
config?.environment.capabilities.cronSchedules === true || draft.task?.schedule.type === "cron";
const cronError =
draft.schedule.mode === "cron" ? scheduledTaskCronError(draft.schedule.cronExpression) : null;
const scheduleModes = [
{ value: "fixed_time" as const, label: "At a time" },
// Older servers reject one-time schedules on save.
...(offersOnce || draft.schedule.mode === "once"
? [{ value: "once" as const, label: "Once" }]
: []),
{ value: "interval" as const, label: "Interval" },
...(offersCron || draft.schedule.mode === "cron"
? [{ value: "cron" as const, label: "Cron" }]
: []),
...(webhookDenial === undefined || draft.schedule.mode === "webhook"
? [{ value: "webhook" as const, label: "On webhook" }]
: []),
];
const offersThreadModes = config?.environment.capabilities.scheduledTaskThreadModes === true;
const save = async () => {
if (
Expand Down Expand Up @@ -735,6 +770,17 @@ function TaskForm({
);
return;
}
if (draft.schedule.mode === "cron" && !offersCron) {
Dialog.alert(
"Cron is not available here",
"This environment runs an older T3 Code. Update it to schedule a cron task, or pick another schedule.",
);
return;
}
if (cronError !== null) {
Dialog.alert("Invalid cron expression", cronError);
return;
}
const parsedFilters = parseWebhookFilterDrafts(draft.schedule.filters);
if (draft.schedule.mode === "webhook" && "error" in parsedFilters) {
Dialog.alert("Invalid filter", parsedFilters.error);
Expand Down Expand Up @@ -1021,42 +1067,38 @@ function TaskForm({
</SettingsSection>

<SettingsSection title="Schedule">
<View className="px-4 py-3">
<SegmentedControl
variant="card"
options={[
{ value: "fixed_time", label: "At a time" },
// Older servers reject one-time schedules on save.
...(offersOnce || draft.schedule.mode === "once"
? [{ value: "once" as const, label: "Once" }]
: []),
{ value: "interval", label: "Interval" },
...(webhookDenial === undefined || draft.schedule.mode === "webhook"
? [{ value: "webhook" as const, label: "On webhook" }]
: []),
]}
selected={draft.schedule.mode}
onSelect={(mode) => {
setTimePickerOpen(false);
setRunAtPicker(null);
setDraft({
...draft,
prompt:
mode === "webhook" && !draft.prompt.trim()
? DEFAULT_WEBHOOK_PROMPT
: draft.prompt,
schedule: {
...draft.schedule,
mode,
runAt:
mode === "once" && !draft.schedule.runAt
? new Date(Math.ceil(Date.now() / 3_600_000) * 3_600_000).toISOString()
: draft.schedule.runAt,
},
});
}}
/>
</View>
<SelectRow
label="Type"
value={
scheduleModes.find((option) => option.value === draft.schedule.mode)?.label ?? ""
}
actions={scheduleModes.map((option) => ({
id: option.value,
title: option.label,
state: option.value === draft.schedule.mode ? "on" : undefined,
}))}
onSelect={(id) => {
const mode = scheduleModes.find((option) => option.value === id)?.value;
if (mode === undefined) return;
setTimePickerOpen(false);
setRunAtPicker(null);
setDraft({
...draft,
prompt:
mode === "webhook" && !draft.prompt.trim()
? DEFAULT_WEBHOOK_PROMPT
: draft.prompt,
schedule: {
...draft.schedule,
mode,
runAt:
mode === "once" && !draft.schedule.runAt
? new Date(Math.ceil(Date.now() / 3_600_000) * 3_600_000).toISOString()
: draft.schedule.runAt,
},
});
}}
/>
{draft.schedule.mode === "fixed_time" ? (
<>
<Pressable
Expand Down Expand Up @@ -1188,6 +1230,30 @@ function TaskForm({
</View>
);
})
) : draft.schedule.mode === "cron" ? (
<>
<FormField
label="Cron expression"
value={draft.schedule.cronExpression}
placeholder={DEFAULT_CRON_EXPRESSION}
disabled={saving}
borderTop
verbatim
hint={cronError === null ? CRON_HELP : `Error: ${cronError}`}
onChange={(cronExpression) =>
setDraft({ ...draft, schedule: { ...draft.schedule, cronExpression } })
}
/>
<Text
className={
cronError === null
? "px-4 pb-3 text-sm text-foreground-muted"
: "px-4 pb-3 text-sm text-danger-foreground"
}
>
{cronError ?? CRON_HELP_VISIBLE}
</Text>
</>
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
) : draft.schedule.mode === "webhook" ? (
<>
<WebhookScheduleDetails
Expand Down Expand Up @@ -1294,7 +1360,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
15 changes: 15 additions & 0 deletions apps/mobile/src/features/settings/scheduledTaskDraft.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,21 @@ describe("scheduleDraftForTask", () => {
expect(scheduleFromDraft(draft)).toEqual({ type: "interval", everyMs: 60_000 });
});

it("round-trips a cron expression, sends it trimmed, and refuses one the server would reject", () => {
const draft = scheduleDraftForTask({
schedule: { type: "cron", expression: "*/30 9-16 * * MON-FRI" },
});
expect(draft.mode).toBe("cron");
expect(scheduleFromDraft(draft)).toEqual({ type: "cron", expression: "*/30 9-16 * * MON-FRI" });
expect(scheduleFromDraft({ ...draft, cronExpression: " 0 9 * * 1 " })).toEqual({
type: "cron",
expression: "0 9 * * 1",
});
for (const cronExpression of ["* * * * * *", "0 25 * * *", "0 9 30 2 *"]) {
expect(scheduleFromDraft({ ...draft, cronExpression })).toBeNull();
}
});

it("round-trips dedupe settings and rejects invalid windows", () => {
const dedupe = {
replayKey: "{{headers.delivery-id}}",
Expand Down
22 changes: 20 additions & 2 deletions apps/mobile/src/features/settings/scheduledTaskDraft.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
import { isWebhookDedupeTemplate, scheduledTaskThreadMode } from "@t3tools/contracts";
import {
isWebhookDedupeTemplate,
scheduledTaskCronError,
scheduledTaskThreadMode,
} from "@t3tools/contracts";
import type {
ModelSelection,
ServerConfig,
Expand Down Expand Up @@ -52,11 +56,16 @@ export function scheduledTaskDefaultModel(
});
}

/** Weekdays every 30 minutes from 09:00 to 16:30, a starting point for the cron field. */
export const DEFAULT_CRON_EXPRESSION = "*/30 9-16 * * 1-5";

export type ScheduleDraft = {
readonly mode: "fixed_time" | "once" | "interval" | "webhook";
readonly mode: "fixed_time" | "once" | "interval" | "cron" | "webhook";
readonly timeOfDay: string;
/** ISO time for a one-time run. Empty until the user picks the "Once" mode. */
readonly runAt: string;
/** Five-field cron, read in the environment's time zone. */
readonly cronExpression: string;
readonly weekdays: ReadonlyArray<number>;
readonly intervalMinutes: string;
readonly signature: ScheduledTaskWebhookSignature | null;
Expand All @@ -73,6 +82,7 @@ export const DEFAULT_SCHEDULE: ScheduleDraft = {
mode: "fixed_time",
timeOfDay: "09:00",
runAt: "",
cronExpression: DEFAULT_CRON_EXPRESSION,
weekdays: [1, 2, 3, 4, 5],
intervalMinutes: "15",
signature: null,
Expand All @@ -96,6 +106,8 @@ export function scheduleDraftForTask(task: Pick<ScheduledTask, "schedule">): Sch
};
case "once":
return { ...DEFAULT_SCHEDULE, mode: "once", runAt: task.schedule.runAt };
case "cron":
return { ...DEFAULT_SCHEDULE, mode: "cron", cronExpression: task.schedule.expression };
case "interval":
return {
...DEFAULT_SCHEDULE,
Expand Down Expand Up @@ -177,6 +189,11 @@ export function scheduleFromDraft(draft: ScheduleDraft): ScheduledTaskUpsertSche
const runAt = new Date(draft.runAt);
return Number.isNaN(runAt.getTime()) ? null : { type: "once", runAt: runAt.toISOString() };
}
if (draft.mode === "cron") {
return scheduledTaskCronError(draft.cronExpression) === null
? { type: "cron", expression: draft.cronExpression.trim() }
: null;
}
if (draft.mode === "interval") {
const minutes = parseIntervalMinutes(draft.intervalMinutes);
const everyMs = Math.round(minutes * 60_000);
Expand Down Expand Up @@ -230,6 +247,7 @@ function draftSignature(draft: ScheduledTaskDraft): string {
draft.schedule.mode,
draft.schedule.timeOfDay,
draft.schedule.runAt,
draft.schedule.cronExpression,
[...draft.schedule.weekdays].sort((a, b) => a - b),
draft.schedule.intervalMinutes,
draft.schedule.maxDeliveryAgeMinutes,
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 @@ -273,6 +273,7 @@ export const make = Effect.gen(function* () {
ignoredFileListing: true,
triage: true,
oneTimeSchedules: true,
cronSchedules: true,
freshContext: true,
scheduledTaskThreadModes: true,
npmReleaseLookup: 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": 5597,
"codexAdditionalContextBytes": 7327,
"mcpToolListJsonBytes": 48131
"mcpToolListJsonBytes": 48195
}
Loading
Loading