1import {
2 EnvironmentId,
3 type ProjectId,
4 ScheduledTaskId,
5 type ScheduledTask,
6 type ScheduledTaskUpsertSchedule,
7 type ModelSelection,
8 type RuntimeMode,
9 type ProviderInteractionMode,
10 type ServerSettings,
11} from "@t3tools/contracts";
12import { parseMaxDeliveryAge } from "@t3tools/client-runtime/scheduled-task-webhook";
13
14import {
15 resolveProjectSettings,
16 type LegacyProjectSettingsFields,
17} from "@t3tools/shared/projectSettings";
18import type { ProviderInstanceEntry } from "../../providerInstances";
19
20import type { ResolvedSettingsScope } from "./settingsScope";
21
22/** Project IDs belong to an environment, including when a grouped project spans machines. */
23export function matchesScheduledTaskScope(
24 scope: ResolvedSettingsScope,
25 environmentId: EnvironmentId,
26 projectId: ProjectId,
27): boolean {
28 if (scope.kind === "unavailable" || !scope.environmentIds.includes(environmentId)) return false;
29 if (scope.kind === "project" || scope.kind === "checkout") {
30 return scope.members.some(
31 (member) => member.environmentId === environmentId && member.id === projectId,
32 );
33 }
34 return true;
35}
36
37export function validateScheduledTasksSearch(raw: Record<string, unknown>) {
38 return {
39 ...(typeof raw.environmentId === "string" && raw.environmentId.trim()
40 ? { environmentId: EnvironmentId.make(raw.environmentId) }
41 : {}),
42 ...(typeof raw.taskId === "string" && raw.taskId.trim()
43 ? { taskId: ScheduledTaskId.make(raw.taskId) }
44 : {}),
45 };
46}
47
48export type ScheduleMode = "fixed" | "interval" | "webhook";
49export type WorkspaceMode = "root" | "worktree" | "existing_worktree";
50
51export interface DraftState {
52 readonly editingId: string | null;
53 readonly title: string;
54 readonly prompt: string;
55 readonly enabled: boolean;
56 readonly scheduleMode: ScheduleMode;
57 readonly intervalMinutes: string;
58 readonly timeOfDay: string;
59 readonly weekdays: ReadonlySet<number>;
60 readonly projectId: string;
61 readonly threadId: string;
62 readonly workspaceMode: WorkspaceMode;
63 readonly baseRef: string;
64 readonly startFromOrigin: boolean;
65 readonly existingWorktreePath: string;
66 readonly modelKey: string;
67 /** Not editable in the dialog, but preserved so editing an agent-created task keeps its modes. */
68 readonly runtimeMode: RuntimeMode;
69 readonly interactionMode: ProviderInteractionMode;
70 /**
71 * The task's original model selection. The picker only edits
72 * `instanceId:model`; keeping the source object preserves provider options
73 * (reasoning, temperature, …) when the model itself is left unchanged.
74 */
75 readonly baseModelSelection: ModelSelection | null;
76 readonly signatureEnabled: boolean;
77 readonly signatureHeader: string;
78 readonly signatureEncoding: "hex" | "base64";
79 readonly signaturePrefix: string;
80 /** Write-only: empty keeps the secret already stored on the server. */
81 readonly signatureSecret: string;
82 /** Minutes as typed; empty runs every held request regardless of age. */
83 readonly maxDeliveryAgeMinutes: string;
84}
85
86/** GitHub's signature settings, the most common sender. */
87export const WEBHOOK_SIGNATURE_DEFAULTS = {
88 signatureHeader: "x-hub-signature-256",
89 signatureEncoding: "hex",
90 signaturePrefix: "sha256=",
91} as const;
92
93/** Null when the draft's webhook age limit is invalid; the caller reports it and does not save. */
94export function scheduleFromDraft(draft: DraftState): ScheduledTaskUpsertSchedule | null {
95 if (draft.scheduleMode === "webhook") {
96 const maxDeliveryAgeMinutes = parseMaxDeliveryAge(draft.maxDeliveryAgeMinutes);
97 if (maxDeliveryAgeMinutes === undefined) return null;
98 const secret = draft.signatureSecret.trim();
99 return {
100 type: "webhook",
101 signature: draft.signatureEnabled
102 ? {
103 header: draft.signatureHeader.trim(),
104 encoding: draft.signatureEncoding,
105 prefix: draft.signaturePrefix,
106 ...(secret ? { secret } : {}),
107 }
108 : null,
109 maxDeliveryAgeMinutes,
110 };
111 }
112 if (draft.scheduleMode === "interval") {
113 const everyMs = Math.round(Number(draft.intervalMinutes) * 60_000);
114 return { type: "interval", everyMs };
115 }
116 const selectedEveryDay = draft.weekdays.size === 0 || draft.weekdays.size === 7;
117 return {
118 type: "fixed_time",
119 timeOfDay: draft.timeOfDay || "09:00",
120 ...(selectedEveryDay ? {} : { weekdays: [...draft.weekdays].toSorted() }),
121 };
122}
123
124export function taskToDraft(task: ScheduledTask): DraftState {
125 const schedule = task.schedule;
126 const weekdays =
127 schedule.type === "fixed_time" && schedule.weekdays && schedule.weekdays.length > 0
128 ? new Set(schedule.weekdays)
129 : new Set([0, 1, 2, 3, 4, 5, 6]);
130 return {
131 editingId: task.id,
132 title: task.title,
133 prompt: task.prompt,
134 enabled: task.enabled,
135 scheduleMode:
136 schedule.type === "interval" ? "interval" : schedule.type === "webhook" ? "webhook" : "fixed",
137 intervalMinutes:
138 schedule.type === "interval" ? String(Math.max(1, schedule.everyMs / 60_000)) : "15",
139 timeOfDay: schedule.type === "fixed_time" ? schedule.timeOfDay : "09:00",
140 weekdays,
141 projectId: task.projectId,
142 threadId: task.threadId ?? "",
143 workspaceMode: task.workspaceStrategy.type,
144 baseRef: task.workspaceStrategy.type === "worktree" ? task.workspaceStrategy.baseRef : "main",
145 startFromOrigin:
146 task.workspaceStrategy.type === "worktree"
147 ? (task.workspaceStrategy.startFromOrigin ?? false)
148 : true,
149 existingWorktreePath:
150 task.workspaceStrategy.type === "existing_worktree"
151 ? task.workspaceStrategy.worktreePath
152 : "",
153 modelKey: `${task.modelSelection.instanceId}:${task.modelSelection.model}`,
154 runtimeMode: task.runtimeMode,
155 interactionMode: task.interactionMode,
156 baseModelSelection: task.modelSelection,
157 ...(schedule.type === "webhook" && schedule.signature !== null
158 ? {
159 signatureEnabled: true,
160 signatureHeader: schedule.signature.header,
161 signatureEncoding: schedule.signature.encoding,
162 signaturePrefix: schedule.signature.prefix,
163 }
164 : { signatureEnabled: false, ...WEBHOOK_SIGNATURE_DEFAULTS }),
165 signatureSecret: "",
166 maxDeliveryAgeMinutes:
167 schedule.type === "webhook" && schedule.maxDeliveryAgeMinutes != null
168 ? String(schedule.maxDeliveryAgeMinutes)
169 : "",
170 };
171}
172
173/** Use configured defaults before the catalog's advertised default model. */
174export function scheduledTaskDefaultModel(
175 settings: ServerSettings,
176 project: (LegacyProjectSettingsFields & { readonly id: ProjectId }) | null,
177 entries: readonly ProviderInstanceEntry[],
178): ModelSelection | null {
179 const available = entries.filter(
180 (entry) =>
181 entry.enabled &&
182 entry.installed &&
183 entry.isAvailable &&
184 entry.snapshot.auth.status !== "unauthenticated",
185 );
186 const configured = resolveProjectSettings(settings, project?.id ?? null, project).settings
187 .defaultModelSelection;
188 for (const selection of [configured, settings.defaultModelSelection]) {
189 if (
190 selection &&
191 available.some(
192 (entry) =>
193 entry.instanceId === selection.instanceId &&
194 entry.models.find((model) => model.slug === selection.model)?.isLegacy !== true,
195 )
196 )
197 return selection;
198 }
199 const models = available.flatMap((entry) =>
200 entry.models
201 .filter((model) => !model.isLegacy)
202 .map((model) => ({ instanceId: entry.instanceId, model })),
203 );
204 const fallback = models.find(({ model }) => model.isDefault) ?? models[0];
205 return fallback ? { instanceId: fallback.instanceId, model: fallback.model.slug } : null;
206}