Skip to content
Closed
Show file tree
Hide file tree
Changes from 8 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
Expand Up @@ -20,6 +20,7 @@ import {
import type { BuiltinAutomationTemplate } from './automation-template';

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.
Expand Down Expand Up @@ -74,9 +75,20 @@ export function useAutomationFormState(
const [projectId, setProjectId] = useState<string | undefined>(
seed?.projectId ?? firstMountedProjectId()
);
const [triggerKind, setTriggerKind] = useState<TriggerConfig['kind']>(
seedTrigger?.kind ?? initialTemplate?.defaultTrigger.kind ?? 'cron'
);
const [cronExpr, setCronExpr] = useState<string>(
seedTrigger?.expr ?? initialTemplate?.defaultTrigger.expr ?? DEFAULT_CRON
seedTrigger?.kind === 'rrule'
? DEFAULT_CRON
: (seedTrigger?.expr ?? initialTemplate?.defaultTrigger.expr ?? DEFAULT_CRON)
);
const [rruleExpr, setRRuleExpr] = useState<string>(() => {
if (seedTrigger?.kind !== 'rrule') return DEFAULT_RRULE;

const rruleLine = seedTrigger.expr.split('\n').find((line) => /^RRULE:/i.test(line));
return rruleLine ? rruleLine.replace(/^RRULE:/i, '') : seedTrigger.expr;
});
const [cronTz] = useState<string>(seedTrigger?.tz ?? getLocalTimeZone());

const effectiveProjectId =
Expand Down Expand Up @@ -181,11 +193,19 @@ 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: rruleExpr.trim(), tz: cronTz }
: { kind: 'cron', expr: cronExpr.trim(), tz: cronTz };
Comment thread
janburzinski marked this conversation as resolved.

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);
}

Expand All @@ -195,8 +215,12 @@ export function useAutomationFormState(
projectId,
setProjectId,
effectiveProjectId,
triggerKind,
setTriggerKind,
cronExpr,
setCronExpr,
rruleExpr,
setRRuleExpr,
cronTz,
initialConversation,
workspaceConfig,
Expand Down
Loading
Loading