Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/emdash-desktop/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
32 changes: 16 additions & 16 deletions apps/emdash-desktop/src/main/core/automations/repo.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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');
}
Expand Down Expand Up @@ -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<Automation[]> {
Expand Down Expand Up @@ -255,18 +252,18 @@ export async function createAutomation(input: CreateAutomationParams): Promise<A
throw new Error('project_not_found');
}

const now = Date.now();
const triggerConfig = normalizeTriggerConfig(input.triggerConfig, now);
assertValidAutomationInput({
triggerConfig: input.triggerConfig,
triggerConfig,
conversationConfig: input.conversationConfig,
});

const now = Date.now();
const [row] = await db
.insert(automations)
.values({
id: randomUUID(),
name: input.name.trim(),
triggerConfig: input.triggerConfig,
triggerConfig,
conversationConfig: input.conversationConfig,
taskConfig: input.taskConfig ?? null,
projectId: input.projectId,
Expand Down Expand Up @@ -300,7 +297,10 @@ export async function updateAutomationSettings(
if (!existingRow) return null;

const existing = mapAutomationRow(existingRow);
const finalTriggerConfig = patch.triggerConfig ?? existing.triggerConfig;
const normalizedPatchTriggerConfig = patch.triggerConfig
? normalizeTriggerConfig(patch.triggerConfig)
: undefined;
const finalTriggerConfig = normalizedPatchTriggerConfig ?? existing.triggerConfig;
const finalConversationConfig = patch.conversationConfig ?? existing.conversationConfig;
const finalProjectId = patch.projectId !== undefined ? patch.projectId : existing.projectId;

Expand All @@ -315,7 +315,7 @@ export async function updateAutomationSettings(
const values: Partial<typeof automations.$inferInsert> = { 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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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',
};
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ const FORM_ERROR_MESSAGES: Record<string, string> = {
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',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -131,6 +138,8 @@ export const AutomationDetailView = observer(function AutomationDetailView({
setCronError(null);
}}
onCronErrorClear={() => setCronError(null)}
onTriggerKindChange={saveTriggerKind}
onRRuleExprBlur={saveCurrentTrigger}
onPromptBlur={handlePromptBlur}
error={saveError}
/>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -106,10 +110,10 @@ export const AutomationRow = observer(function AutomationRow({
<AgentStatusIndicator status={agentStatus} />
</div>
<div className="flex shrink-0 flex-row items-center gap-1 text-xs text-foreground-muted">
{cronLabel && (
{scheduleLabel && (
<span className="flex items-center gap-1 rounded-md bg-background-1 px-2 py-1 text-foreground-muted group-hover:bg-background-2">
<Clock className="size-3 shrink-0" />
<span className="shrink-0">{cronLabel}</span>
<span className="shrink-0">{scheduleLabel}</span>
</span>
)}
<div className="flex max-w-32 flex-row items-center gap-1.5 rounded-md bg-background-1 px-2 py-1 text-foreground-muted group-hover:bg-background-2">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -15,6 +16,8 @@ interface AutomationSettingsFieldsProps {
cronError: string | null;
onCronExprChange: (expr: string) => void;
onCronErrorClear: () => void;
onTriggerKindChange?: (kind: NonNullable<AutomationFormState['triggerKind']>) => void;
onRRuleExprBlur?: () => void;
onPromptBlur?: () => void;
error?: string | null;
}
Expand All @@ -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,
Expand All @@ -53,9 +62,31 @@ export function AutomationSettingsFields({
<CronPicker
value={cronExpr}
onChange={(nextCronExpr) => {
setTriggerKind('cron');
onCronExprChange(nextCronExpr);
onCronErrorClear();
}}
custom={{
selected: (triggerKind ?? 'cron') === 'rrule',
onSelect: () => {
setTriggerKind('rrule');
onCronErrorClear();
onTriggerKindChange?.('rrule');
},
content: (
<Input
value={rruleExpr}
onChange={(event) => {
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 && <FieldError>{cronError}</FieldError>}
</Field>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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;
Expand All @@ -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,
Expand Down
Original file line number Diff line number Diff line change
@@ -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');
});
});
Original file line number Diff line number Diff line change
@@ -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;
}
Loading
Loading