diff --git a/apps/emdash-desktop/package.json b/apps/emdash-desktop/package.json index 03d7c6ff79..0e39fb36b1 100644 --- a/apps/emdash-desktop/package.json +++ b/apps/emdash-desktop/package.json @@ -77,6 +77,7 @@ "nbranch": "^0.1.1", "node-pty": "1.1.0", "pidusage": "^4.0.1", + "rrule": "^2.8.1", "smol-toml": "^1.6.0", "ssh2": "^1.17.0", "ts-pattern": "^5.9.0", diff --git a/apps/emdash-desktop/src/main/core/automations/automation-scheduler.db.test.ts b/apps/emdash-desktop/src/main/core/automations/automation-scheduler.db.test.ts index 62b28c8cae..eccb8381ae 100644 --- a/apps/emdash-desktop/src/main/core/automations/automation-scheduler.db.test.ts +++ b/apps/emdash-desktop/src/main/core/automations/automation-scheduler.db.test.ts @@ -294,6 +294,28 @@ describe('AutomationScheduler bootstrap self-healing', () => { expect(countRunsByStatus(fixture, 'scheduled', 'automation-1')).toBe(1); }); + it('schedules an RRULE run using the stored DTSTART anchor', async () => { + const now = Date.UTC(2026, 6, 6, 9, 0, 1); + vi.setSystemTime(now); + + seedProject(fixture); + seedAutomation(fixture, { + triggerConfig: JSON.stringify({ + kind: 'rrule', + expr: 'DTSTART;TZID=UTC:20260701T090000\nRRULE:FREQ=WEEKLY;BYDAY=MO', + tz: 'UTC', + }), + }); + + const scheduler = new AutomationScheduler(makeCallbacks(), doneExecutor); + await scheduler.reload(); + + const row = fixture.sqlite + .prepare('SELECT scheduled_at AS scheduledAt FROM automation_runs WHERE automation_id = ?') + .get('automation-1') as { scheduledAt: number } | undefined; + expect(row?.scheduledAt).toBe(Date.UTC(2026, 6, 13, 9, 0, 0)); + }); + it('does not create a duplicate when a scheduled run already exists', async () => { const now = Date.UTC(2026, 4, 15, 12, 0, 0); vi.setSystemTime(now); diff --git a/apps/emdash-desktop/src/main/core/automations/repo.ts b/apps/emdash-desktop/src/main/core/automations/repo.ts index 45d3b06ed7..14f99db503 100644 --- a/apps/emdash-desktop/src/main/core/automations/repo.ts +++ b/apps/emdash-desktop/src/main/core/automations/repo.ts @@ -1,5 +1,4 @@ import { randomUUID } from 'node:crypto'; -import { Cron } from 'croner'; import { and, asc, eq, inArray, isNull, sql } from 'drizzle-orm'; import { generateRandom } from '@main/core/tasks/name-generation/generateTaskName'; import { db } from '@main/db/client'; @@ -29,16 +28,17 @@ import type { TriggerConfig, } from '@shared/core/automations/config'; import { storedAutomationTaskConfig } from '@shared/core/automations/config'; -import { getLocalTimeZone } from '@shared/core/automations/timezone'; -import { assertValidCronTrigger } from '@shared/core/automations/validation'; - -const DEFAULT_TZ = getLocalTimeZone(); +import { + assertValidTrigger, + getNextTriggerRunAt, + normalizeTriggerConfig, +} from '@shared/core/automations/validation'; function assertValidAutomationInput(input: { triggerConfig: TriggerConfig; conversationConfig: ConversationConfig; }): void { - assertValidCronTrigger(input.triggerConfig); + assertValidTrigger(input.triggerConfig); if (!input.conversationConfig.prompt.trim()) { throw new Error('conversation_config_prompt_required'); } @@ -196,10 +196,7 @@ export function getNextRunAt( trigger: TriggerConfig, from: number | Date = new Date() ): number | null { - const next = new Cron(trigger.expr, { timezone: trigger.tz || DEFAULT_TZ }).nextRun( - from instanceof Date ? from : new Date(from) - ); - return next?.getTime() ?? null; + return getNextTriggerRunAt(trigger, from); } export async function listAutomations(projectId?: string): Promise { @@ -255,18 +252,18 @@ export async function createAutomation(input: CreateAutomationParams): Promise = { updatedAt: Date.now() }; if (patch.projectId !== undefined) values.projectId = patch.projectId; if (patch.triggerConfig !== undefined) { - values.triggerConfig = patch.triggerConfig; + values.triggerConfig = normalizedPatchTriggerConfig; } if (patch.conversationConfig !== undefined) { values.conversationConfig = patch.conversationConfig; diff --git a/apps/emdash-desktop/src/main/core/telemetry/automation-telemetry.ts b/apps/emdash-desktop/src/main/core/telemetry/automation-telemetry.ts index 7f6bd2edaf..bed6ba1aac 100644 --- a/apps/emdash-desktop/src/main/core/telemetry/automation-telemetry.ts +++ b/apps/emdash-desktop/src/main/core/telemetry/automation-telemetry.ts @@ -15,7 +15,7 @@ function automationTelemetryProps(automation: Automation) { return { automation_id: automation.id, project_id: automation.projectId, - trigger_kind: 'cron' as const, + trigger_kind: automation.triggerConfig?.kind ?? 'cron', }; } diff --git a/apps/emdash-desktop/src/renderer/features/automations/automation-run-format.ts b/apps/emdash-desktop/src/renderer/features/automations/automation-run-format.ts index 4fb60ec4b2..70e0dd228f 100644 --- a/apps/emdash-desktop/src/renderer/features/automations/automation-run-format.ts +++ b/apps/emdash-desktop/src/renderer/features/automations/automation-run-format.ts @@ -64,6 +64,7 @@ const FORM_ERROR_MESSAGES: Record = { automation_run_already_queued: 'This automation already has a queued or running run', automation_run_not_found: 'This automation run no longer exists', cron_invalid: 'Enter a valid schedule', + rrule_invalid: 'Enter a valid RRULE schedule', deadline_policy_invalid: 'Choose a valid deadline policy', deadline_ms_invalid: 'Choose a positive deadline duration', run_update_failed: 'The automation run could not be updated', diff --git a/apps/emdash-desktop/src/renderer/features/automations/components/AutomationDetailView.tsx b/apps/emdash-desktop/src/renderer/features/automations/components/AutomationDetailView.tsx index 12606b020b..4c56db9888 100644 --- a/apps/emdash-desktop/src/renderer/features/automations/components/AutomationDetailView.tsx +++ b/apps/emdash-desktop/src/renderer/features/automations/components/AutomationDetailView.tsx @@ -48,8 +48,15 @@ export const AutomationDetailView = observer(function AutomationDetailView({ useAutomationEventBridge(automation.id); - const { formState, setCronExpr, handlePromptBlur, handleNameBlur, saveError } = - useAutomationSettingsAutoSave(automation); + const { + formState, + setCronExpr, + saveCurrentTrigger, + saveTriggerKind, + handlePromptBlur, + handleNameBlur, + saveError, + } = useAutomationSettingsAutoSave(automation); const { name, setName } = formState; const { runNow } = useAutomations(); @@ -131,6 +138,8 @@ export const AutomationDetailView = observer(function AutomationDetailView({ setCronError(null); }} onCronErrorClear={() => setCronError(null)} + onTriggerKindChange={saveTriggerKind} + onRRuleExprBlur={saveCurrentTrigger} onPromptBlur={handlePromptBlur} error={saveError} /> diff --git a/apps/emdash-desktop/src/renderer/features/automations/components/AutomationRow.tsx b/apps/emdash-desktop/src/renderer/features/automations/components/AutomationRow.tsx index c8b9e00dac..512831fe0a 100644 --- a/apps/emdash-desktop/src/renderer/features/automations/components/AutomationRow.tsx +++ b/apps/emdash-desktop/src/renderer/features/automations/components/AutomationRow.tsx @@ -24,6 +24,7 @@ import { Switch } from '@renderer/lib/ui/switch'; import { cn } from '@renderer/utils/utils'; import type { Automation } from '@shared/core/automations/automation'; import type { AutomationRunStatus } from '@shared/core/automations/automation-run'; +import { formatTriggerScheduleLabel } from '@shared/core/automations/validation'; import { formatRunTriggerKindLabel } from '../automation-run-format'; const RUN_STATUS_ICON: Record< @@ -62,13 +63,16 @@ export const AutomationRow = observer(function AutomationRow({ const taskStore = taskId && projectId ? getTaskStore(projectId, taskId) : undefined; const agentStatus = taskStore ? taskAgentStatus(taskStore) : null; - const expr = automation.triggerConfig?.expr ?? null; - const cronLabel = expr + const trigger = automation.triggerConfig ?? null; + const scheduleLabel = trigger ? (() => { + if ((trigger.kind ?? 'cron') === 'rrule') { + return formatTriggerScheduleLabel(trigger); + } try { - return cronstrue.toString(expr.trim()); + return cronstrue.toString(trigger.expr.trim()); } catch { - return expr; + return trigger.expr; } })() : null; @@ -106,10 +110,10 @@ export const AutomationRow = observer(function AutomationRow({
- {cronLabel && ( + {scheduleLabel && ( - {cronLabel} + {scheduleLabel} )}
diff --git a/apps/emdash-desktop/src/renderer/features/automations/components/AutomationSettingsFields.tsx b/apps/emdash-desktop/src/renderer/features/automations/components/AutomationSettingsFields.tsx index 4fbf3c823f..06da06e399 100644 --- a/apps/emdash-desktop/src/renderer/features/automations/components/AutomationSettingsFields.tsx +++ b/apps/emdash-desktop/src/renderer/features/automations/components/AutomationSettingsFields.tsx @@ -7,6 +7,7 @@ import { WorkspaceSettingsSection } from '@renderer/features/tasks/task-config/w import { CronPicker } from '@renderer/lib/CronPicker'; import { useFeatureFlag } from '@renderer/lib/hooks/useFeatureFlag'; import { Field, FieldError, FieldGroup } from '@renderer/lib/ui/field'; +import { Input } from '@renderer/lib/ui/input'; import { Label } from '@renderer/lib/ui/label'; import type { AutomationFormState } from '../useAutomationFormState'; @@ -15,6 +16,8 @@ interface AutomationSettingsFieldsProps { cronError: string | null; onCronExprChange: (expr: string) => void; onCronErrorClear: () => void; + onTriggerKindChange?: (kind: NonNullable) => void; + onRRuleExprBlur?: () => void; onPromptBlur?: () => void; error?: string | null; } @@ -24,12 +27,18 @@ export function AutomationSettingsFields({ cronError, onCronExprChange, onCronErrorClear, + onTriggerKindChange, + onRRuleExprBlur, onPromptBlur, error, }: AutomationSettingsFieldsProps) { const { initialConversation, + triggerKind, + setTriggerKind, cronExpr, + rruleExpr, + setRRuleExpr, workspaceConfig, effectiveProjectId, isUnborn, @@ -53,9 +62,31 @@ export function AutomationSettingsFields({ { + setTriggerKind('cron'); onCronExprChange(nextCronExpr); onCronErrorClear(); }} + custom={{ + selected: (triggerKind ?? 'cron') === 'rrule', + onSelect: () => { + setTriggerKind('rrule'); + onCronErrorClear(); + onTriggerKindChange?.('rrule'); + }, + content: ( + { + setRRuleExpr(event.target.value); + onCronErrorClear(); + }} + onBlur={onRRuleExprBlur} + spellCheck={false} + className="h-7 min-w-0 flex-1 font-mono text-xs" + placeholder="FREQ=WEEKLY" + /> + ), + }} /> {cronError && {cronError}} diff --git a/apps/emdash-desktop/src/renderer/features/automations/components/CreateAutomationView.tsx b/apps/emdash-desktop/src/renderer/features/automations/components/CreateAutomationView.tsx index 29bfc379e0..97f1cc30ec 100644 --- a/apps/emdash-desktop/src/renderer/features/automations/components/CreateAutomationView.tsx +++ b/apps/emdash-desktop/src/renderer/features/automations/components/CreateAutomationView.tsx @@ -12,7 +12,7 @@ import { Label } from '@renderer/lib/ui/label'; import { SheetFooter } from '@renderer/lib/ui/sheet'; import type { Automation } from '@shared/core/automations/automation'; import type { ConversationConfig } from '@shared/core/automations/config'; -import { assertValidCronTrigger } from '@shared/core/automations/validation'; +import { assertValidTrigger, normalizeTriggerConfig } from '@shared/core/automations/validation'; import { formatAutomationError } from '../automation-run-format'; import type { BuiltinAutomationTemplate } from '../automation-template'; import { emptyStateAutomationTemplates } from '../builtin-catalog'; @@ -64,8 +64,9 @@ export const CreateAutomationView = observer(function CreateAutomationView({ setError(null); const taskConfig = buildTaskConfig(effectiveProjectId); if (!taskConfig) return; + const normalizedTriggerConfig = normalizeTriggerConfig(triggerConfig); try { - assertValidCronTrigger(triggerConfig); + assertValidTrigger(normalizedTriggerConfig); } catch (validationError) { setCronError(formatAutomationError(validationError)); return; @@ -83,7 +84,7 @@ export const CreateAutomationView = observer(function CreateAutomationView({ const trimmedName = name.trim(); const saved = await create.mutateAsync({ name: trimmedName, - triggerConfig, + triggerConfig: normalizedTriggerConfig, conversationConfig, taskConfig, projectId: effectiveProjectId, diff --git a/apps/emdash-desktop/src/renderer/features/automations/rrule-form-utils.test.ts b/apps/emdash-desktop/src/renderer/features/automations/rrule-form-utils.test.ts new file mode 100644 index 0000000000..2b39bf835d --- /dev/null +++ b/apps/emdash-desktop/src/renderer/features/automations/rrule-form-utils.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from 'vitest'; +import { buildRRuleTriggerExpr } from './rrule-form-utils'; + +describe('buildRRuleTriggerExpr', () => { + it('preserves the stored DTSTART when saving an edited RRULE line', () => { + expect( + buildRRuleTriggerExpr( + 'FREQ=WEEKLY;BYDAY=MO', + 'DTSTART;TZID=Europe/Berlin:20260706T090000\nRRULE:FREQ=WEEKLY;BYDAY=MO' + ) + ).toBe('DTSTART;TZID=Europe/Berlin:20260706T090000\nRRULE:FREQ=WEEKLY;BYDAY=MO'); + }); + + it('keeps full RRULE input with its own DTSTART unchanged', () => { + expect( + buildRRuleTriggerExpr( + 'DTSTART;TZID=UTC:20260706T120000\nRRULE:FREQ=WEEKLY;BYDAY=MO', + 'DTSTART;TZID=Europe/Berlin:20260706T090000\nRRULE:FREQ=WEEKLY;BYDAY=MO' + ) + ).toBe('DTSTART;TZID=UTC:20260706T120000\nRRULE:FREQ=WEEKLY;BYDAY=MO'); + }); +}); diff --git a/apps/emdash-desktop/src/renderer/features/automations/rrule-form-utils.ts b/apps/emdash-desktop/src/renderer/features/automations/rrule-form-utils.ts new file mode 100644 index 0000000000..6bc6ea0532 --- /dev/null +++ b/apps/emdash-desktop/src/renderer/features/automations/rrule-form-utils.ts @@ -0,0 +1,20 @@ +export function getStoredDtstartLine(expr: string): string | null { + return expr.split('\n').find((line) => /^DTSTART(?:;[^:]*)?:/i.test(line)) ?? null; +} + +export function getEditableRRuleExpr(expr: string): string { + const rruleLine = expr.split('\n').find((line) => /^RRULE:/i.test(line)); + return rruleLine ? rruleLine.replace(/^RRULE:/i, '') : expr; +} + +function withRRulePrefix(expr: string): string { + return /^RRULE\b/im.test(expr) ? expr : `RRULE:${expr}`; +} + +export function buildRRuleTriggerExpr(expr: string, storedExpr?: string): string { + const trimmed = expr.trim(); + if (/^DTSTART(?:;[^:]*)?:/im.test(trimmed)) return trimmed; + + const dtstartLine = storedExpr ? getStoredDtstartLine(storedExpr) : null; + return dtstartLine ? `${dtstartLine}\n${withRRulePrefix(trimmed)}` : trimmed; +} diff --git a/apps/emdash-desktop/src/renderer/features/automations/useAutomationFormState.ts b/apps/emdash-desktop/src/renderer/features/automations/useAutomationFormState.ts index 597a26b25e..5b06f6d5bb 100644 --- a/apps/emdash-desktop/src/renderer/features/automations/useAutomationFormState.ts +++ b/apps/emdash-desktop/src/renderer/features/automations/useAutomationFormState.ts @@ -18,8 +18,10 @@ import { type WorkspaceConfigInitial, } from '../tasks/create-task-modal/use-workspace-config'; import type { BuiltinAutomationTemplate } from './automation-template'; +import { buildRRuleTriggerExpr, getEditableRRuleExpr } from './rrule-form-utils'; const DEFAULT_CRON = toCron(DEFAULT_CRON_STATE); +const DEFAULT_RRULE = 'FREQ=WEEKLY'; /** * Derives the initial workspace config state for seeding the form from a stored automation. @@ -74,9 +76,19 @@ export function useAutomationFormState( const [projectId, setProjectId] = useState( seed?.projectId ?? firstMountedProjectId() ); + const [triggerKind, setTriggerKind] = useState( + seedTrigger?.kind ?? initialTemplate?.defaultTrigger.kind ?? 'cron' + ); const [cronExpr, setCronExpr] = useState( - seedTrigger?.expr ?? initialTemplate?.defaultTrigger.expr ?? DEFAULT_CRON + seedTrigger?.kind === 'rrule' + ? DEFAULT_CRON + : (seedTrigger?.expr ?? initialTemplate?.defaultTrigger.expr ?? DEFAULT_CRON) ); + const [rruleExpr, setRRuleExpr] = useState(() => { + if (seedTrigger?.kind !== 'rrule') return DEFAULT_RRULE; + + return getEditableRRuleExpr(seedTrigger.expr); + }); const [cronTz] = useState(seedTrigger?.tz ?? getLocalTimeZone()); const effectiveProjectId = @@ -181,11 +193,26 @@ export function useAutomationFormState( return JSON.parse(JSON.stringify(result)) as StoredAutomationTaskConfig; } - const triggerConfig: TriggerConfig = { expr: cronExpr.trim(), tz: cronTz }; + const triggerConfig: TriggerConfig = + triggerKind === 'rrule' + ? { + kind: 'rrule', + expr: buildRRuleTriggerExpr( + rruleExpr, + seedTrigger?.kind === 'rrule' ? seedTrigger.expr : undefined + ), + tz: cronTz, + } + : { kind: 'cron', expr: cronExpr.trim(), tz: cronTz }; function applyTemplate(template: BuiltinAutomationTemplate) { setName(template.name); - setCronExpr(template.defaultTrigger.expr); + setTriggerKind(template.defaultTrigger.kind ?? 'cron'); + if (template.defaultTrigger.kind === 'rrule') { + setRRuleExpr(template.defaultTrigger.expr); + } else { + setCronExpr(template.defaultTrigger.expr); + } initialConversation.setPrompt(template.defaultConversationConfig.initialPrompt); } @@ -195,8 +222,12 @@ export function useAutomationFormState( projectId, setProjectId, effectiveProjectId, + triggerKind, + setTriggerKind, cronExpr, setCronExpr, + rruleExpr, + setRRuleExpr, cronTz, initialConversation, workspaceConfig, diff --git a/apps/emdash-desktop/src/renderer/features/automations/useAutomationSettingsAutoSave.ts b/apps/emdash-desktop/src/renderer/features/automations/useAutomationSettingsAutoSave.ts index 139eebe5da..3356951c5d 100644 --- a/apps/emdash-desktop/src/renderer/features/automations/useAutomationSettingsAutoSave.ts +++ b/apps/emdash-desktop/src/renderer/features/automations/useAutomationSettingsAutoSave.ts @@ -1,7 +1,7 @@ -import { useEffect, useRef } from 'react'; +import { useEffect, useRef, useState } from 'react'; import type { Automation } from '@shared/core/automations/automation'; import type { ConversationConfig, TriggerConfig } from '@shared/core/automations/config'; -import { assertValidCronTrigger } from '@shared/core/automations/validation'; +import { assertValidTrigger, normalizeTriggerConfig } from '@shared/core/automations/validation'; import { formatAutomationError } from './automation-run-format'; import { useAutomations } from './use-automations'; import { useAutomationFormState } from './useAutomationFormState'; @@ -11,6 +11,7 @@ export type AutomationSettingsAutoSave = ReturnType(null); const { effectiveProjectId, @@ -36,14 +37,16 @@ export function useAutomationSettingsAutoSave(automation: Automation) { function savePatch(overrideTrigger?: TriggerConfig) { if (!effectiveProjectId) return; - const activeTrigger = overrideTrigger ?? triggerConfig; + const activeTrigger = normalizeTriggerConfig(overrideTrigger ?? triggerConfig); const taskConfig = buildTaskConfig(effectiveProjectId); if (!taskConfig) return; try { - assertValidCronTrigger(activeTrigger); - } catch { + assertValidTrigger(activeTrigger); + } catch (error) { + setValidationError(formatAutomationError(error)); return; } + setValidationError(null); if (!name.trim() || !prompt.trim()) return; void updateSettings.mutateAsync({ id: automation.id, @@ -58,7 +61,16 @@ export function useAutomationSettingsAutoSave(automation: Automation) { function setCronExpr(expr: string) { formState.setCronExpr(expr); - savePatch({ expr, tz: cronTz }); + savePatch({ kind: 'cron', expr, tz: cronTz }); + } + + function saveCurrentTrigger() { + savePatch(); + } + + function saveTriggerKind(kind: NonNullable) { + const expr = kind === 'rrule' ? formState.rruleExpr : formState.cronExpr; + savePatch({ kind, expr, tz: cronTz }); } // Provider lives inside the initialConversation sub-hook and is not directly @@ -99,15 +111,19 @@ export function useAutomationSettingsAutoSave(automation: Automation) { void rename.mutateAsync({ id: automation.id, name: trimmed }); } - const saveError = updateSettings.error - ? formatAutomationError(updateSettings.error) - : rename.error - ? formatAutomationError(rename.error) - : null; + const saveError = validationError + ? validationError + : updateSettings.error + ? formatAutomationError(updateSettings.error) + : rename.error + ? formatAutomationError(rename.error) + : null; return { formState, setCronExpr, + saveCurrentTrigger, + saveTriggerKind, handlePromptBlur, handleNameBlur, isSaving: updateSettings.isPending || rename.isPending, diff --git a/apps/emdash-desktop/src/renderer/lib/CronPicker/CronPicker.tsx b/apps/emdash-desktop/src/renderer/lib/CronPicker/CronPicker.tsx index 16dc1e2838..610492b975 100644 --- a/apps/emdash-desktop/src/renderer/lib/CronPicker/CronPicker.tsx +++ b/apps/emdash-desktop/src/renderer/lib/CronPicker/CronPicker.tsx @@ -19,12 +19,17 @@ import { toCron, WEEKDAY_LABELS, } from './cron-utils'; -import type { CronPeriod, CronState } from './types'; +import type { CronPeriod, CronPickerPeriod, CronState } from './types'; interface CronPickerProps { value: string; onChange: (cron: string) => void; className?: string; + custom?: { + selected: boolean; + onSelect: () => void; + content: React.ReactNode; + }; } function useCronState(value: string): { state: CronState; parseError: boolean } { @@ -205,7 +210,7 @@ function Label({ children }: { children: React.ReactNode }) { return {children}; } -export function CronPicker({ value, onChange, className }: CronPickerProps) { +export function CronPicker({ value, onChange, className, custom }: CronPickerProps) { const { state, parseError } = useCronState(value); function emit(next: CronState) { @@ -213,6 +218,10 @@ export function CronPicker({ value, onChange, className }: CronPickerProps) { } function handlePeriodChange(period: string) { + if (period === 'custom') { + custom?.onSelect(); + return; + } emit(changePeriod(state, period as CronPeriod)); } @@ -239,12 +248,18 @@ export function CronPicker({ value, onChange, className }: CronPickerProps) { } const { period, hour, minute, weekDay, monthDay, month } = state; - - const showMonth = period === 'year'; - const showMonthDay = period === 'month' || period === 'year'; - const showWeekDay = period === 'week'; - const showTime = period === 'day' || period === 'week' || period === 'month' || period === 'year'; - const showHourMinute = period === 'hour'; + const selectedPeriod: CronPickerPeriod = custom?.selected ? 'custom' : period; + const periodOptions: readonly CronPickerPeriod[] = custom + ? [...PERIOD_ORDER, 'custom'] + : PERIOD_ORDER; + + const showMonth = !custom?.selected && period === 'year'; + const showMonthDay = !custom?.selected && (period === 'month' || period === 'year'); + const showWeekDay = !custom?.selected && period === 'week'; + const showTime = + !custom?.selected && + (period === 'day' || period === 'week' || period === 'month' || period === 'year'); + const showHourMinute = !custom?.selected && period === 'hour'; return (
@@ -253,17 +268,19 @@ export function CronPicker({ value, onChange, className }: CronPickerProps) { {/* Period selector */} PERIOD_LABELS[v as CronPeriod] ?? v} + renderValue={(v) => (v === 'custom' ? 'Custom' : (PERIOD_LABELS[v as CronPeriod] ?? v))} > - {PERIOD_ORDER.map((p) => ( + {periodOptions.map((p) => ( - {PERIOD_LABELS[p]} + {p === 'custom' ? 'Custom' : PERIOD_LABELS[p]} ))} + {custom?.selected && custom.content} + {/* Year: month selector */} {showMonth && ( <> @@ -342,7 +359,7 @@ export function CronPicker({ value, onChange, className }: CronPickerProps) { )}
- {parseError && ( + {!custom?.selected && parseError && (

Could not parse the cron expression. Showing defaults — saving will overwrite it.

diff --git a/apps/emdash-desktop/src/renderer/lib/CronPicker/types.ts b/apps/emdash-desktop/src/renderer/lib/CronPicker/types.ts index 0e20cddd4c..49dab8ceb0 100644 --- a/apps/emdash-desktop/src/renderer/lib/CronPicker/types.ts +++ b/apps/emdash-desktop/src/renderer/lib/CronPicker/types.ts @@ -1,4 +1,5 @@ export type CronPeriod = 'minute' | 'hour' | 'day' | 'week' | 'month' | 'year'; +export type CronPickerPeriod = CronPeriod | 'custom'; export interface CronState { period: CronPeriod; diff --git a/apps/emdash-desktop/src/shared/core/automations/config.ts b/apps/emdash-desktop/src/shared/core/automations/config.ts index 8f242074ea..eaff1f1583 100644 --- a/apps/emdash-desktop/src/shared/core/automations/config.ts +++ b/apps/emdash-desktop/src/shared/core/automations/config.ts @@ -4,6 +4,8 @@ import { workspaceConfig } from '@shared/core/workspaces/workspace-config'; import { defineVersionedSchema } from '@shared/lib/versioned-schema/versioned-schema'; export const triggerConfigSchema = z.object({ + /** Absent means cron for legacy stored automations. */ + kind: z.enum(['cron', 'rrule']).optional(), expr: z.string(), tz: z.string().optional(), }); diff --git a/apps/emdash-desktop/src/shared/core/automations/validation.test.ts b/apps/emdash-desktop/src/shared/core/automations/validation.test.ts new file mode 100644 index 0000000000..74f4919560 --- /dev/null +++ b/apps/emdash-desktop/src/shared/core/automations/validation.test.ts @@ -0,0 +1,111 @@ +import { describe, expect, it } from 'vitest'; +import { + assertValidTrigger, + formatTriggerScheduleLabel, + getNextTriggerRunAt, + normalizeTriggerConfig, +} from './validation'; + +describe('automation trigger validation', () => { + it('accepts legacy cron triggers', () => { + expect(() => assertValidTrigger({ expr: '0 9 * * 1', tz: 'UTC' })).not.toThrow(); + }); + + it('accepts RRULE triggers and computes the next run', () => { + const nextRunAt = getNextTriggerRunAt( + { + kind: 'rrule', + expr: 'DTSTART:20260706T090000Z\nRRULE:FREQ=WEEKLY;BYDAY=MO', + }, + Date.UTC(2026, 6, 1, 0, 0, 0) + ); + + expect(nextRunAt).toBe(Date.UTC(2026, 6, 6, 9, 0, 0)); + }); + + it('injects DTSTART when saving RRULE triggers without an anchor', () => { + const normalized = normalizeTriggerConfig( + { kind: 'rrule', expr: 'FREQ=WEEKLY;BYDAY=MO', tz: 'Europe/Berlin' }, + Date.UTC(2026, 6, 1, 7, 30, 0) + ); + + expect(normalized.expr).toBe( + 'DTSTART;TZID=Europe/Berlin:20260701T093000\nRRULE:FREQ=WEEKLY;BYDAY=MO' + ); + }); + + it('preserves case-insensitive RRULE property prefixes', () => { + const normalized = normalizeTriggerConfig( + { kind: 'rrule', expr: 'Rrule:FREQ=WEEKLY;BYDAY=MO', tz: 'UTC' }, + Date.UTC(2026, 6, 1, 9, 0, 0) + ); + + expect(normalized.expr).toBe('DTSTART;TZID=UTC:20260701T090000\nRrule:FREQ=WEEKLY;BYDAY=MO'); + expect(getNextTriggerRunAt(normalized, Date.UTC(2026, 6, 1, 10, 0, 0))).toBe( + Date.UTC(2026, 6, 6, 9, 0, 0) + ); + }); + + it('does not double-prefix malformed RRULE-looking input', () => { + const normalized = normalizeTriggerConfig( + { kind: 'rrule', expr: 'Rrule FREQ=WEEKLY;BYDAY=MO', tz: 'UTC' }, + Date.UTC(2026, 6, 1, 9, 0, 0) + ); + + expect(normalized.expr).toBe('DTSTART;TZID=UTC:20260701T090000\nRrule FREQ=WEEKLY;BYDAY=MO'); + expect(() => assertValidTrigger(normalized)).toThrow('rrule_invalid'); + }); + + it('keeps RRULE schedules stable across delayed scheduler passes', () => { + const trigger = normalizeTriggerConfig( + { kind: 'rrule', expr: 'FREQ=WEEKLY;BYDAY=MO', tz: 'UTC' }, + Date.UTC(2026, 6, 1, 9, 0, 0) + ); + + expect(getNextTriggerRunAt(trigger, Date.UTC(2026, 6, 6, 9, 0, 1))).toBe( + Date.UTC(2026, 6, 13, 9, 0, 0) + ); + expect(getNextTriggerRunAt(trigger, Date.UTC(2026, 6, 6, 9, 1, 30))).toBe( + Date.UTC(2026, 6, 13, 9, 0, 0) + ); + }); + + it('uses RRULE time zones for local wall-clock schedules', () => { + const trigger = { + kind: 'rrule' as const, + expr: 'DTSTART;TZID=Europe/Berlin:20260323T090000\nRRULE:FREQ=WEEKLY;BYDAY=MO', + tz: 'Europe/Berlin', + }; + + expect(getNextTriggerRunAt(trigger, Date.UTC(2026, 2, 30, 6, 30, 0))).toBe( + Date.UTC(2026, 2, 30, 7, 0, 0) + ); + expect(getNextTriggerRunAt(trigger, Date.UTC(2026, 2, 30, 7, 30, 0))).toBe( + Date.UTC(2026, 3, 6, 7, 0, 0) + ); + }); + + it('rejects expired finite RRULE triggers', () => { + expect(() => + assertValidTrigger({ + kind: 'rrule', + expr: 'DTSTART:20200101T090000Z\nRRULE:FREQ=DAILY;COUNT=1', + }) + ).toThrow('rrule_invalid'); + }); + + it('formats RRULE labels as readable text', () => { + expect( + formatTriggerScheduleLabel({ + kind: 'rrule', + expr: 'DTSTART:20260706T090000Z\nRRULE:FREQ=WEEKLY;BYDAY=MO', + }) + ).toBe('Every week on Monday'); + }); + + it('rejects invalid RRULE triggers', () => { + expect(() => assertValidTrigger({ kind: 'rrule', expr: 'not an rrule' })).toThrow( + 'rrule_invalid' + ); + }); +}); diff --git a/apps/emdash-desktop/src/shared/core/automations/validation.ts b/apps/emdash-desktop/src/shared/core/automations/validation.ts index 163cfdb604..4bce3bd53c 100644 --- a/apps/emdash-desktop/src/shared/core/automations/validation.ts +++ b/apps/emdash-desktop/src/shared/core/automations/validation.ts @@ -1,18 +1,198 @@ import { Cron } from 'croner'; +import * as rrule from 'rrule'; import type { TriggerConfig } from './config'; import { getLocalTimeZone } from './timezone'; +type RRuleModule = typeof rrule; +type RRuleModuleWithDefault = RRuleModule & { default?: RRuleModule }; +type DateParts = { + year: number; + month: number; + day: number; + hour: number; + minute: number; + second: number; +}; + +function triggerKind(trigger: TriggerConfig): NonNullable { + return trigger.kind ?? 'cron'; +} + +function getRRuleStr() { + // Electron's main bundle can expose rrule as either ESM named exports or a CJS + // default object, depending on which side of the app imports it. + const mod = rrule as RRuleModuleWithDefault; + return mod.rrulestr ?? mod.default?.rrulestr; +} + +function hasDtstart(expr: string): boolean { + return /^DTSTART(?:;[^:]*)?:/im.test(expr); +} + +function startsWithRRuleProperty(expr: string): boolean { + return /^RRULE\b/im.test(expr); +} + +function getTimeZone(trigger: TriggerConfig): string { + return trigger.tz || getLocalTimeZone(); +} + +function getRRuleTimeZone(trigger: TriggerConfig): string { + const tzidMatch = /^DTSTART;[^:]*TZID=([^;:]+)[^:]*:/im.exec(trigger.expr); + if (tzidMatch?.[1]) return tzidMatch[1]; + if (/^DTSTART(?:;[^:]*)?:\d{8}T\d{6}Z/im.test(trigger.expr)) return 'UTC'; + return getTimeZone(trigger); +} + +function getDateParts(date: Date, timeZone: string): DateParts { + const parts = new Intl.DateTimeFormat('en-US', { + timeZone, + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + hourCycle: 'h23', + }).formatToParts(date); + const get = (type: Intl.DateTimeFormatPartTypes) => + Number(parts.find((part) => part.type === type)?.value ?? 0); + return { + year: get('year'), + month: get('month'), + day: get('day'), + hour: get('hour'), + minute: get('minute'), + second: get('second'), + }; +} + +function partsToUtc(parts: DateParts): number { + return Date.UTC(parts.year, parts.month - 1, parts.day, parts.hour, parts.minute, parts.second); +} + +function dateToFloatingDateInTimeZone(date: Date, timeZone: string): Date { + return new Date(partsToUtc(getDateParts(date, timeZone))); +} + +function getTimeZoneOffsetAt(timestamp: number, timeZone: string): number { + return partsToUtc(getDateParts(new Date(timestamp), timeZone)) - timestamp; +} + +function floatingDateToUtc(date: Date, timeZone: string): number { + const floatingParts: DateParts = { + year: date.getUTCFullYear(), + month: date.getUTCMonth() + 1, + day: date.getUTCDate(), + hour: date.getUTCHours(), + minute: date.getUTCMinutes(), + second: date.getUTCSeconds(), + }; + const utcGuess = partsToUtc(floatingParts); + const firstOffset = getTimeZoneOffsetAt(utcGuess, timeZone); + const correctedUtc = utcGuess - firstOffset; + const correctedOffset = getTimeZoneOffsetAt(correctedUtc, timeZone); + if (correctedOffset === firstOffset) return correctedUtc; + + return utcGuess - (firstOffset + correctedOffset) / 2; +} + +function formatDtstart(date: Date, timeZone: string): string { + const parts = getDateParts(date, timeZone); + const pad = (value: number) => String(value).padStart(2, '0'); + return `${String(parts.year).padStart(4, '0')}${pad(parts.month)}${pad(parts.day)}T${pad( + parts.hour + )}${pad(parts.minute)}${pad(parts.second)}`; +} + +export function normalizeTriggerConfig( + trigger: TriggerConfig, + anchor: number | Date = new Date() +): TriggerConfig { + if (triggerKind(trigger) !== 'rrule') return trigger; + + const expr = trigger.expr.trim(); + if (hasDtstart(expr)) return { ...trigger, expr }; + + const timeZone = getTimeZone(trigger); + const anchorDate = anchor instanceof Date ? anchor : new Date(anchor); + const rruleExpr = startsWithRRuleProperty(expr) ? expr : `RRULE:${expr}`; + return { + ...trigger, + expr: `DTSTART;TZID=${timeZone}:${formatDtstart(anchorDate, timeZone)}\n${rruleExpr}`, + tz: timeZone, + }; +} + +export function getNextTriggerRunAt( + trigger: TriggerConfig, + from: number | Date = new Date() +): number | null { + const fromDate = from instanceof Date ? from : new Date(from); + + if (triggerKind(trigger) === 'rrule') { + const rrulestr = getRRuleStr(); + if (!rrulestr) throw new Error('rrule_invalid'); + const normalizedTrigger = normalizeTriggerConfig(trigger, fromDate); + const timeZone = getRRuleTimeZone(normalizedTrigger); + const rule = rrulestr(normalizedTrigger.expr.trim(), { tzid: timeZone }); + const floatingFromDate = dateToFloatingDateInTimeZone(fromDate, timeZone); + const next = rule.after(floatingFromDate, false); + return next ? floatingDateToUtc(next, timeZone) : null; + } + + const next = new Cron(trigger.expr, { timezone: trigger.tz || getLocalTimeZone() }).nextRun( + fromDate + ); + return next?.getTime() ?? null; +} + export function assertValidCronTrigger(trigger: TriggerConfig): void { const expr = trigger.expr.trim(); if (!expr) throw new Error('cron_invalid'); + if (triggerKind(trigger) !== 'cron') throw new Error('cron_invalid'); if (expr.split(/\s+/).length !== 5) throw new Error('cron_invalid'); try { - const nextRun = new Cron(expr, { timezone: trigger.tz || getLocalTimeZone() }).nextRun( - new Date() - ); + const nextRun = getNextTriggerRunAt(trigger); if (!nextRun) throw new Error('cron_invalid'); } catch { throw new Error('cron_invalid'); } } + +export function formatTriggerScheduleLabel(trigger: TriggerConfig): string { + if (triggerKind(trigger) === 'rrule') { + try { + const rrulestr = getRRuleStr(); + const normalizedTrigger = normalizeTriggerConfig(trigger); + const rule = rrulestr?.(normalizedTrigger.expr.trim(), { + tzid: getRRuleTimeZone(normalizedTrigger), + }); + const text = rule && 'toText' in rule ? rule.toText() : null; + return text ? text[0]?.toUpperCase() + text.slice(1) : 'Custom RRULE'; + } catch { + return trigger.expr; + } + } + + try { + return new Cron(trigger.expr, { timezone: trigger.tz || getLocalTimeZone() }).toString(); + } catch { + return trigger.expr; + } +} + +export function assertValidTrigger(trigger: TriggerConfig): void { + if (triggerKind(trigger) === 'rrule') { + try { + const nextRun = getNextTriggerRunAt(trigger); + if (!nextRun) throw new Error('rrule_invalid'); + } catch { + throw new Error('rrule_invalid'); + } + return; + } + + assertValidCronTrigger(trigger); +} diff --git a/apps/emdash-desktop/src/shared/telemetry.ts b/apps/emdash-desktop/src/shared/telemetry.ts index d1a138da41..79f2d9cb06 100644 --- a/apps/emdash-desktop/src/shared/telemetry.ts +++ b/apps/emdash-desktop/src/shared/telemetry.ts @@ -65,11 +65,11 @@ export type TelemetryEventProperties = { automation_created: { enabled: boolean; - trigger_kind: 'cron'; + trigger_kind: 'cron' | 'rrule'; provider: AgentProviderId | null; has_initial_prompt: boolean; }; - automation_enabled_changed: { enabled: boolean; trigger_kind: 'cron' }; + automation_enabled_changed: { enabled: boolean; trigger_kind: 'cron' | 'rrule' }; automation_run_started: { trigger_kind: AutomationRunTriggerKind }; automation_run_completed: { status: Extract; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 61a173cb2a..d13339fdb9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -98,6 +98,9 @@ importers: pidusage: specifier: ^4.0.1 version: 4.0.1 + rrule: + specifier: ^2.8.1 + version: 2.8.1 smol-toml: specifier: ^1.6.0 version: 1.6.1 @@ -6881,6 +6884,9 @@ packages: resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==} engines: {node: '>= 18'} + rrule@2.8.1: + resolution: {integrity: sha512-hM3dHSBMeaJ0Ktp7W38BJZ7O1zOgaFEsn41PDk+yHoEtfLV+PoJt9E9xAlZiWgf/iqEqionN0ebHFZIDAp+iGw==} + run-applescript@7.1.0: resolution: {integrity: sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q==} engines: {node: '>=18'} @@ -14561,6 +14567,10 @@ snapshots: transitivePeerDependencies: - supports-color + rrule@2.8.1: + dependencies: + tslib: 2.8.1 + run-applescript@7.1.0: {} rw@1.3.3: {}