import { useAtomValue } from "@effect/atom-react"; import { connectionStatusTitle } from "@t3tools/client-runtime/connection"; import * as Option from "effect/Option"; import { AsyncResult } from "effect/reactivity"; import { Clock3Icon, CopyIcon, InboxIcon, MoreHorizontalIcon, PencilIcon, PlayIcon, PlusIcon, Trash2Icon, } from "lucide-react"; import { type ReactNode, useRef, useState } from "react"; import type { EnvironmentId, OrchestrationV2ThreadLaunchWorkspaceStrategy, ProjectId, ScheduledTask, ScheduledTaskId, ScheduledTaskSchedule, ScheduledTaskUpsertInput, ScheduledTaskWebhookDeliverySummary, ThreadId, } from "@t3tools/contracts"; import { DEFAULT_WEBHOOK_PROMPT, emptyWebhookFilterDraft, parseWebhookFilterDrafts, removeWebhookFilterDraft, WEBHOOK_FILTER_OPS, type WebhookFilterDraft, } from "@t3tools/client-runtime/scheduled-task-webhook"; import { MAX_WEBHOOK_DELIVERY_AGE_MINUTES, MAX_WEBHOOK_FILTERS, MIN_SCHEDULED_TASK_INTERVAL_MS, ProviderInstanceId, resolveEnvironmentMachineKind, } from "@t3tools/contracts"; import { createModelSelection } from "@t3tools/shared/model"; import { isAtomCommandInterrupted, squashAtomCommandFailure, } from "@t3tools/client-runtime/state/runtime"; import { formatRelativeTime } from "../../timestampFormat"; import { useEnvironmentSettings } from "../../hooks/useSettings"; import { getCustomModelOptionsByInstance } from "../../modelSelection"; import { applyProviderInstanceSettings, deriveProviderInstanceEntries, sortProviderInstanceEntries, } from "../../providerInstances"; import { usePrimaryCloudLinkState } from "../../cloud/primaryCloudLinkState"; import { requestConfirmDialog } from "../../confirmDialog"; import { webhookAddress } from "@t3tools/client-runtime/webhook-address"; import { Link, useNavigate } from "@tanstack/react-router"; import { useCopyToClipboard } from "../../hooks/useCopyToClipboard"; import { useNowMinute } from "../../hooks/useNowMinute"; import { useEnvironment, useEnvironmentHttpBaseUrl, type EnvironmentPresentation, } from "../../state/environments"; import { useProjects } from "../../state/entities"; import { environmentAtomList, environmentIdsKey } from "../../state/environmentAtomList"; import { formatEnvironmentQueryError, useEnvironmentQuery } from "../../state/query"; import { EMPTY_SERVER_PROVIDERS, serverEnvironment } from "../../state/server"; import { useAtomCommand } from "../../state/use-atom-command"; import { WorktreeBaseBranchPicker } from "../WorktreeBaseBranchPicker"; import { EnvironmentMachineIcon } from "../EnvironmentMachineIcon"; import { ConnectionStatusDot, connectionPhaseDotClassName } from "../ConnectionStatusDot"; import { AuthOrchestrationOperateScope } from "@t3tools/contracts"; import { readEnvironmentScope } from "~/state/session"; import { useSettingsScope } from "./SettingsScopeContext"; import { EnvironmentRow, environmentTransportLabel } from "./EnvironmentRow"; import { SettingsGroup } from "./SettingsGroup"; import { settingsScopeEnvironmentLabel, settingsScopeEnvironmentLabels } from "./settingsScopeAxis"; import { WEBHOOK_SIGNATURE_DEFAULTS, webhookDraftErrors, webhookDeliveryLabel, matchesScheduledTaskScope, scheduleFromDraft, scheduledTaskDefaultModel, taskToDraft, type DraftState, type ScheduleMode, type WorkspaceMode, } from "./scheduledTasksSettings.logic"; import { Label } from "../ui/label"; import { Menu, MenuTrigger, MenuPopup, MenuItem, MenuSeparator } from "../ui/menu"; import { ToggleGroup, Toggle } from "../ui/toggle-group"; import { Empty, EmptyHeader, EmptyTitle, EmptyDescription, EmptyMedia } from "../ui/empty"; import { ProviderModelPicker } from "../chat/ProviderModelPicker"; import { readPromptUltrathink, TraitsPicker } from "../chat/TraitsPicker"; import { Badge } from "../ui/badge"; import { Button } from "../ui/button"; import { Dialog, DialogClose, DialogDescription, DialogFooter, DialogHeader, DialogPanel, DialogPopup, DialogTitle, } from "../ui/dialog"; import { Input } from "../ui/input"; import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select"; import { Switch } from "../ui/switch"; import { Textarea } from "../ui/textarea"; import { stackedThreadToast, toastManager } from "../ui/toast"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; import { SettingsPageContainer, SettingsRow, SettingsSection, SETTINGS_PICKER_TRIGGER_CLASSNAME, } from "./settingsLayout"; const WEEKDAY_ORDER = [1, 2, 3, 4, 5, 6, 0] as const; const WEEKDAY_LABELS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"] as const; const WEEKDAY_SHORT = ["Su", "Mo", "Tu", "We", "Th", "Fr", "Sa"] as const; const WORKSPACE_MODE_LABELS: Record = { worktree: "Create a new worktree", root: "Use the project checkout", existing_worktree: "Use a specific checkout", }; const EMPTY_DRAFT: DraftState = { editingId: null, title: "", prompt: "", enabled: true, scheduleMode: "fixed", intervalMinutes: "15", timeOfDay: "09:00", weekdays: new Set([1, 2, 3, 4, 5]), projectId: "", threadId: "", workspaceMode: "worktree", baseRef: "main", startFromOrigin: true, existingWorktreePath: "", modelSelection: null, runtimeMode: "full-access", interactionMode: "default", signatureEnabled: false, ...WEBHOOK_SIGNATURE_DEFAULTS, signatureSecret: "", maxDeliveryAgeMinutes: "", replayKey: "", coalesceKey: "", coalesceWindowSeconds: "", filters: [], savedFilters: [], }; function WebhookFiltersField({ filters, onChange, }: { filters: ReadonlyArray; onChange: (filters: ReadonlyArray) => void; }) { const update = (index: number, patch: Partial) => onChange(filters.map((filter, at) => (at === index ? { ...filter, ...patch } : filter))); return (

Run only when every filter matches. Other requests are logged as filtered. Separate “is one of” values with commas; a value can't contain a comma.

{filters.map((filter, index) => ( // Always stacked: the dialog stays narrow on wide windows, so a single line // would cut off typical paths and values. The grid wrappers size each control // without restyling it; a grid item stretches to its minmax(0, 1fr) track.
update(index, { path: event.target.value })} />
{filter.op === "exists" ? null : (
update(index, { value: event.target.value })} />
)}
))}
); } function Field({ label, hint, htmlFor, children, }: { label: string; hint?: string; htmlFor?: string; children: ReactNode; }) { return (
{children}
); } export function scheduleLabel(schedule: ScheduledTaskSchedule): string { if (schedule.type === "webhook") return "On webhook"; if (schedule.type === "interval") { const minutes = schedule.everyMs / 60_000; return Number.isInteger(minutes) ? `Every ${minutes} min` : `Every ${Math.round(schedule.everyMs / 1000)} sec`; } const weekdays = schedule.weekdays ?? []; const days = weekdays.length === 0 ? "Daily" : weekdays.length === 5 && weekdays.every((day) => day >= 1 && day <= 5) ? "Weekdays" : weekdays.map((day) => WEEKDAY_LABELS[day]).join(", "); return `${days} at ${schedule.timeOfDay}`; } /** formatRelativeTime handles only the past; upcoming runs need future labels. */ function relativeLabel(value: string | null, nowMs: number): string { if (!value) return "Not scheduled"; const diffMs = new Date(value).getTime() - nowMs; if (diffMs <= 0) { const relative = formatRelativeTime(value, nowMs); if (!relative) return "Not scheduled"; return relative.suffix ? `${relative.value} ${relative.suffix}` : relative.value; } const minutes = Math.ceil(diffMs / 60_000); if (minutes < 2) return "in under a minute"; if (minutes < 60) return `in ${minutes}m`; const hours = Math.round(minutes / 60); if (hours < 24) return `in ${hours}h`; return `in ${Math.round(hours / 24)}d`; } // Use the label's minute tick because the compiler caches render-time Date.now() on the timestamp. export function ScheduledRelativeLabel(props: { value: string | null }) { const nowMinute = useNowMinute(); return relativeLabel(props.value, Date.parse(`${nowMinute}:00.000Z`)); } function deliveryOutcomeVariant(outcome: string) { if (outcome === "accepted") return "success"; if ( outcome === "duplicate" || outcome === "coalesced" || outcome === "disabled" || outcome === "rate_limited" || outcome === "expired" ) { return "warning"; } if (outcome === "dispatch_failed" || outcome === "rejected_signature") return "error"; return "outline"; } function statusVariant(status: ScheduledTask["lastRunStatus"]) { if (status === "failed") return "error"; if (status === "succeeded") return "success"; if (status === "running") return "info"; return "outline"; } export function ScheduledTasksSettings(target: { readonly environmentId?: EnvironmentId; readonly taskId?: ScheduledTaskId | undefined; }) { const { scope, environments, connectedEnvironments, environment } = useSettingsScope(); const [editor, setEditor] = useState<{ environmentId: EnvironmentId; task: ScheduledTask | null; } | null>(null); const openForEdit = (environmentId: EnvironmentId, task: ScheduledTask) => { setEditor({ environmentId, task }); }; const defaultEnvironment = environment ?? connectedEnvironments[0]; const linkedEnvironmentId = target.environmentId ?? defaultEnvironment?.environmentId; const linkedEnvironmentConnected = environments.some( (entry) => entry.environmentId === linkedEnvironmentId && entry.connection.phase === "connected" && entry.serverConfig !== null, ); const linkedTasksQuery = useEnvironmentQuery( target.taskId !== undefined && linkedEnvironmentId !== undefined && linkedEnvironmentConnected ? serverEnvironment.scheduledTasksLive({ environmentId: linkedEnvironmentId, input: {} }) : null, ); const linkedTask = linkedTasksQuery.data?.tasks.find( (task) => task.id === target.taskId && linkedEnvironmentId !== undefined && matchesScheduledTaskScope(scope, linkedEnvironmentId, task.projectId), ); // A `taskId` link opens its editor once, the first time the task loads. The editor then owns // the session until close, so a disconnect that disables the query cannot drop unsaved edits. // An editor the user already opened wins: a late-loading link must not replace its draft. const [openedLinkTaskId, setOpenedLinkTaskId] = useState(); if (target.taskId === undefined && openedLinkTaskId !== undefined) { setOpenedLinkTaskId(undefined); } if (linkedTask && linkedEnvironmentId !== undefined && openedLinkTaskId !== target.taskId) { setOpenedLinkTaskId(target.taskId); if (editor === null) setEditor({ environmentId: linkedEnvironmentId, task: linkedTask }); } const navigate = useNavigate({ from: "/settings/scheduled-tasks" }); const closeEditor = () => { setEditor(null); if (target.taskId === undefined) return; void navigate({ to: "/settings/scheduled-tasks", search: ({ taskId: _closed, ...rest }) => rest, replace: true, }); }; const canCreate = useAtomValue( serverEnvironment.upsertScheduledTask.permissionAtom(defaultEnvironment?.environmentId ?? null), ); return ( defaultEnvironment && setEditor({ environmentId: defaultEnvironment.environmentId, task: null }) } > New task } > {scope.kind === "unavailable" ? ( ) : environments.length === 0 ? ( No environments available Connect an environment to manage scheduled tasks. ) : environments.length > 1 ? ( ) : (
{environments.map((entry) => ( 1} taskId={linkedEnvironmentId === entry.environmentId ? target.taskId : undefined} onEdit={openForEdit} /> ))}
)}
{editor ? ( ) : null}
); } const scheduledTasksListAtom = environmentAtomList("web-scheduled-tasks-list", (environmentId) => serverEnvironment.scheduledTasksLive({ environmentId, input: {} }), ); function ScheduledTaskEnvironmentList({ environmentId, taskId, onEdit, }: { readonly environmentId: EnvironmentId | undefined; readonly taskId: ScheduledTaskId | undefined; readonly onEdit: (environmentId: EnvironmentId, task: ScheduledTask) => void; }) { const { scope, environments } = useSettingsScope(); const connectedIds = environments.flatMap((environment) => environment.connection.phase === "connected" && environment.serverConfig !== null ? [environment.environmentId] : [], ); const queries = useAtomValue(scheduledTasksListAtom(environmentIdsKey(connectedIds))); const states = environments.map((environment) => { const index = connectedIds.indexOf(environment.environmentId); const query = index < 0 ? null : queries[index]!; const data = query === null ? null : Option.getOrNull(AsyncResult.value(query)); return { environment, connected: index >= 0, tasks: data?.tasks.filter((task) => matchesScheduledTaskScope(scope, environment.environmentId, task.projectId), ), error: query?._tag === "Failure" ? formatEnvironmentQueryError(query.cause) : null, }; }); const linkedState = states.find((state) => state.environment.environmentId === environmentId); const linkedTask = linkedState?.tasks?.find((task) => task.id === taskId); const loaded = states.filter((state) => state.connected && state.error === null && state.tasks); const populated = loaded.filter((state) => state.tasks && state.tasks.length > 0); const summaries: Record = { "Not connected:": [], "Loading scheduled tasks on": [], "Could not load scheduled tasks:": [], "No tasks on": [], }; for (const { environment, connected, tasks, error } of states) { const label = settingsScopeEnvironmentLabel(environment, environments); if (!connected) { summaries["Not connected:"]!.push( `${label} (${connectionStatusTitle(environment.connection)})`, ); } else if (error !== null) { summaries["Could not load scheduled tasks:"]!.push(`${label}: ${error}`); } else if (!tasks) { summaries["Loading scheduled tasks on"]!.push(label); } else if (tasks.length === 0 && populated.length > 0) { summaries["No tasks on"]!.push(label); } } return ( {populated.map(({ environment, tasks }) => { const { primary, secondary } = settingsScopeEnvironmentLabels(environment, environments); const transport = environmentTransportLabel(environment); return (
{secondary && secondary !== transport ? ( {secondary} ) : null} {transport} } > {connectionStatusTitle(environment.connection)} {tasks?.map((task) => ( onEdit(environment.environmentId, task)} /> ))}
); })} {taskId && linkedState?.tasks && linkedState.error === null && !linkedTask ? ( ) : null} {populated.length === 0 && loaded.length > 0 ? ( ) : null} {Object.entries(summaries).map(([label, entries]) => { if (entries.length === 0) return null; const summary = `${label} ${entries.join(", ")}`; return ( } > {summary} {summary} ); })}
); } function ScheduledTaskEnvironmentSection({ environment, showEnvironmentHeading, taskId, onEdit, }: { readonly environment: EnvironmentPresentation; readonly showEnvironmentHeading: boolean; readonly taskId?: ScheduledTaskId | undefined; readonly onEdit: (environmentId: EnvironmentId, task: ScheduledTask) => void; }) { const { scope } = useSettingsScope(); const connected = environment.connection.phase === "connected" && environment.serverConfig !== null; const tasksQuery = useEnvironmentQuery( connected ? serverEnvironment.scheduledTasksLive({ environmentId: environment.environmentId, input: {}, }) : null, ); const tasks = tasksQuery.data?.tasks.filter((task) => matchesScheduledTaskScope(scope, environment.environmentId, task.projectId), ); const linkedTask = tasks?.find((task) => task.id === taskId); return ( } > {!connected ? ( ) : tasksQuery.error ? ( ) : !tasks ? ( ) : ( <> {taskId && !linkedTask ? ( ) : null} {tasks.length === 0 ? ( ) : ( tasks.map((task) => ( onEdit(environment.environmentId, task)} /> )) )} )} ); } function ScheduledTaskRow({ environmentId, task, onEdit, }: { readonly environmentId: EnvironmentId; readonly task: ScheduledTask; readonly onEdit: () => void; }) { const canOperate = useAtomValue( serverEnvironment.upsertScheduledTask.permissionAtom(environmentId), ); const [busy, setBusy] = useState(false); const [deliveriesOpen, setDeliveriesOpen] = useState(false); const isWebhook = task.schedule.type === "webhook"; const toggle = useAtomCommand(serverEnvironment.setScheduledTaskEnabled, { label: "scheduled task enabled", }); const run = useAtomCommand(serverEnvironment.runScheduledTaskNow, { label: "scheduled task run now", }); const remove = useAtomCommand(serverEnvironment.deleteScheduledTask, { label: "scheduled task delete", }); const act = async (action: "toggle" | "run" | "delete") => { if (busy || !readEnvironmentScope(environmentId, AuthOrchestrationOperateScope)) return; setBusy(true); const result = action === "toggle" ? await toggle({ environmentId, input: { id: task.id, enabled: !task.enabled } }) : action === "run" ? await run({ environmentId, input: { id: task.id } }) : await remove({ environmentId, input: { id: task.id } }); setBusy(false); if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { toastManager.add( stackedThreadToast({ type: "error", title: "Could not update scheduled task", description: String(squashAtomCommandFailure(result)), }), ); } }; return ( {task.prompt}} status={
{scheduleLabel(task.schedule)} ·{" "} {!task.enabled ? ( "Paused" ) : isWebhook ? ( "Listening" ) : task.nextRunAt ? ( <> Next run ) : ( "Not scheduled" )} {task.lastRunStatus !== "never" ? ( {task.lastRunStatus} ) : null} {task.lastRunError ? {task.lastRunError} : null}
} control={
void act("toggle")} /> } > Edit {isWebhook ? ( setDeliveriesOpen(true)}> Deliveries ) : ( void act("run")}> Run now )} void act("delete")}> Delete {deliveriesOpen ? ( setDeliveriesOpen(false)} /> ) : null}
} /> ); } function WebhookDeliveriesDialog({ environmentId, task, onClose, }: { readonly environmentId: EnvironmentId; readonly task: ScheduledTask; readonly onClose: () => void; }) { const deliveriesQuery = useEnvironmentQuery( serverEnvironment.scheduledTaskWebhookDeliveries({ environmentId, input: { id: task.id, acceptAnyOutcome: true }, }), ); const [selectedId, setSelectedId] = useState( null, ); const selectedQuery = useEnvironmentQuery( selectedId === null ? null : serverEnvironment.scheduledTaskWebhookDelivery({ environmentId, input: { id: task.id, deliveryId: selectedId, acceptAnyOutcome: true }, }), ); const deliveries = deliveriesQuery.data?.deliveries ?? null; const selected = selectedQuery.data?.delivery ?? null; const error = selectedId === null ? deliveriesQuery.error : selectedQuery.error; return ( { if (!next) onClose(); }} > Deliveries · {task.title} Recent requests to this task's webhook URL. {error ?

{error}

: null} {selectedId !== null && selected === null && selectedQuery.error === null ? (

Loading delivery…

) : selected ? (
{webhookDeliveryLabel(selected)} {selected.method} · {selected.signatureVerified ? ( Signature verified ) : null}
{selected.error ? (

{selected.error}

) : null} {selected.renderedPrompt ?? (selected.pendingWindow ? "Run scheduled when the window closes; no run started yet." : "No run was started for this request.")} {selected.missingFields.length > 0 ? (

Empty placeholders: {selected.missingFields.join(", ")}

) : null} {Object.entries(selected.headers) .map(([name, value]) => `${name}: ${value}`) .join("\n")} {selected.query ? ( {selected.query} ) : null} {selected.body || "(empty)"}
) : selectedId !== null ? null : deliveries === null ? (

Loading deliveries…

) : deliveries.length === 0 ? (

No requests yet.

) : (
    {deliveries.map((delivery) => (
  • ))}
)}
{selectedId !== null ? ( ) : ( )} }>Done
); } function DeliveryBlock({ title, children }: { title: string; children: ReactNode }) { return (
        {children}
      
); } function WebhookEndpointField({ environmentId, task, }: { readonly environmentId: EnvironmentId; readonly task: ScheduledTask | null; }) { const canRotate = useAtomValue( serverEnvironment.rotateScheduledTaskWebhookToken.permissionAtom(environmentId), ); const httpBaseUrl = useEnvironmentHttpBaseUrl(environmentId); const { copyToClipboard, isCopied } = useCopyToClipboard({ target: "webhook URL" }); const rotate = useAtomCommand(serverEnvironment.rotateScheduledTaskWebhookToken, { label: "scheduled task rotate webhook token", }); const [rotating, setRotating] = useState(false); const endpoint = task?.webhook; if (!task || !endpoint) { return

The URL appears after you save.

; } const { address: url, copyable, note } = webhookAddress(endpoint, httpBaseUrl); const rotateUrl = async () => { const confirmed = (await requestConfirmDialog("Rotate this webhook URL?\nThe current URL stops working.", { variant: "destructive", })) ?? window.confirm("Rotate this webhook URL? The current URL stops working."); if (!confirmed) return; setRotating(true); const result = await rotate({ environmentId, input: { id: task.id } }); setRotating(false); if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { toastManager.add( stackedThreadToast({ type: "error", title: "Could not rotate webhook URL", description: String(squashAtomCommandFailure(result)), }), ); } }; return (
event.currentTarget.select()} />
{note !== null ?

{note}

: null} {endpoint.url !== null ? : null}
); } function WebhookDeliveryMode({ environmentId }: { readonly environmentId: EnvironmentId }) { const cloudLink = usePrimaryCloudLinkState(); if (cloudLink.target?.environmentId !== environmentId || cloudLink.data === null) return null; return (

{cloudLink.data.holdWebhooksWhileOffline ? "Held for up to 24 hours while this environment is offline. " : "Forwarded live. Requests fail while this environment is offline. "} Change in Sharing & access

); } function ScheduledTaskEditorDialog({ initialEnvironmentId, task, onClose, }: { readonly initialEnvironmentId: EnvironmentId; readonly task: ScheduledTask | null; readonly onClose: () => void; }) { const [saving, setSaving] = useState(false); return ( { if (!open && !saving) onClose(); }} > {/* The form owns the draft so a keystroke re-renders it, not the dialog chrome. */} ); } function ScheduledTaskEditorForm({ initialEnvironmentId, task, saving, onSavingChange: setSaving, onClose, }: { readonly initialEnvironmentId: EnvironmentId; readonly task: ScheduledTask | null; readonly saving: boolean; readonly onSavingChange: (saving: boolean) => void; readonly onClose: () => void; }) { const { scope, connectedEnvironments } = useSettingsScope(); const [environmentId, setEnvironmentId] = useState(initialEnvironmentId); const environment = useEnvironment(environmentId); const connected = environment?.connection.phase === "connected" && environment.serverConfig !== null; const tasksQuery = useEnvironmentQuery( connected ? serverEnvironment.scheduledTasksLive({ environmentId, input: {} }) : null, ); const allProjects = useProjects(); const projects = allProjects.filter( (project) => project.environmentId === environmentId && matchesScheduledTaskScope(scope, environmentId, project.id), ); const settings = useEnvironmentSettings(environmentId); const providers = useAtomValue(serverEnvironment.providersValueAtom(environmentId)) ?? EMPTY_SERVER_PROVIDERS; const canOperate = useAtomValue( serverEnvironment.upsertScheduledTask.permissionAtom(environmentId), ); const upsertTask = useAtomCommand(serverEnvironment.upsertScheduledTask, { label: "scheduled task upsert", }); const [draft, setDraft] = useState(() => task ? taskToDraft(task) : { ...EMPTY_DRAFT, projectId: projects[0]?.id ?? "" }, ); const submissionPending = useRef(false); const editingTaskMissing = draft.editingId !== null && tasksQuery.data !== null && !tasksQuery.data.tasks.some((entry) => entry.id === draft.editingId); const liveTask = tasksQuery.data ? (tasksQuery.data.tasks.find((entry) => entry.id === draft.editingId) ?? null) : task; const selectedProjectId = draft.projectId || projects[0]?.id || ""; const selectedProject = projects.find((project) => project.id === selectedProjectId); const instanceEntries = sortProviderInstanceEntries( applyProviderInstanceSettings( deriveProviderInstanceEntries(providers), settings, selectedProject?.id ?? null, ), ); const firstInstance = instanceEntries[0]; const draftSelection = draft.modelSelection; const activeSelection = draftSelection && instanceEntries.some((entry) => entry.instanceId === draftSelection.instanceId && entry.enabled) ? draftSelection : scheduledTaskDefaultModel(settings, selectedProject ?? null, instanceEntries); const activeEntry = instanceEntries.find( (entry) => entry.instanceId === activeSelection?.instanceId, ); const activeInstanceId = activeSelection?.instanceId ?? firstInstance?.instanceId ?? ("" as ProviderInstanceId); const activeModel = activeSelection?.model ?? ""; const modelOptionsByInstance = getCustomModelOptionsByInstance( settings, providers, activeInstanceId, activeModel, ); const reportFailure = (title: string, error: unknown) => { toastManager.add( stackedThreadToast({ type: "error", title, description: error instanceof Error ? error.message : String(error), }), ); }; const submit = async () => { if ( !readEnvironmentScope(environmentId, AuthOrchestrationOperateScope) || submissionPending.current || saving || editingTaskMissing || !connected || tasksQuery.data === null ) return; const selection = activeSelection; if ( !draft.title.trim() || !draft.prompt.trim() || !projects.some((project) => project.id === selectedProjectId) || selection === null ) { reportFailure("Scheduled task is incomplete", "Add a title, prompt, project, and model."); return; } const parsedFilters = parseWebhookFilterDrafts(draft.filters); if (draft.scheduleMode === "webhook" && "error" in parsedFilters) { reportFailure("Invalid filter", parsedFilters.error); return; } const schedule = scheduleFromDraft(draft); if (schedule === null) { for (const error of webhookDraftErrors(draft)) reportFailure(error.title, error.description); return; } if ( schedule.type === "webhook" && schedule.signature && (!schedule.signature.header || (!schedule.signature.secret && !(liveTask?.schedule.type === "webhook" && liveTask.webhook?.hasSecret))) ) { reportFailure("Signing secret is required", "Enter the signature header and secret."); return; } if ( schedule.type === "interval" && (!Number.isSafeInteger(schedule.everyMs) || schedule.everyMs < MIN_SCHEDULED_TASK_INTERVAL_MS) ) { reportFailure("Invalid interval", "Enter an interval of at least one minute."); return; } if (draft.workspaceMode === "existing_worktree" && !draft.existingWorktreePath.trim()) { reportFailure("Checkout path is required", "Enter the path of the checkout to run in."); return; } const workspaceStrategy: OrchestrationV2ThreadLaunchWorkspaceStrategy = draft.workspaceMode === "root" ? { type: "root" } : draft.workspaceMode === "existing_worktree" ? { type: "existing_worktree", worktreePath: draft.existingWorktreePath.trim() } : { type: "worktree", baseRef: draft.baseRef.trim() || "main", startFromOrigin: draft.startFromOrigin, }; const input: ScheduledTaskUpsertInput = { ...(draft.editingId ? { id: draft.editingId as ScheduledTaskId, requireExisting: true } : {}), title: draft.title.trim(), prompt: draft.prompt.trim(), enabled: draft.enabled, schedule, projectId: selectedProjectId as ProjectId, threadId: draft.threadId ? (draft.threadId as ThreadId) : null, workspaceStrategy, modelSelection: selection, runtimeMode: draft.runtimeMode, interactionMode: draft.interactionMode, creationSource: "web", }; // Lock before React renders, and keep successful creates locked until the form closes. submissionPending.current = true; setSaving(true); const result = await upsertTask({ environmentId, input }); setSaving(false); if (result._tag === "Failure") { submissionPending.current = false; if (!isAtomCommandInterrupted(result)) { reportFailure("Could not save scheduled task", squashAtomCommandFailure(result)); } return; } onClose(); }; return ( <> {draft.editingId ? "Edit task" : "New task"} Run a prompt automatically on an interval, at a fixed time, or when a webhook is called. {/* Fieldsets default to min-width: min-content, which lets one wide control widen the form past the dialog. */}
{!connected ? (

Reconnect this environment before saving.

) : null} {tasksQuery.error ? (

{tasksQuery.error}

) : null} {editingTaskMissing ? (

This scheduled task no longer exists.

) : null} setDraft((current) => ({ ...current, title: event.target.value })) } />
{draft.workspaceMode === "worktree" ? ( setDraft((current) => ({ ...current, baseRef }))} startFromOrigin={draft.startFromOrigin} onStartFromOriginChange={(startFromOrigin) => setDraft((current) => ({ ...current, startFromOrigin })) } disabled={saving || !connected} /> ) : null} {draft.workspaceMode === "existing_worktree" ? ( setDraft((current) => ({ ...current, existingWorktreePath: event.target.value, })) } /> ) : null}