import * as Sentry from '@sentry/browser' import { useQuery } from '@tanstack/react-query' import { invoke } from '@tauri-apps/api/core' import { initHostStore, useHostStore } from '../store/host' import { usePersistedStore } from '../store/persisted' import type { ScheduledTask } from '../types/schedules' import { LOCAL_HOST_ID } from './hosts' import { type RcRequest, type TaskRequestInput, buildTaskRequests } from './rclone/requests' // Orchestration between the zustand host store (task definitions — the source of truth) and the // Rust OS scheduler (registration reality). Registration is always an upsert, so the startup // reconcile() self-heals deleted OS artifacts, moved app bundles, and restored backups. // // Scheduling is LOCAL-HOST-ONLY: tasks stored under remote hosts stay inert. export interface SchedulerSupport { supported: boolean reason?: string } export interface SchedulerJobSpec { schemaVersion: 1 taskId: string hostId: string name: string operation: ScheduledTask['operation'] cron: string configId: string binary: 'app-default' | string maxRunSeconds: number verboseLogging: boolean runMode: 'system' | 'user' requests: RcRequest[] } export interface SchedulerTaskStatus { taskId: string installed: boolean enabled: boolean running: boolean lastFinished?: { runId: string ts: string success: boolean error?: string durationMs: number jobids?: number[] stats?: { bytes?: number; transfers?: number; errors?: number } /** Synthesized: the run left a started event but no finished one (crash/power loss). */ interrupted?: boolean } /** Backend health warning — installed+enabled but the OS won't fire it (e.g. the macOS * background item was toggled off in System Settings). */ warning?: string } export type SchedulerHistoryLine = | { event: 'started'; runId: string; ts: string; pid: number; hostId: string } | { event: 'finished' runId: string ts: string success: boolean error?: string durationMs: number jobids?: number[] stats?: { bytes?: number; transfers?: number; errors?: number } } | { event: 'skipped'; ts: string; reason: string } /** Max run time bounds, in hours. The wire format (JobSpec.maxRunSeconds) stays in seconds. */ export const DEFAULT_MAX_RUN_HOURS = 24 export const MAX_RUN_HOURS_LIMIT = 120 function clampMaxRunHours(hours: number | undefined): number { if (!Number.isFinite(hours)) { return DEFAULT_MAX_RUN_HOURS } return Math.min(Math.max(Math.round(hours as number), 1), MAX_RUN_HOURS_LIMIT) } let cachedSupport: SchedulerSupport | null = null export async function schedulerSupported(): Promise { if (!cachedSupport) { cachedSupport = await invoke('scheduler_supported') } return cachedSupport } export function useSchedulerSupported() { return useQuery({ queryKey: ['scheduler', 'supported'], queryFn: schedulerSupported, staleTime: Number.POSITIVE_INFINITY, }) } /** * Whether scheduling can actually work here: the current host is the local machine AND the OS * backend reports support. Unresolved support counts as unavailable. Shared by the operation * pages (to gate the Cron section + footer Schedule button) and the footer. */ export function useSchedulingAvailable(): boolean { const currentHostId = usePersistedStore((s) => s.currentHostId) ?? LOCAL_HOST_ID const support = useSchedulerSupported() return currentHostId === LOCAL_HOST_ID && (support.data?.supported ?? false) } export interface CronValidation { valid: boolean error?: string /** Next local fire times (RFC3339), computed by the same Rust matcher the runner uses — * the only preview source that can't disagree with what the OS schedule will do. */ nextRuns: string[] } export async function schedulerValidateCron(cron: string) { return invoke('scheduler_validate_cron', { cron }) } export async function schedulerStatus(hostId: string) { return invoke('scheduler_status', { hostId }) } export async function schedulerReadHistory(taskId: string, limit?: number) { return invoke('scheduler_read_history', { taskId, limit }) } export async function schedulerRunNow(taskId: string) { return invoke('scheduler_run_now', { taskId }) } export async function schedulerReadLog(taskId: string, which: 'runner' | 'daemon') { return invoke<{ content: string; truncated: boolean }>('scheduler_read_log', { taskId, which, }) } function buildJobSpec(task: ScheduledTask): SchedulerJobSpec { return { schemaVersion: 1, taskId: task.id, hostId: LOCAL_HOST_ID, name: task.name ?? task.operation, operation: task.operation, cron: task.cron, configId: task.configId, binary: task.binaryPath, maxRunSeconds: clampMaxRunHours(task.maxRunHours) * 3600, verboseLogging: task.verboseLogging ?? false, runMode: task.runMode ?? 'user', // Pre-serialized here, at save time, by the exact same builders the live start* path // uses — the runner just POSTs them. Throws when the args can't serialize. requests: buildTaskRequests(task), } } async function registerTask(task: ScheduledTask): Promise { const spec = buildJobSpec(task) // One command: the artifact is installed directly in the target enabled state. A separate // set_enabled step used to leave disabled tasks briefly armed (and, when it failed, running // against the user's intent — or flagged as unregistered although active). await invoke('scheduler_register', { spec, enabled: task.isEnabled }) } function isCurrentHostLocal() { return (usePersistedStore.getState().currentHostId ?? LOCAL_HOST_ID) === LOCAL_HOST_ID } function assertLocalHost() { if (!isCurrentHostLocal()) { throw new Error('Scheduling is only available on your local machine') } } async function assertSupported() { const support = await schedulerSupported() if (!support.supported) { throw new Error(support.reason ?? 'Scheduling is not available on this system') } } /** * Creates a task, registers it with the OS scheduler, and returns its id. On registration * failure the task is kept (with the error stored on it) — never silently lost; the startup * reconcile retries. isEnabled always reflects user intent, never system state. */ export async function createScheduledTask(input: { name: string operation: ScheduledTask['operation'] cron: string args: ScheduledTask['args'] /** Defaults to the active config when omitted. */ configId?: string /** Defaults to 'app-default' when omitted. */ binaryPath?: string /** Defaults to 'user' (only runs while logged in) when omitted. */ runMode?: 'system' | 'user' }): Promise { assertLocalHost() await assertSupported() const validation = await schedulerValidateCron(input.cron) if (!validation.valid) { throw new Error(validation.error ?? 'Invalid cron expression') } const hostState = useHostStore.getState() const configId = input.configId ?? hostState.activeConfigId if (!configId) { throw new Error('No active config file') } if (!hostState.configFiles.some((config) => config.id === configId)) { throw new Error('The selected config file no longer exists') } const task = { name: input.name, operation: input.operation, cron: input.cron, args: input.args, isEnabled: true, configId, binaryPath: input.binaryPath ?? 'app-default', runMode: input.runMode ?? 'user', } as Omit // Serialization must succeed before anything persists. (Callers guarantee the operation/args // correlation via useScheduleTask's generic; Omit<> flattens the discriminated union, hence // the cast.) buildTaskRequests({ operation: input.operation, args: input.args } as TaskRequestInput) const id = hostState.addScheduledTask(task) const stored = useHostStore.getState().scheduledTasks.find((t) => t.id === id) if (!stored) { throw new Error('Failed to save the scheduled task') } try { await registerTask(stored) } catch (error) { const registrationError = error instanceof Error ? error.message : String(error) useHostStore.getState().updateScheduledTask(id, { registrationError }) throw new Error( `The schedule was saved but could not be registered with the system: ${registrationError}` ) } return id } /** * Updates a task and re-registers it (upsert). On a remote host this is a store-only edit — * remote tasks are inert in v1 and must never touch the local OS scheduler. */ export async function updateScheduledTask( id: string, patch: Partial ): Promise { if (!isCurrentHostLocal()) { useHostStore.getState().updateScheduledTask(id, patch) return } await assertSupported() if (patch.cron) { const validation = await schedulerValidateCron(patch.cron) if (!validation.valid) { throw new Error(validation.error ?? 'Invalid cron expression') } } const store = useHostStore.getState() store.updateScheduledTask(id, { ...patch, registrationError: undefined }) const merged = useHostStore.getState().scheduledTasks.find((t) => t.id === id) if (!merged) { throw new Error('Task not found') } try { await registerTask(merged) } catch (error) { const registrationError = error instanceof Error ? error.message : String(error) useHostStore.getState().updateScheduledTask(id, { registrationError }) throw new Error(`The task was saved but could not be registered: ${registrationError}`) } } /** * Removes the task. The OS unregister removes the job file even when the OS-level uninstall * fails, so a surviving trigger self-heals on its next fire (the runner finds no job file, * removes the trigger, and exits). Remote-host tasks are store-only. */ export async function removeScheduledTask(id: string): Promise { if (isCurrentHostLocal()) { try { await invoke('scheduler_unregister', { taskId: id, hostId: LOCAL_HOST_ID }) } catch (error) { console.error( '[scheduler] unregister failed; the trigger self-heals on next fire', error ) } } useHostStore.getState().removeScheduledTask(id) } export async function setScheduledTaskEnabled(id: string, enabled: boolean): Promise { const task = useHostStore.getState().scheduledTasks.find((t) => t.id === id) if (!task) { throw new Error('Task not found') } // Remote-host tasks are inert — the toggle is a definition-only edit. if (!isCurrentHostLocal()) { useHostStore.getState().updateScheduledTask(id, { isEnabled: enabled }) return } // Enabling a task whose registration previously failed retries the full registration. if (enabled && task.registrationError) { await updateScheduledTask(id, { isEnabled: true }) return } // Disabling a task whose registration failed: an OS artifact may STILL exist (a failed // edit leaves the previous artifact active; a failed mode flip can leave one in the other // backend). The Rust side sweeps every backend and treats "no artifact anywhere" as // success, so always ask it — a real disable failure must surface rather than leave the // task firing while the UI says paused. if (!enabled && task.registrationError) { await invoke('scheduler_set_enabled', { taskId: id, enabled: false }) useHostStore.getState().updateScheduledTask(id, { isEnabled: false }) return } // OS first, store second — a failed OS call must not leave the UI claiming a state the // scheduler doesn't have. await invoke('scheduler_set_enabled', { taskId: id, enabled }) useHostStore.getState().updateScheduledTask(id, { isEnabled: enabled }) } /** * Startup/host-switch reconciliation — idempotent, runs on EVERY start. Re-registers every * local task (heals exe-path drift, deleted OS artifacts, and performs the one-time migration * registration after the v3 store migrate) and unregisters strays the store no longer knows. */ export async function reconcile(): Promise { const support = await schedulerSupported() if (!support.supported) { console.log('[scheduler] unsupported, skipping reconcile:', support.reason) return } // Never reconcile against a non-local host store: the stray sweep would treat every real // local registration as unknown and destroy it. if (!isCurrentHostLocal()) { console.log('[scheduler] current host is remote, skipping reconcile') return } const taskIds = useHostStore.getState().scheduledTasks.map((task) => task.id) for (const id of taskIds) { // Re-read at registration time: the UI is usable while reconcile runs, so a user edit // mid-loop must win — registering a stale snapshot would silently revert it in the job // file and OS artifact while the store shows the new definition. const task = useHostStore.getState().scheduledTasks.find((t) => t.id === id) if (!task) { continue } try { await registerTask(task) if (task.registrationError) { useHostStore.getState().updateScheduledTask(task.id, { registrationError: undefined, }) } } catch (error) { // registrationError only — isEnabled stays user intent, so a transient failure // (user bus not ready yet, launchctl hiccup) heals on the next reconcile instead of // permanently pausing the task. const registrationError = error instanceof Error ? error.message : String(error) console.error('[scheduler] failed to register task', task.id, registrationError) useHostStore.getState().updateScheduledTask(task.id, { registrationError }) } } // Strays: OS registrations whose task no longer exists in the store. try { const statuses = await schedulerStatus(LOCAL_HOST_ID) const known = new Set(useHostStore.getState().scheduledTasks.map((t) => t.id)) for (const status of statuses) { if (!known.has(status.taskId)) { console.log('[scheduler] unregistering stray task', status.taskId) await invoke('scheduler_unregister', { taskId: status.taskId, hostId: LOCAL_HOST_ID, }) } } } catch (error) { console.error('[scheduler] stray sweep failed', error) } // Artifact-only leftovers: an OS artifact whose job file is gone (a failed uninstall after // the job file was already removed). A DISABLED leftover never fires, so the runner's // fire-time self-heal can never reach it — this sweep is the only cleanup path. Runs last: // the loop above just re-registered every stored task, so their job files protect them. try { const swept = await invoke('scheduler_sweep_orphans') if (swept > 0) { console.log('[scheduler] swept orphaned artifacts:', swept) } } catch (error) { console.error('[scheduler] orphan sweep failed', error) } } let initialized = false /** * Called ONLY from the hidden main window (single writer, like initJobWatcher): reconciles at * startup and again whenever the user switches back to the local host. */ export async function initScheduler(): Promise { if (initialized) { return } initialized = true const isLocal = () => (usePersistedStore.getState().currentHostId ?? LOCAL_HOST_ID) === LOCAL_HOST_ID let lastHostId = usePersistedStore.getState().currentHostId usePersistedStore.subscribe((state) => { if (state.currentHostId !== lastHostId) { lastHostId = state.currentHostId if (isLocal()) { // This window's useHostStore must be re-pointed at the local host BEFORE // reconciling — nothing else in the hidden main window re-inits it on host // switch, and reconciling against a stale (remote) host store would sweep away // every genuine local registration as a stray. initHostStore(LOCAL_HOST_ID) .then(() => reconcile()) .catch((error) => { console.error('[scheduler] reconcile after host switch failed', error) }) } } }) if (!isLocal()) { return } try { await reconcile() } catch (error) { console.error('[scheduler] startup reconcile failed', error) Sentry.captureException(error) } }