1import type {
2 ModelSelection,
3 ServerConfig,
4 ProjectId,
5 RuntimeMode,
6 ScheduledTask,
7 ScheduledTaskUpsertSchedule,
8 ScheduledTaskWebhookSignature,
9} from "@t3tools/contracts";
10
11import { DEFAULT_SERVER_SETTINGS } from "@t3tools/contracts";
12import { parseMaxDeliveryAge } from "@t3tools/client-runtime/scheduled-task-webhook";
13import {
14 resolveProjectSettings,
15 type LegacyProjectSettingsFields,
16} from "@t3tools/shared/projectSettings";
17import {
18 buildModelOptions,
19 resolveDefaultableModelSelection,
20 resolveNewTaskModelSelection,
21} from "../../lib/modelOptions";
22
23export function scheduledTaskDefaultModel(
24 config: ServerConfig | null,
25 project: (LegacyProjectSettingsFields & { readonly id: ProjectId }) | null,
26): ModelSelection | null {
27 const settings = config?.settings ?? DEFAULT_SERVER_SETTINGS;
28 const configured = resolveProjectSettings(settings, project?.id ?? null, project).settings
29 .defaultModelSelection;
30 const projectDefaultSelection =
31 resolveDefaultableModelSelection(config, configured, project?.id ?? null) ??
32 resolveDefaultableModelSelection(config, settings.defaultModelSelection, project?.id ?? null);
33 return resolveNewTaskModelSelection({
34 draftSelection: null,
35 projectDefaultSelection,
36 stickySelection: null,
37 modelOptions: buildModelOptions(
38 config,
39 projectDefaultSelection,
40 undefined,
41 project?.id ?? null,
42 ),
43 });
44}
45
46export type ScheduleDraft = {
47 readonly mode: "fixed_time" | "interval" | "webhook";
48 readonly timeOfDay: string;
49 readonly weekdays: ReadonlyArray<number>;
50 readonly intervalMinutes: string;
51 /** A webhook signature check configured elsewhere; mobile keeps it but does not edit it. */
52 readonly signature: ScheduledTaskWebhookSignature | null;
53 /** Minutes as typed; empty runs every held request regardless of age. */
54 readonly maxDeliveryAgeMinutes: string;
55};
56
57export const DEFAULT_SCHEDULE: ScheduleDraft = {
58 mode: "fixed_time",
59 timeOfDay: "09:00",
60 weekdays: [1, 2, 3, 4, 5],
61 intervalMinutes: "15",
62 signature: null,
63 maxDeliveryAgeMinutes: "",
64};
65
66export function scheduleDraftForTask(task: Pick<ScheduledTask, "schedule">): ScheduleDraft {
67 switch (task.schedule.type) {
68 case "fixed_time":
69 return {
70 ...DEFAULT_SCHEDULE,
71 timeOfDay: task.schedule.timeOfDay,
72 weekdays: task.schedule.weekdays?.length
73 ? [...new Set(task.schedule.weekdays)].sort((a, b) => a - b)
74 : [0, 1, 2, 3, 4, 5, 6],
75 };
76 case "interval":
77 return {
78 ...DEFAULT_SCHEDULE,
79 mode: "interval",
80 intervalMinutes: String(Math.max(1, task.schedule.everyMs / 60_000)),
81 };
82 case "webhook":
83 return {
84 ...DEFAULT_SCHEDULE,
85 mode: "webhook",
86 signature: task.schedule.signature,
87 maxDeliveryAgeMinutes:
88 task.schedule.maxDeliveryAgeMinutes == null
89 ? ""
90 : String(task.schedule.maxDeliveryAgeMinutes),
91 };
92 }
93}
94
95export function scheduleFromDraft(draft: ScheduleDraft): ScheduledTaskUpsertSchedule | null {
96 if (draft.mode === "webhook") {
97 const maxDeliveryAgeMinutes = parseMaxDeliveryAge(draft.maxDeliveryAgeMinutes);
98 if (maxDeliveryAgeMinutes === undefined) return null;
99 // No secret is sent, so the server keeps the stored one.
100 return {
101 type: "webhook",
102 signature:
103 draft.signature === null
104 ? null
105 : {
106 header: draft.signature.header,
107 encoding: draft.signature.encoding,
108 prefix: draft.signature.prefix,
109 },
110 maxDeliveryAgeMinutes,
111 };
112 }
113 if (draft.mode === "interval") {
114 const minutes = Number(draft.intervalMinutes);
115 // Undo floating-point noise from displaying existing millisecond intervals as minutes.
116 const everyMs = Math.round(minutes * 60_000);
117 return minutes >= 1 && Number.isSafeInteger(everyMs) ? { type: "interval", everyMs } : null;
118 }
119 const weekdays = [...new Set(draft.weekdays)].sort((a, b) => a - b);
120 if (
121 !/^([01]?\d|2[0-3]):[0-5]\d$/.test(draft.timeOfDay) ||
122 weekdays.length === 0 ||
123 weekdays.some((day) => !Number.isInteger(day) || day < 0 || day > 6)
124 ) {
125 return null;
126 }
127 return {
128 type: "fixed_time",
129 timeOfDay: draft.timeOfDay,
130 ...(weekdays.length === 7 ? {} : { weekdays }),
131 };
132}
133
134type Workspace = "worktree" | "root" | "existing_worktree";
135export type ScheduledTaskDraft = {
136 readonly task: ScheduledTask | null;
137 readonly title: string;
138 readonly prompt: string;
139 readonly projectId: ProjectId | null;
140 readonly modelSelection: ModelSelection | null;
141 readonly modelSelectionIsExplicit: boolean;
142 readonly schedule: ScheduleDraft;
143 readonly workspace: Workspace;
144 readonly baseRef: string;
145 readonly checkoutPath: string;
146 readonly enabled: boolean;
147 readonly startFromOrigin: boolean;
148 readonly runtimeMode: RuntimeMode;
149};
150
151function draftSignature(draft: ScheduledTaskDraft): string {
152 return JSON.stringify([
153 draft.title,
154 draft.prompt,
155 draft.projectId,
156 draft.modelSelection?.instanceId,
157 draft.modelSelection?.model,
158 [...(draft.modelSelection?.options ?? [])]
159 .sort((a, b) => a.id.localeCompare(b.id))
160 .map((option) => [option.id, option.value]),
161 draft.schedule.mode,
162 draft.schedule.timeOfDay,
163 [...draft.schedule.weekdays].sort((a, b) => a - b),
164 draft.schedule.intervalMinutes,
165 draft.schedule.maxDeliveryAgeMinutes,
166 draft.workspace,
167 draft.baseRef,
168 draft.checkoutPath,
169 draft.enabled,
170 draft.startFromOrigin,
171 draft.runtimeMode,
172 ]);
173}
174
175export function hasScheduledTaskDraftChanges(
176 initial: ScheduledTaskDraft,
177 current: ScheduledTaskDraft,
178): boolean {
179 return draftSignature(initial) !== draftSignature(current);
180}
181
182export function createDraft(
183 projectId: ProjectId | null,
184 modelSelection: ModelSelection | null,
185): ScheduledTaskDraft {
186 return {
187 task: null,
188 title: "",
189 prompt: "",
190 projectId,
191 modelSelection,
192 modelSelectionIsExplicit: false,
193 schedule: DEFAULT_SCHEDULE,
194 workspace: "worktree",
195 baseRef: "main",
196 checkoutPath: "",
197 enabled: true,
198 startFromOrigin: true,
199 runtimeMode: "full-access",
200 };
201}
202
203export function editDraft(task: ScheduledTask): ScheduledTaskDraft {
204 return {
205 task,
206 title: task.title,
207 prompt: task.prompt,
208 projectId: task.projectId,
209 modelSelection: task.modelSelection,
210 modelSelectionIsExplicit: true,
211 schedule: scheduleDraftForTask(task),
212 workspace: task.workspaceStrategy.type,
213 baseRef: task.workspaceStrategy.type === "worktree" ? task.workspaceStrategy.baseRef : "main",
214 checkoutPath:
215 task.workspaceStrategy.type === "existing_worktree"
216 ? task.workspaceStrategy.worktreePath
217 : "",
218 enabled: task.enabled,
219 startFromOrigin:
220 task.workspaceStrategy.type === "worktree"
221 ? (task.workspaceStrategy.startFromOrigin ?? false)
222 : true,
223 runtimeMode: task.runtimeMode,
224 };
225}