| 1 | import { |
| 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"; |
| 12 | import { parseMaxDeliveryAge } from "@t3tools/client-runtime/scheduled-task-webhook"; |
| 13 | |
| 14 | import { |
| 15 | resolveProjectSettings, |
| 16 | type LegacyProjectSettingsFields, |
| 17 | } from "@t3tools/shared/projectSettings"; |
| 18 | import type { ProviderInstanceEntry } from "../../providerInstances"; |
| 19 | |
| 20 | import type { ResolvedSettingsScope } from "./settingsScope"; |
| 21 | |
| 22 | /** Project IDs belong to an environment, including when a grouped project spans machines. */ |
| 23 | export 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 | |
| 37 | export 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 | |
| 48 | export type ScheduleMode = "fixed" | "interval" | "webhook"; |
| 49 | export type WorkspaceMode = "root" | "worktree" | "existing_worktree"; |
| 50 | |
| 51 | export 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. */ |
| 87 | export 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. */ |
| 94 | export 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 | |
| 124 | export 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. */ |
| 174 | export 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 | } |