Improve automations scheduling UX (#1997)

* Improve automations scheduling UX

* fix: address review findings
This commit is contained in:
Jinjing
2026-05-15 17:22:13 -07:00
committed by GitHub
parent bf85d51bc1
commit fc0a955d0b
10 changed files with 1049 additions and 252 deletions
@@ -6,7 +6,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip
import { AGENT_CATALOG, AgentIcon } from '@/lib/agent-catalog'
import type { Automation, AutomationRun } from '../../../../shared/automations-types'
import type { Worktree } from '../../../../shared/types'
import { parseAutomationRrule } from '../../../../shared/automation-schedules'
import { formatAutomationSchedule } from '../../../../shared/automation-schedules'
import {
formatAutomationDateTime,
formatAutomationDateTimeWithRelative,
@@ -44,15 +44,6 @@ function DetailMetric({ label, value }: { label: string; value: string }): React
)
}
function formatTime(hour: number, minute: number): string {
const date = new Date()
date.setHours(hour, minute, 0, 0)
return new Intl.DateTimeFormat(undefined, {
hour: 'numeric',
minute: '2-digit'
}).format(date)
}
function formatGrace(minutes: number): string {
if (minutes <= 0) {
return 'No grace'
@@ -64,24 +55,6 @@ function formatGrace(minutes: number): string {
return `${hours} ${hours === 1 ? 'hour' : 'hours'}`
}
function formatSchedule(rrule: string): string {
const schedule = parseAutomationRrule(rrule)
if (schedule.preset === 'hourly') {
return `Hourly at :${String(schedule.minute).padStart(2, '0')}`
}
const time = formatTime(schedule.hour, schedule.minute)
if (schedule.preset === 'daily') {
return `Daily at ${time}`
}
if (schedule.preset === 'weekdays') {
return `Weekdays at ${time}`
}
const day = new Intl.DateTimeFormat(undefined, { weekday: 'long' }).format(
new Date(2026, 0, 4 + schedule.dayOfWeek)
)
return `${day}s at ${time}`
}
function ToolbarIconButton({
label,
children,
@@ -223,7 +196,7 @@ export function AutomationDetail({
</span>
</div>
</div>
<DetailMetric label="Schedule" value={formatSchedule(automation.rrule)} />
<DetailMetric label="Schedule" value={formatAutomationSchedule(automation.rrule)} />
<DetailMetric
label={automation.workspaceMode === 'new_per_run' ? 'Create from' : 'Workspace'}
value={
@@ -1,8 +1,9 @@
import React from 'react'
import { Info, Plus } from 'lucide-react'
import { Info, Plus, Sparkles } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import {
Select,
SelectContent,
@@ -21,9 +22,14 @@ import type {
} from '../../../../shared/automations-types'
import type { GlobalSettings, Repo, TuiAgent, Worktree } from '../../../../shared/types'
import { Field } from './automation-page-parts'
import { AutomationSchedulePicker } from './AutomationSchedulePicker'
import { AUTOMATION_TEMPLATES, type AutomationTemplate } from './automation-templates'
import { CreateFromPicker } from './CreateFromPicker'
import { WorkspaceCombobox } from './WorkspaceCombobox'
const PICKER_TRIGGER_CLASS =
'border-input bg-input/30 shadow-xs hover:bg-accent/60 dark:bg-input/30 dark:hover:bg-input/50'
export type AutomationDraft = {
name: string
prompt: string
@@ -35,7 +41,9 @@ export type AutomationDraft = {
preset: AutomationSchedulePreset
time: string
dayOfWeek: string
customSchedule: string
missedRunGraceMinutes: string
scheduleWarning: string | null
}
type AutomationEditorDialogProps = {
@@ -51,9 +59,32 @@ type AutomationEditorDialogProps = {
onProjectChange: (projectId: string) => void
onOpenChange: (open: boolean) => void
onDraftChange: (updater: (current: AutomationDraft) => AutomationDraft) => void
onApplyTemplate: (template: AutomationTemplate) => void
onSave: () => void
}
function AutomationTemplateCard({
template,
onSelect
}: {
template: AutomationTemplate
onSelect: () => void
}): React.JSX.Element {
return (
<button
type="button"
onClick={onSelect}
className="rounded-md border border-border/70 bg-background px-3 py-2 text-left shadow-xs transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50"
>
<div className="text-[11px] font-medium uppercase text-muted-foreground">
{template.category}
</div>
<div className="mt-1 text-sm font-medium">{template.label}</div>
<div className="mt-1 line-clamp-2 text-xs text-muted-foreground">{template.description}</div>
</button>
)
}
export function AutomationEditorDialog({
open,
isEditing,
@@ -67,88 +98,142 @@ export function AutomationEditorDialog({
onProjectChange,
onOpenChange,
onDraftChange,
onApplyTemplate,
onSave
}: AutomationEditorDialogProps): React.JSX.Element {
const [templateOpen, setTemplateOpen] = React.useState(false)
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent
className="sm:max-w-lg"
className="flex max-h-[90vh] flex-col gap-0 p-0 sm:max-w-[920px]"
onOpenAutoFocus={(event) => {
event.preventDefault()
}}
>
<DialogHeader className="gap-1">
<DialogTitle className="text-base font-semibold">
{isEditing ? 'Edit Automation' : 'Create Automation'}
</DialogTitle>
<DialogHeader className="border-b border-border/50 px-5 py-4">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 flex-1 space-y-2">
<DialogTitle className="text-sm font-medium">
{isEditing ? 'Edit automation' : 'Create automation'}
</DialogTitle>
<Input
value={draft.name}
placeholder="Weekday repo audit"
aria-label="Automation name"
className="h-10 border-0 bg-transparent px-0 text-lg font-semibold shadow-none focus-visible:ring-0"
onChange={(event) =>
onDraftChange((current) => ({ ...current, name: event.target.value }))
}
/>
</div>
{!isEditing ? (
<Popover open={templateOpen} onOpenChange={setTemplateOpen}>
<PopoverTrigger asChild>
<Button type="button" variant="outline" size="sm">
<Sparkles className="size-4" />
Use template
</Button>
</PopoverTrigger>
<PopoverContent align="end" className="w-96 p-3">
<div className="grid gap-2">
{AUTOMATION_TEMPLATES.map((template) => (
<AutomationTemplateCard
key={template.id}
template={template}
onSelect={() => {
onApplyTemplate(template)
setTemplateOpen(false)
}}
/>
))}
</div>
</PopoverContent>
</Popover>
) : null}
</div>
</DialogHeader>
<div className="grid gap-3">
<Field label="Name">
<Input
value={draft.name}
placeholder="Weekday repo audit"
<div className="min-h-0 flex-1 overflow-auto px-5 py-4">
{draft.scheduleWarning ? (
<div className="mb-3 rounded-md border border-border bg-muted/40 px-3 py-2 text-xs text-muted-foreground">
{draft.scheduleWarning}
</div>
) : null}
<Field label="Prompt">
<textarea
value={draft.prompt}
placeholder="Run the weekly dependency audit and summarize risky changes."
onChange={(event) =>
onDraftChange((current) => ({ ...current, name: event.target.value }))
onDraftChange((current) => ({ ...current, prompt: event.target.value }))
}
className="min-h-[260px] w-full resize-none rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-xs outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 dark:bg-input/30"
/>
</Field>
<Field label="Project">
<RepoCombobox
repos={repos}
value={draft.projectId}
onValueChange={onProjectChange}
placeholder="Select project"
triggerClassName="h-9 w-full min-w-0"
showStandaloneAddButton={false}
/>
</Field>
<Field label="Run location">
<ToggleGroup
type="single"
value={draft.workspaceMode}
onValueChange={(workspaceMode) =>
workspaceMode &&
onDraftChange((current) => ({
...current,
workspaceMode: workspaceMode as AutomationWorkspaceMode
}))
}
variant="outline"
size="sm"
className="grid w-full grid-cols-2"
>
<ToggleGroupItem value="existing" className="w-full">
Selected workspace
</ToggleGroupItem>
<ToggleGroupItem value="new_per_run" className="w-full">
New workspace each run
</ToggleGroupItem>
</ToggleGroup>
</Field>
{draft.workspaceMode === 'existing' ? (
<Field label="Workspace">
<WorkspaceCombobox
worktrees={worktrees}
value={draft.workspaceId}
onValueChange={(workspaceId) =>
onDraftChange((current) => ({ ...current, workspaceId }))
}
</div>
<div className="border-t border-border/50 px-5 py-4">
<div className="grid gap-3 lg:grid-cols-[minmax(9rem,1.1fr)_minmax(14rem,1.4fr)_minmax(12rem,1.2fr)_minmax(8rem,0.8fr)_minmax(9rem,1fr)]">
<Field label="Project">
<RepoCombobox
repos={repos}
value={draft.projectId}
onValueChange={onProjectChange}
placeholder="Select project"
triggerClassName={`h-9 w-full min-w-0 ${PICKER_TRIGGER_CLASS}`}
showStandaloneAddButton={false}
/>
</Field>
) : (
<Field label="Create from">
<CreateFromPicker
repoId={draft.projectId}
repoMap={repoMap}
worktrees={worktrees}
value={draft.baseBranch}
onValueChange={(baseBranch) =>
onDraftChange((current) => ({ ...current, baseBranch }))
<Field label={draft.workspaceMode === 'new_per_run' ? 'Start branch' : 'Workspace'}>
<ToggleGroup
type="single"
value={draft.workspaceMode}
onValueChange={(workspaceMode) =>
workspaceMode &&
onDraftChange((current) => ({
...current,
workspaceMode: workspaceMode as AutomationWorkspaceMode
}))
}
/>
variant="outline"
size="sm"
className="mb-2 grid w-full grid-cols-2"
>
<ToggleGroupItem
value="existing"
className="w-full border-input bg-input/30 shadow-xs data-[state=on]:border-primary data-[state=on]:bg-primary data-[state=on]:text-primary-foreground dark:bg-input/30"
>
Workspace
</ToggleGroupItem>
<ToggleGroupItem
value="new_per_run"
className="w-full border-input bg-input/30 shadow-xs data-[state=on]:border-primary data-[state=on]:bg-primary data-[state=on]:text-primary-foreground dark:bg-input/30"
>
New run
</ToggleGroupItem>
</ToggleGroup>
{draft.workspaceMode === 'existing' ? (
<WorkspaceCombobox
worktrees={worktrees}
value={draft.workspaceId}
triggerClassName={PICKER_TRIGGER_CLASS}
onValueChange={(workspaceId) =>
onDraftChange((current) => ({ ...current, workspaceId }))
}
/>
) : (
<CreateFromPicker
repoId={draft.projectId}
repoMap={repoMap}
worktrees={worktrees}
value={draft.baseBranch}
triggerClassName={PICKER_TRIGGER_CLASS}
onValueChange={(baseBranch) =>
onDraftChange((current) => ({ ...current, baseBranch }))
}
/>
)}
</Field>
)}
<div className="grid grid-cols-2 gap-2">
<Field label="Agent">
<AgentCombobox
agents={AGENT_CATALOG}
@@ -157,124 +242,70 @@ export function AutomationEditorDialog({
agentId && onDraftChange((current) => ({ ...current, agentId }))
}
defaultAgent={settings?.defaultTuiAgent ?? null}
triggerClassName="h-9 w-full min-w-0"
triggerClassName={`h-9 w-full min-w-0 ${PICKER_TRIGGER_CLASS}`}
/>
</Field>
<Field label="Schedule">
<Select
value={draft.preset}
onValueChange={(preset) =>
onDraftChange((current) => ({
...current,
preset: preset as AutomationSchedulePreset
}))
}
>
<SelectTrigger className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="hourly">Hourly</SelectItem>
<SelectItem value="daily">Daily</SelectItem>
<SelectItem value="weekdays">Weekdays</SelectItem>
<SelectItem value="weekly">Weekly</SelectItem>
</SelectContent>
</Select>
</Field>
</div>
<div className="grid grid-cols-2 gap-2">
<Field label="Time">
<Input
type="time"
value={draft.time}
disabled={draft.preset === 'hourly'}
onChange={(event) =>
onDraftChange((current) => ({ ...current, time: event.target.value }))
}
<AutomationSchedulePicker
draft={draft}
triggerClassName={PICKER_TRIGGER_CLASS}
onDraftChange={onDraftChange}
/>
</Field>
<Field label="Day">
<Field
label={
<span className="inline-flex items-center gap-1">
Grace
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
aria-label="Missed-run grace help"
className="rounded-sm text-muted-foreground outline-none hover:text-foreground focus-visible:ring-[3px] focus-visible:ring-ring/50"
>
<Info className="size-3.5" />
</button>
</TooltipTrigger>
<TooltipContent side="top" sideOffset={6} className="max-w-72">
If Orca or the execution host was unavailable at the scheduled time, Orca runs
one missed occurrence when it becomes available within this window. Older
missed runs are skipped.
</TooltipContent>
</Tooltip>
</span>
}
>
<Select
value={draft.dayOfWeek}
disabled={draft.preset !== 'weekly'}
onValueChange={(dayOfWeek) =>
onDraftChange((current) => ({ ...current, dayOfWeek }))
value={draft.missedRunGraceMinutes}
onValueChange={(missedRunGraceMinutes) =>
onDraftChange((current) => ({ ...current, missedRunGraceMinutes }))
}
>
<SelectTrigger className="w-full">
<SelectTrigger className={`w-full ${PICKER_TRIGGER_CLASS}`}>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="0">Sunday</SelectItem>
<SelectItem value="1">Monday</SelectItem>
<SelectItem value="2">Tuesday</SelectItem>
<SelectItem value="3">Wednesday</SelectItem>
<SelectItem value="4">Thursday</SelectItem>
<SelectItem value="5">Friday</SelectItem>
<SelectItem value="6">Saturday</SelectItem>
<SelectContent position="popper" side="bottom" align="start" sideOffset={4}>
<SelectItem value="0">No grace</SelectItem>
<SelectItem value="30">30 minutes</SelectItem>
<SelectItem value="60">1 hour</SelectItem>
<SelectItem value="180">3 hours</SelectItem>
<SelectItem value="720">12 hours</SelectItem>
<SelectItem value="1440">24 hours</SelectItem>
<SelectItem value="2880">48 hours</SelectItem>
</SelectContent>
</Select>
</Field>
</div>
<Field
label={
<span className="inline-flex items-center gap-1">
Missed-run grace
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
aria-label="Missed-run grace help"
className="rounded-sm text-muted-foreground outline-none hover:text-foreground focus-visible:ring-[3px] focus-visible:ring-ring/50"
>
<Info className="size-3.5" />
</button>
</TooltipTrigger>
<TooltipContent side="top" sideOffset={6} className="max-w-72">
If Orca or the execution host was unavailable at the scheduled time, Orca runs
one missed occurrence when it becomes available within this window. Older missed
runs are skipped.
</TooltipContent>
</Tooltip>
</span>
}
>
<Select
value={draft.missedRunGraceMinutes}
onValueChange={(missedRunGraceMinutes) =>
onDraftChange((current) => ({ ...current, missedRunGraceMinutes }))
}
>
<SelectTrigger className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent position="popper" side="bottom" align="start" sideOffset={4}>
<SelectItem value="0">No grace</SelectItem>
<SelectItem value="30">30 minutes</SelectItem>
<SelectItem value="60">1 hour</SelectItem>
<SelectItem value="180">3 hours</SelectItem>
<SelectItem value="720">12 hours</SelectItem>
<SelectItem value="1440">24 hours</SelectItem>
<SelectItem value="2880">48 hours</SelectItem>
</SelectContent>
</Select>
</Field>
<Field label="Prompt">
<textarea
value={draft.prompt}
rows={5}
placeholder="Run the weekly dependency audit and summarize risky changes."
onChange={(event) =>
onDraftChange((current) => ({ ...current, prompt: event.target.value }))
}
className="w-full resize-none rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-xs outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 dark:bg-input/30"
/>
</Field>
<div className="flex justify-end gap-2 pt-1">
<div className="mt-4 flex justify-end gap-2">
<Button variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button onClick={onSave} disabled={isSaving || repos.length === 0 || !canSave}>
<Button
variant="outline"
onClick={onSave}
disabled={isSaving || repos.length === 0 || !canSave}
className="border-foreground/25 bg-foreground/[0.04] text-foreground hover:bg-foreground/[0.08]"
>
{isEditing ? null : <Plus className="size-4" />}
{isEditing ? 'Save Changes' : 'Save Automation'}
</Button>
@@ -0,0 +1,226 @@
import React from 'react'
import { CalendarClock, ChevronsUpDown } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { cn } from '@/lib/utils'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue
} from '@/components/ui/select'
import type { AutomationSchedulePreset } from '../../../../shared/automations-types'
import {
buildAutomationRrule,
formatAutomationSchedule,
isValidAutomationSchedule
} from '../../../../shared/automation-schedules'
import type { AutomationDraft } from './AutomationEditorDialog'
import { Field } from './automation-page-parts'
const FIELD_CONTROL_CLASS = 'border-input bg-input/30 shadow-xs dark:bg-input/30'
const DAY_OPTIONS = [
['0', 'Sunday'],
['1', 'Monday'],
['2', 'Tuesday'],
['3', 'Wednesday'],
['4', 'Thursday'],
['5', 'Friday'],
['6', 'Saturday']
] as const
function parseTime(value: string): { hour: number; minute: number } {
const [hour, minute] = value.split(':').map((part) => Number(part))
return {
hour: Number.isFinite(hour) ? hour : 9,
minute: Number.isFinite(minute) ? minute : 0
}
}
function getDraftScheduleLabel(draft: AutomationDraft): string {
if (draft.preset === 'custom') {
return draft.customSchedule.trim()
? formatAutomationSchedule(draft.customSchedule)
: 'Custom cron'
}
const { hour, minute } = parseTime(draft.time)
return formatAutomationSchedule(
buildAutomationRrule({
preset: draft.preset,
hour,
minute,
dayOfWeek: Number(draft.dayOfWeek)
})
)
}
function buildCustomCronFromDraft(draft: AutomationDraft): string {
const { hour, minute } = parseTime(draft.time)
if (draft.preset === 'hourly') {
return `${minute} * * * *`
}
if (draft.preset === 'weekdays') {
return `${minute} ${hour} * * 1-5`
}
if (draft.preset === 'weekly') {
return `${minute} ${hour} * * ${Number(draft.dayOfWeek)}`
}
return `${minute} ${hour} * * *`
}
export function AutomationSchedulePicker({
draft,
triggerClassName,
onDraftChange
}: {
draft: AutomationDraft
triggerClassName?: string
onDraftChange: (updater: (current: AutomationDraft) => AutomationDraft) => void
}): React.JSX.Element {
const [open, setOpen] = React.useState(false)
const label = getDraftScheduleLabel(draft)
const customSchedule = draft.customSchedule.trim()
const customScheduleInvalid =
draft.preset === 'custom' &&
customSchedule.length > 0 &&
!isValidAutomationSchedule(customSchedule)
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
type="button"
variant="outline"
role="combobox"
aria-expanded={open}
className={cn('h-9 w-full justify-between px-3 text-sm font-normal', triggerClassName)}
>
<span className="flex min-w-0 items-center gap-2">
<CalendarClock className="size-4 text-muted-foreground" />
<span className="truncate">{label}</span>
</span>
<ChevronsUpDown className="size-4 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent
align="start"
className="w-[var(--radix-popover-trigger-width)] min-w-[19rem] p-3"
>
<div className="grid gap-3">
<Field label="Schedule">
<Select
value={draft.preset}
onValueChange={(preset) =>
onDraftChange((current) => ({
...current,
preset: preset as AutomationSchedulePreset,
customSchedule:
preset === 'custom' && !current.customSchedule.trim()
? buildCustomCronFromDraft(current)
: current.customSchedule,
scheduleWarning: null
}))
}
>
<SelectTrigger className={`w-full ${FIELD_CONTROL_CLASS}`}>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="hourly">Hourly</SelectItem>
<SelectItem value="daily">Daily</SelectItem>
<SelectItem value="weekdays">Weekdays</SelectItem>
<SelectItem value="weekly">Weekly</SelectItem>
<SelectItem value="custom">Custom cron</SelectItem>
</SelectContent>
</Select>
</Field>
{draft.preset !== 'custom' ? (
<Button
type="button"
variant="secondary"
size="sm"
className="justify-start"
onClick={() =>
onDraftChange((current) => ({
...current,
preset: 'custom',
customSchedule:
current.customSchedule.trim() || buildCustomCronFromDraft(current),
scheduleWarning: null
}))
}
>
Use custom cron
</Button>
) : null}
{draft.preset === 'custom' ? (
<Field label="Cron string">
<Input
value={draft.customSchedule}
placeholder="0 9 * * 1-5"
spellCheck={false}
className={`font-mono ${FIELD_CONTROL_CLASS}`}
aria-invalid={customScheduleInvalid}
onChange={(event) =>
onDraftChange((current) => ({
...current,
customSchedule: event.target.value,
scheduleWarning: null
}))
}
/>
<div className="mt-1 text-[11px] text-muted-foreground">
Five fields: minute hour day month weekday.
</div>
{customScheduleInvalid ? (
<div className="mt-1 text-[11px] text-destructive">
Enter a valid 5-field cron expression.
</div>
) : null}
</Field>
) : null}
{draft.preset === 'weekly' ? (
<Field label="Day">
<Select
value={draft.dayOfWeek}
onValueChange={(dayOfWeek) =>
onDraftChange((current) => ({ ...current, dayOfWeek, scheduleWarning: null }))
}
>
<SelectTrigger className={`w-full ${FIELD_CONTROL_CLASS}`}>
<SelectValue />
</SelectTrigger>
<SelectContent>
{DAY_OPTIONS.map(([value, label]) => (
<SelectItem key={value} value={value}>
{label}
</SelectItem>
))}
</SelectContent>
</Select>
</Field>
) : null}
{draft.preset !== 'custom' ? (
<Field label={draft.preset === 'hourly' ? 'Minute' : 'Time'}>
<Input
type="time"
value={draft.time}
className={FIELD_CONTROL_CLASS}
onChange={(event) =>
onDraftChange((current) => ({
...current,
time: event.target.value,
scheduleWarning: null
}))
}
/>
</Field>
) : null}
</div>
</PopoverContent>
</Popover>
)
}
@@ -1,7 +1,17 @@
/* eslint-disable max-lines -- Why: this page owns the automations list/detail
* orchestration while the form and detail presentation live in sibling files. */
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { CalendarClock, Check, Pause, Pencil, Play, Plus, RefreshCw, Trash2 } from 'lucide-react'
import {
CalendarClock,
Check,
Clock,
Pause,
Pencil,
Play,
Plus,
RefreshCw,
Trash2
} from 'lucide-react'
import { toast } from 'sonner'
import { Button } from '@/components/ui/button'
import {
@@ -35,7 +45,12 @@ import type {
AutomationUpdateInput
} from '../../../../shared/automations-types'
import type { Worktree } from '../../../../shared/types'
import { buildAutomationRrule, parseAutomationRrule } from '../../../../shared/automation-schedules'
import {
buildAutomationRrule,
formatAutomationSchedule,
isValidAutomationSchedule,
tryParseAutomationRrule
} from '../../../../shared/automation-schedules'
import { formatAutomationDateTimeWithRelative } from './automation-page-parts'
import {
formatAutomationCost,
@@ -44,6 +59,7 @@ import {
} from './automation-usage-model'
import { AutomationDetail } from './AutomationDetail'
import { AutomationEditorDialog, type AutomationDraft } from './AutomationEditorDialog'
import { AUTOMATION_TEMPLATES, type AutomationTemplate } from './automation-templates'
import { ExternalAutomationManagers } from './ExternalAutomationManagers'
const AGENTS = AGENT_CATALOG.map((agent) => agent.id)
@@ -58,6 +74,10 @@ function formatTimeInput(hour: number, minute: number): string {
return `${String(hour).padStart(2, '0')}:${String(minute).padStart(2, '0')}`
}
function getAgentLabel(agentId: string): string {
return AGENT_CATALOG.find((agent) => agent.id === agentId)?.label ?? agentId
}
export default function AutomationsPage(): React.JSX.Element {
const repos = useAppStore((s) => s.repos)
const worktreesByRepo = useAppStore((s) => s.worktreesByRepo)
@@ -108,7 +128,9 @@ export default function AutomationsPage(): React.JSX.Element {
preset: 'weekdays',
time: DEFAULT_TIME,
dayOfWeek: '1',
missedRunGraceMinutes: '720'
customSchedule: '',
missedRunGraceMinutes: '720',
scheduleWarning: null
})
const selected =
@@ -182,6 +204,20 @@ export default function AutomationsPage(): React.JSX.Element {
return () => window.removeEventListener(AUTOMATIONS_CHANGED_EVENT, onAutomationsChanged)
}, [refresh])
useEffect(() => {
const onVisibilityOrFocus = (): void => {
if (document.visibilityState === 'visible') {
void refresh()
}
}
window.addEventListener('focus', onVisibilityOrFocus)
document.addEventListener('visibilitychange', onVisibilityOrFocus)
return () => {
window.removeEventListener('focus', onVisibilityOrFocus)
document.removeEventListener('visibilitychange', onVisibilityOrFocus)
}
}, [refresh])
useEffect(() => {
const completedRuns = runs.filter((run) => {
if (run.status !== 'dispatched' || !run.terminalSessionId) {
@@ -240,11 +276,26 @@ export default function AutomationsPage(): React.JSX.Element {
}
}, [draft.projectId, draft.workspaceId, worktreesByRepo])
const openCreateDialog = (): void => {
const applyTemplateToDraft = useCallback((template: AutomationTemplate): void => {
setDraft((current) => ({
...current,
name: template.name,
prompt: template.prompt,
preset: template.preset,
time: template.time ?? current.time,
dayOfWeek: template.dayOfWeek ?? current.dayOfWeek,
customSchedule: '',
agentId: template.agentId ?? current.agentId,
missedRunGraceMinutes: template.missedRunGraceMinutes ?? current.missedRunGraceMinutes,
scheduleWarning: null
}))
}, [])
const openCreateDialog = (template?: AutomationTemplate): void => {
editRequestRef.current += 1
const target = getDefaultTarget()
setEditingAutomationId(null)
const nextDraft: AutomationDraft = {
const baseDraft: AutomationDraft = {
name: '',
prompt: '',
agentId: defaultAgent,
@@ -255,8 +306,23 @@ export default function AutomationsPage(): React.JSX.Element {
preset: 'weekdays',
time: DEFAULT_TIME,
dayOfWeek: '1',
missedRunGraceMinutes: '720'
customSchedule: '',
missedRunGraceMinutes: '720',
scheduleWarning: null
}
const nextDraft = template
? {
...baseDraft,
name: template.name,
prompt: template.prompt,
preset: template.preset,
time: template.time ?? baseDraft.time,
dayOfWeek: template.dayOfWeek ?? baseDraft.dayOfWeek,
customSchedule: '',
agentId: template.agentId ?? baseDraft.agentId,
missedRunGraceMinutes: template.missedRunGraceMinutes ?? baseDraft.missedRunGraceMinutes
}
: baseDraft
setDraft(nextDraft)
setDraftAtOpen(nextDraft)
setCreateOpen(true)
@@ -275,7 +341,8 @@ export default function AutomationsPage(): React.JSX.Element {
if (requestId !== editRequestRef.current) {
return
}
const schedule = parseAutomationRrule(latest.rrule)
const schedule = tryParseAutomationRrule(latest.rrule)
const hasCustomSchedule = !schedule && isValidAutomationSchedule(latest.rrule)
setEditingAutomationId(latest.id)
const nextDraft: AutomationDraft = {
name: latest.name,
@@ -285,10 +352,15 @@ export default function AutomationsPage(): React.JSX.Element {
workspaceMode: latest.workspaceMode,
workspaceId: latest.workspaceId ?? '',
baseBranch: latest.baseBranch ?? '',
preset: schedule.preset,
time: formatTimeInput(schedule.hour, schedule.minute),
dayOfWeek: String(schedule.dayOfWeek),
missedRunGraceMinutes: String(latest.missedRunGraceMinutes)
preset: schedule?.preset ?? (hasCustomSchedule ? 'custom' : 'weekdays'),
time: schedule ? formatTimeInput(schedule.hour, schedule.minute) : DEFAULT_TIME,
dayOfWeek: String(schedule?.dayOfWeek ?? 1),
customSchedule: hasCustomSchedule ? latest.rrule : '',
missedRunGraceMinutes: String(latest.missedRunGraceMinutes),
scheduleWarning:
schedule || hasCustomSchedule
? null
: 'This automation has an unsupported saved schedule. Pick a supported schedule before saving changes.'
}
setDraft(nextDraft)
setDraftAtOpen(nextDraft)
@@ -334,6 +406,14 @@ export default function AutomationsPage(): React.JSX.Element {
toast.error('Choose a run location and enter a prompt before saving.')
return
}
if (draft.scheduleWarning) {
toast.error('Pick a supported schedule before saving.')
return
}
if (draft.preset === 'custom' && !isValidAutomationSchedule(draft.customSchedule)) {
toast.error('Enter a valid 5-field cron expression before saving.')
return
}
setIsSaving(true)
try {
const selectedWorkspaceExists =
@@ -345,12 +425,15 @@ export default function AutomationsPage(): React.JSX.Element {
}
const now = Date.now()
const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone
const rrule = buildAutomationRrule({
preset: draft.preset,
hour: Number.isFinite(hour) ? hour : 9,
minute: Number.isFinite(minute) ? minute : 0,
dayOfWeek: Number(draft.dayOfWeek)
})
const rrule =
draft.preset === 'custom'
? draft.customSchedule.trim()
: buildAutomationRrule({
preset: draft.preset,
hour: Number.isFinite(hour) ? hour : 9,
minute: Number.isFinite(minute) ? minute : 0,
dayOfWeek: Number(draft.dayOfWeek)
})
const rawMissedRunGraceMinutes = Number(draft.missedRunGraceMinutes)
const missedRunGraceMinutes = Number.isFinite(rawMissedRunGraceMinutes)
? Math.max(0, rawMissedRunGraceMinutes)
@@ -579,7 +662,7 @@ export default function AutomationsPage(): React.JSX.Element {
variant="ghost"
size="icon-sm"
aria-label="Add automation"
onClick={openCreateDialog}
onClick={() => openCreateDialog()}
className="border border-border/50 bg-transparent hover:bg-muted/50"
>
<Plus className="size-4" />
@@ -605,6 +688,7 @@ export default function AutomationsPage(): React.JSX.Element {
onProjectChange={handleProjectChange}
onOpenChange={setCreateOpen}
onDraftChange={setDraft}
onApplyTemplate={applyTemplateToDraft}
onSave={() => void saveAutomation()}
/>
@@ -735,6 +819,12 @@ export default function AutomationsPage(): React.JSX.Element {
<div className="grid min-h-0 flex-1 grid-cols-[minmax(280px,360px)_1fr] overflow-hidden border-t border-border/50">
<section className="flex min-h-0 flex-col border-r border-border/50 bg-muted/20">
<div className="min-h-0 flex-1 overflow-auto p-2">
{automations.length > 0 ? (
<div className="grid grid-cols-[1fr_auto] gap-2 px-2 pb-2 text-[11px] font-medium uppercase text-muted-foreground">
<span>Automation</span>
<span>Next</span>
</div>
) : null}
{automations.map((automation) => {
const automationRepo = repoMap.get(automation.projectId)
const automationWorktree = automation.workspaceId
@@ -742,7 +832,7 @@ export default function AutomationsPage(): React.JSX.Element {
: null
const workspaceLabel =
automation.workspaceMode === 'new_per_run'
? 'New workspace each run'
? `Create from ${automation.baseBranch ?? automationRepo?.worktreeBaseRef ?? 'project default'}`
: (automationWorktree?.displayName ?? 'Missing workspace')
const usageSummary = summarizeAutomationRunUsage(
runs.filter((run) => run.automationId === automation.id)
@@ -755,6 +845,9 @@ export default function AutomationsPage(): React.JSX.Element {
: usageSummary.unavailableRuns > 0
? 'Usage unavailable'
: 'No run usage yet'
const nextRunLabel = automation.enabled
? formatAutomationDateTimeWithRelative(automation.nextRunAt, relativeNow)
: 'Paused'
return (
<ContextMenu key={automation.id}>
<ContextMenuTrigger asChild>
@@ -762,35 +855,50 @@ export default function AutomationsPage(): React.JSX.Element {
type="button"
onClick={() => setSelectedId(automation.id)}
className={cn(
'mb-1 flex w-full flex-col gap-1 rounded-md border px-3 py-2 text-left text-sm transition-colors',
'mb-1 grid w-full grid-cols-[minmax(0,1fr)_auto] gap-3 rounded-md border px-3 py-2 text-left text-sm transition-colors',
selected?.id === automation.id
? 'border-foreground/30 bg-muted/70 text-foreground shadow-sm'
: 'border-transparent hover:bg-muted/50'
)}
>
<span className="font-medium">{automation.name}</span>
<span className="flex min-w-0 items-center gap-1.5 text-xs text-muted-foreground">
{automationRepo ? (
<RepoDotLabel
name={automationRepo.displayName}
color={automationRepo.badgeColor}
dotClassName="size-1.5"
<span className="min-w-0">
<span className="flex min-w-0 items-center gap-2">
<span
className={cn(
'size-2 rounded-full',
automation.enabled ? 'bg-foreground' : 'bg-muted-foreground/40'
)}
/>
) : (
<span>Unknown project</span>
)}
<span className="shrink-0">/</span>
<span className="truncate">{workspaceLabel}</span>
<span className="truncate font-medium">{automation.name}</span>
</span>
<span className="mt-1 flex min-w-0 items-center gap-1.5 text-xs text-muted-foreground">
{automationRepo ? (
<RepoDotLabel
name={automationRepo.displayName}
color={automationRepo.badgeColor}
dotClassName="size-1.5"
/>
) : (
<span>Unknown project</span>
)}
<span className="shrink-0">/</span>
<span className="truncate">{workspaceLabel}</span>
</span>
<span className="mt-1 flex min-w-0 items-center gap-2 text-xs text-muted-foreground">
<span className="truncate">
{formatAutomationSchedule(automation.rrule)}
</span>
<span className="shrink-0">·</span>
<span className="truncate">{getAgentLabel(automation.agentId)}</span>
</span>
<span className="mt-1 block truncate text-xs text-muted-foreground">
{usageText}
</span>
</span>
<span className="text-xs text-muted-foreground">
{automation.enabled
? `Next run ${formatAutomationDateTimeWithRelative(
automation.nextRunAt,
relativeNow
)}`
: 'Paused'}
<span className="flex max-w-28 flex-col items-end gap-1 text-right text-xs text-muted-foreground">
<Clock className="size-3.5" />
<span className="line-clamp-2">{nextRunLabel}</span>
</span>
<span className="text-xs text-muted-foreground">{usageText}</span>
</button>
</ContextMenuTrigger>
<ContextMenuContent className="w-48">
@@ -823,7 +931,34 @@ export default function AutomationsPage(): React.JSX.Element {
)
})}
{automations.length === 0 ? (
<div className="p-4 text-sm text-muted-foreground">No automations yet.</div>
<div className="grid gap-2 p-2">
<div className="px-1 pb-1 text-sm font-medium">Start from a template</div>
{AUTOMATION_TEMPLATES.map((template) => (
<button
key={template.id}
type="button"
onClick={() => openCreateDialog(template)}
className="rounded-md border border-border/70 bg-background px-3 py-2 text-left shadow-xs transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50"
>
<div className="text-[11px] font-medium uppercase text-muted-foreground">
{template.category}
</div>
<div className="mt-1 text-sm font-medium">{template.label}</div>
<div className="mt-1 line-clamp-2 text-xs text-muted-foreground">
{template.description}
</div>
</button>
))}
<Button
type="button"
variant="outline"
className="mt-1 w-full justify-start"
onClick={() => openCreateDialog()}
>
<Plus className="size-4" />
Add new
</Button>
</div>
) : null}
</div>
</section>
@@ -28,12 +28,14 @@ export function CreateFromPicker({
repoMap,
worktrees,
value,
triggerClassName,
onValueChange
}: {
repoId: string
repoMap: Map<string, Repo>
worktrees: Worktree[]
value: string
triggerClassName?: string
onValueChange: (baseBranch: string) => void
}): React.JSX.Element {
const activeRuntimeEnvironmentId = useAppStore(
@@ -147,7 +149,7 @@ export function CreateFromPicker({
variant="outline"
role="combobox"
aria-expanded={open}
className="h-9 w-full justify-between px-3 text-sm font-normal"
className={cn('h-9 w-full justify-between px-3 text-sm font-normal', triggerClassName)}
>
<span className="truncate">{selectedLabel}</span>
<ChevronsUpDown className="size-4 opacity-50" />
@@ -15,10 +15,12 @@ import type { Worktree } from '../../../../shared/types'
export function WorkspaceCombobox({
worktrees,
value,
triggerClassName,
onValueChange
}: {
worktrees: Worktree[]
value: string
triggerClassName?: string
onValueChange: (workspaceId: string) => void
}): React.JSX.Element {
const [open, setOpen] = React.useState(false)
@@ -41,7 +43,7 @@ export function WorkspaceCombobox({
variant="outline"
role="combobox"
aria-expanded={open}
className="h-9 w-full justify-between px-3 text-sm font-normal"
className={cn('h-9 w-full justify-between px-3 text-sm font-normal', triggerClassName)}
>
<span className={cn('truncate', !selected && 'text-muted-foreground')}>
{selected?.displayName ?? 'Select workspace'}
@@ -0,0 +1,68 @@
import type { AutomationSchedulePreset } from '../../../../shared/automations-types'
import type { TuiAgent } from '../../../../shared/types'
export type AutomationTemplate = {
id: string
category: string
label: string
description: string
name: string
prompt: string
preset: AutomationSchedulePreset
time?: string
dayOfWeek?: string
agentId?: TuiAgent
missedRunGraceMinutes?: string
}
export const AUTOMATION_TEMPLATES: AutomationTemplate[] = [
{
id: 'repo-health-weekday',
category: 'Repo health',
label: 'Weekday repo audit',
description: 'Check dependencies, failing tests, and risky open changes each weekday.',
name: 'Weekday repo audit',
prompt:
'Review the repository health. Check dependency updates, failing tests, lint/typecheck status, and risky open changes. Summarize findings and suggest the next action.',
preset: 'weekdays',
time: '09:00',
missedRunGraceMinutes: '720'
},
{
id: 'release-prep-weekly',
category: 'Release prep',
label: 'Release readiness',
description: 'Prepare a weekly release risk summary from the current project state.',
name: 'Release readiness review',
prompt:
'Prepare a release readiness summary. Look for blockers, unmerged risky changes, missing validation, and documentation gaps. End with a concise release/no-release recommendation.',
preset: 'weekly',
time: '14:00',
dayOfWeek: '4',
missedRunGraceMinutes: '1440'
},
{
id: 'recurring-review-daily',
category: 'Recurring review',
label: 'Daily change review',
description: 'Scan recent work and call out correctness, UX, and test coverage risks.',
name: 'Daily change review',
prompt:
'Review recent changes in this workspace. Focus on correctness risks, UX regressions, missing tests, and follow-up tasks. Keep the report short and actionable.',
preset: 'daily',
time: '16:30',
missedRunGraceMinutes: '180'
},
{
id: 'maintenance-hourly',
category: 'Maintenance',
label: 'Hourly queue check',
description: 'Look for stuck work, stale generated files, and failed local validation.',
name: 'Hourly maintenance check',
prompt:
'Check for stuck work, stale generated files, failing validation, and anything that needs human attention. Report only actionable issues.',
preset: 'hourly',
time: '00:15',
missedRunGraceMinutes: '30'
}
]
+70 -1
View File
@@ -1,9 +1,12 @@
import { describe, expect, it } from 'vitest'
import {
buildAutomationRrule,
formatAutomationSchedule,
isValidAutomationSchedule,
latestAutomationOccurrenceAtOrBefore,
nextAutomationOccurrenceAfter,
parseAutomationRrule
parseAutomationRrule,
tryParseAutomationRrule
} from './automation-schedules'
describe('automation schedules', () => {
@@ -48,4 +51,70 @@ describe('automation schedules', () => {
dayOfWeek: 0
})
})
it('rejects malformed weekly BYDAY instead of remapping to Sunday', () => {
expect(() => parseAutomationRrule('FREQ=WEEKLY;BYDAY=NO;BYHOUR=10;BYMINUTE=15')).toThrow(
'Invalid recurrence day.'
)
expect(tryParseAutomationRrule('FREQ=WEEKLY;BYDAY=MO,NO;BYHOUR=10;BYMINUTE=15')).toBeNull()
})
it('formats invalid schedules with a safe fallback label', () => {
expect(formatAutomationSchedule('FREQ=YEARLY')).toBe('Invalid schedule')
})
it('formats hourly schedules using the stored minute', () => {
expect(formatAutomationSchedule('FREQ=HOURLY;BYMINUTE=5')).toBe('Hourly at :05')
})
it('computes custom cron schedules', () => {
const next = nextAutomationOccurrenceAfter(
'15 10 * * 1-5',
new Date('2026-05-01T00:00:00').getTime(),
new Date('2026-05-15T12:00:00').getTime()
)
expect(next).toBe(new Date('2026-05-18T10:15:00').getTime())
const latest = latestAutomationOccurrenceAtOrBefore(
'15 10 * * 1-5',
new Date('2026-05-01T00:00:00').getTime(),
new Date('2026-05-15T12:00:00').getTime()
)
expect(latest).toBe(new Date('2026-05-15T10:15:00').getTime())
})
it('labels valid custom cron schedules without treating them as invalid', () => {
expect(formatAutomationSchedule('*/30 9-17 * * MON-FRI')).toBe(
'Custom cron: */30 9-17 * * MON-FRI'
)
})
it('treats all-value cron day fields as unrestricted for DOM/DOW matching', () => {
const next = nextAutomationOccurrenceAfter(
'0 9 */1 * MON',
new Date('2026-05-01T00:00:00').getTime(),
new Date('2026-05-15T12:00:00').getTime()
)
expect(next).toBe(new Date('2026-05-18T09:00:00').getTime())
expect(isValidAutomationSchedule('0 9 * * 0-7')).toBe(true)
expect(isValidAutomationSchedule('0 9 * * 1-7')).toBe(true)
})
it('rejects cron fields with malformed separators', () => {
expect(isValidAutomationSchedule('*/15/2 9 * * *')).toBe(false)
expect(isValidAutomationSchedule('0 9 1--5 * *')).toBe(false)
})
it('rejects syntactically valid cron schedules with no possible run', () => {
expect(isValidAutomationSchedule('0 0 31 2 *')).toBe(false)
})
it('finds rare but valid leap-day custom cron schedules', () => {
const next = nextAutomationOccurrenceAfter(
'0 0 29 2 *',
new Date('2026-05-01T00:00:00').getTime(),
new Date('2026-05-15T12:00:00').getTime()
)
expect(next).toBe(new Date('2028-02-29T00:00:00').getTime())
})
})
+303 -12
View File
@@ -1,18 +1,63 @@
/* eslint-disable max-lines -- Why: automation scheduling needs RRULE presets and
* custom cron parsing to share one execution path for main/renderer parity. */
import type { AutomationSchedulePreset } from './automations-types'
const DAY_MS = 24 * 60 * 60 * 1000
const HOUR_MS = 60 * 60 * 1000
const MINUTE_MS = 60 * 1000
// Why: valid cron expressions like Feb 29 can have an 8-year gap across non-leap centuries.
const CRON_SCAN_DAYS = 9 * 366
const CRON_SCAN_MINUTES = CRON_SCAN_DAYS * 24 * 60
type ParsedRule = {
type ParsedRrule = {
kind: 'rrule'
freq: 'HOURLY' | 'DAILY' | 'WEEKLY'
byDay: string[]
byHour: number
byMinute: number
}
const DAY_CODES = ['SU', 'MO', 'TU', 'WE', 'TH', 'FR', 'SA'] as const
type ParsedCron = {
kind: 'cron'
minutes: Set<number>
hours: Set<number>
daysOfMonth: Set<number>
months: Set<number>
daysOfWeek: Set<number>
dayOfMonthRestricted: boolean
dayOfWeekRestricted: boolean
}
function parseRrule(rrule: string): ParsedRule {
type ParsedSchedule = ParsedRrule | ParsedCron
const DAY_CODES = ['SU', 'MO', 'TU', 'WE', 'TH', 'FR', 'SA'] as const
const WEEKDAY_CODES = ['MO', 'TU', 'WE', 'TH', 'FR'] as const
const MONTH_NAMES = new Map([
['JAN', 1],
['FEB', 2],
['MAR', 3],
['APR', 4],
['MAY', 5],
['JUN', 6],
['JUL', 7],
['AUG', 8],
['SEP', 9],
['OCT', 10],
['NOV', 11],
['DEC', 12]
])
const DAY_NAMES = new Map<string, number>([
...DAY_CODES.map((code, index) => [code, index] as const),
['SUN', 0],
['MON', 1],
['TUE', 2],
['WED', 3],
['THU', 4],
['FRI', 5],
['SAT', 6]
])
function parseRrule(rrule: string): ParsedRrule {
const entries = new Map<string, string>()
for (const part of rrule.split(';')) {
const [key, value] = part.split('=')
@@ -33,7 +78,139 @@ function parseRrule(rrule: string): ParsedRule {
throw new Error('Invalid recurrence minute.')
}
const byDay = (entries.get('BYDAY') ?? '').split(',').filter(Boolean)
return { freq, byDay, byHour, byMinute }
return { kind: 'rrule', freq, byDay, byHour, byMinute }
}
function parseCronNumber(value: string, names: Map<string, number> | null, field: string): number {
const normalized = value.toUpperCase()
const named = names?.get(normalized)
const parsed = named ?? Number(normalized)
if (!Number.isInteger(parsed)) {
throw new Error(`Invalid cron ${field}.`)
}
return parsed
}
function parseCronField(args: {
value: string
min: number
max: number
field: string
names?: Map<string, number>
normalize?: (value: number) => number
}): Set<number> {
const result = new Set<number>()
for (const rawPart of args.value.split(',')) {
const part = rawPart.trim()
if (!part) {
throw new Error(`Invalid cron ${args.field}.`)
}
const stepParts = part.split('/')
if (stepParts.length > 2) {
throw new Error(`Invalid cron ${args.field}.`)
}
const [rangePart, stepPart] = stepParts
if (!rangePart) {
throw new Error(`Invalid cron ${args.field}.`)
}
const step = stepPart === undefined ? 1 : Number(stepPart)
if (!Number.isInteger(step) || step < 1) {
throw new Error(`Invalid cron ${args.field}.`)
}
let start: number
let end: number
if (rangePart === '*') {
start = args.min
end = args.max
} else if (rangePart.includes('-')) {
const rangeParts = rangePart.split('-')
if (rangeParts.length !== 2 || !rangeParts[0] || !rangeParts[1]) {
throw new Error(`Invalid cron ${args.field}.`)
}
const [startPart, endPart] = rangeParts
start = parseCronNumber(startPart, args.names ?? null, args.field)
end = parseCronNumber(endPart, args.names ?? null, args.field)
} else {
start = parseCronNumber(rangePart, args.names ?? null, args.field)
end = start
}
const normalizedStart = args.normalize?.(start) ?? start
const normalizedEnd = args.normalize?.(end) ?? end
if (
start < args.min ||
start > args.max ||
end < args.min ||
end > args.max ||
normalizedStart < args.min ||
normalizedStart > args.max ||
normalizedEnd < args.min ||
normalizedEnd > args.max ||
start > end
) {
throw new Error(`Invalid cron ${args.field}.`)
}
for (let value = start; value <= end; value += step) {
result.add(args.normalize?.(value) ?? value)
}
}
if (result.size === 0) {
throw new Error(`Invalid cron ${args.field}.`)
}
return result
}
function parseCronExpression(expression: string): ParsedCron {
const parts = expression.trim().split(/\s+/)
if (parts.length !== 5) {
throw new Error('Cron schedule must have five fields.')
}
const [minute, hour, dayOfMonth, month, dayOfWeek] = parts
const daysOfMonth = parseCronField({
value: dayOfMonth,
min: 1,
max: 31,
field: 'day of month'
})
const daysOfWeek = parseCronField({
value: dayOfWeek,
min: 0,
max: 7,
field: 'day of week',
names: DAY_NAMES,
normalize: (value) => (value === 7 ? 0 : value)
})
return {
kind: 'cron',
minutes: parseCronField({ value: minute, min: 0, max: 59, field: 'minute' }),
hours: parseCronField({ value: hour, min: 0, max: 23, field: 'hour' }),
daysOfMonth,
months: parseCronField({ value: month, min: 1, max: 12, field: 'month', names: MONTH_NAMES }),
daysOfWeek,
dayOfMonthRestricted: daysOfMonth.size !== 31,
dayOfWeekRestricted: daysOfWeek.size !== 7
}
}
function parseSchedule(schedule: string): ParsedSchedule {
const trimmed = schedule.trim()
if (trimmed.includes('=')) {
return parseRrule(trimmed)
}
return parseCronExpression(trimmed)
}
export function isValidAutomationSchedule(schedule: string): boolean {
try {
const parsed = parseSchedule(schedule)
if (parsed.kind === 'cron' && !cronHasPossibleOccurrence(parsed, Date.now())) {
throw new Error('Cron schedule has no possible run.')
}
return true
} catch {
return false
}
}
export function parseAutomationRrule(rrule: string): {
@@ -49,18 +226,65 @@ export function parseAutomationRrule(rrule: string): {
if (rule.freq === 'DAILY') {
return { preset: 'daily', hour: rule.byHour, minute: rule.byMinute, dayOfWeek: 1 }
}
if (rule.byDay.join(',') === 'MO,TU,WE,TH,FR') {
if (rule.byDay.join(',') === WEEKDAY_CODES.join(',')) {
return { preset: 'weekdays', hour: rule.byHour, minute: rule.byMinute, dayOfWeek: 1 }
}
const dayCode = rule.byDay[0] ?? 'MO'
if (rule.byDay.length !== 1) {
throw new Error('Invalid recurrence day.')
}
const dayCode = rule.byDay[0]
const dayOfWeek = DAY_CODES.indexOf(dayCode as (typeof DAY_CODES)[number])
if (dayOfWeek < 0) {
throw new Error('Invalid recurrence day.')
}
return {
preset: 'weekly',
hour: rule.byHour,
minute: rule.byMinute,
dayOfWeek: Math.max(0, DAY_CODES.indexOf(dayCode as (typeof DAY_CODES)[number]))
dayOfWeek
}
}
export function tryParseAutomationRrule(
rrule: string
): ReturnType<typeof parseAutomationRrule> | null {
try {
return parseAutomationRrule(rrule)
} catch {
return null
}
}
function formatTime(hour: number, minute: number): string {
const date = new Date()
date.setHours(hour, minute, 0, 0)
return new Intl.DateTimeFormat(undefined, {
hour: 'numeric',
minute: '2-digit'
}).format(date)
}
export function formatAutomationSchedule(rrule: string): string {
const schedule = tryParseAutomationRrule(rrule)
if (!schedule) {
return isValidAutomationSchedule(rrule) ? `Custom cron: ${rrule.trim()}` : 'Invalid schedule'
}
if (schedule.preset === 'hourly') {
return `Hourly at :${String(schedule.minute).padStart(2, '0')}`
}
const time = formatTime(schedule.hour, schedule.minute)
if (schedule.preset === 'daily') {
return `Daily at ${time}`
}
if (schedule.preset === 'weekdays') {
return `Weekdays at ${time}`
}
const day = new Intl.DateTimeFormat(undefined, { weekday: 'long' }).format(
new Date(2026, 0, 4 + schedule.dayOfWeek)
)
return `${day}s at ${time}`
}
function atLocalTime(dayMs: number, hour: number, minute: number): number {
const date = new Date(dayMs)
date.setHours(hour, minute, 0, 0)
@@ -73,7 +297,7 @@ function startOfLocalDay(timestamp: number): number {
return date.getTime()
}
function dayMatches(rule: ParsedRule, timestamp: number): boolean {
function dayMatches(rule: ParsedRrule, timestamp: number): boolean {
if (rule.freq === 'DAILY') {
return true
}
@@ -81,7 +305,7 @@ function dayMatches(rule: ParsedRule, timestamp: number): boolean {
return rule.byDay.includes(code)
}
function scanDayCandidates(rule: ParsedRule, anchor: number, direction: 1 | -1): number | null {
function scanDayCandidates(rule: ParsedRrule, anchor: number, direction: 1 | -1): number | null {
let day = startOfLocalDay(anchor)
for (let i = 0; i < 370; i += 1) {
const candidate = atLocalTime(day, rule.byHour, rule.byMinute)
@@ -98,8 +322,46 @@ function scanDayCandidates(rule: ParsedRule, anchor: number, direction: 1 | -1):
return null
}
function floorToMinute(timestamp: number): number {
const date = new Date(timestamp)
date.setSeconds(0, 0)
return date.getTime()
}
function cronMatches(rule: ParsedCron, timestamp: number): boolean {
if (!cronDateMatches(rule, timestamp)) {
return false
}
const date = new Date(timestamp)
return rule.hours.has(date.getHours()) && rule.minutes.has(date.getMinutes())
}
function cronDateMatches(rule: ParsedCron, timestamp: number): boolean {
const date = new Date(timestamp)
if (!rule.months.has(date.getMonth() + 1)) {
return false
}
const dayOfMonthMatches = rule.daysOfMonth.has(date.getDate())
const dayOfWeekMatches = rule.daysOfWeek.has(date.getDay())
if (rule.dayOfMonthRestricted && rule.dayOfWeekRestricted) {
return dayOfMonthMatches || dayOfWeekMatches
}
return dayOfMonthMatches && dayOfWeekMatches
}
function cronHasPossibleOccurrence(rule: ParsedCron, anchor: number): boolean {
let day = startOfLocalDay(anchor)
for (let i = 0; i < CRON_SCAN_DAYS; i += 1) {
if (cronDateMatches(rule, day)) {
return true
}
day += DAY_MS
}
return false
}
export function buildAutomationRrule(args: {
preset: AutomationSchedulePreset
preset: Exclude<AutomationSchedulePreset, 'custom'>
hour: number
minute: number
dayOfWeek?: number
@@ -124,7 +386,26 @@ export function nextAutomationOccurrenceAfter(
dtstart: number,
after: number
): number {
const rule = parseRrule(rrule)
const rule = parseSchedule(rrule)
if (rule.kind === 'cron') {
let candidate = floorToMinute(Math.max(dtstart, after))
if (candidate <= after) {
candidate += MINUTE_MS
}
if (candidate < dtstart) {
candidate = floorToMinute(dtstart)
if (candidate < dtstart) {
candidate += MINUTE_MS
}
}
for (let i = 0; i < CRON_SCAN_MINUTES; i += 1) {
if (cronMatches(rule, candidate)) {
return candidate
}
candidate += MINUTE_MS
}
throw new Error('Unable to compute next automation run.')
}
if (rule.freq === 'HOURLY') {
const start = Math.max(dtstart, after)
const base = new Date(start)
@@ -150,7 +431,17 @@ export function latestAutomationOccurrenceAtOrBefore(
if (now < dtstart) {
return null
}
const rule = parseRrule(rrule)
const rule = parseSchedule(rrule)
if (rule.kind === 'cron') {
let candidate = floorToMinute(now)
for (let i = 0; i < CRON_SCAN_MINUTES && candidate >= dtstart; i += 1) {
if (cronMatches(rule, candidate)) {
return candidate
}
candidate -= MINUTE_MS
}
return null
}
if (rule.freq === 'HOURLY') {
const base = new Date(now)
base.setMinutes(rule.byMinute, 0, 0)
+1 -1
View File
@@ -15,7 +15,7 @@ export type AutomationRunStatus =
| 'dispatch_failed'
export type AutomationRunTrigger = 'scheduled' | 'manual'
export type AutomationSchedulePreset = 'hourly' | 'daily' | 'weekdays' | 'weekly'
export type AutomationSchedulePreset = 'hourly' | 'daily' | 'weekdays' | 'weekly' | 'custom'
export type AutomationRunUsageProvider = 'claude' | 'codex'
export type AutomationRunUsageStatus = 'known' | 'unavailable'
export type AutomationRunUsageAttribution = 'provider_session_time_window'