mirror of
https://github.com/stablyai/orca.git
synced 2026-09-28 08:02:43 +00:00
Improve automations scheduling UX (#1997)
* Improve automations scheduling UX * fix: address review findings
This commit is contained in:
@@ -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'
|
||||
}
|
||||
]
|
||||
@@ -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())
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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'
|
||||
|
||||
Reference in New Issue
Block a user