Improve automation schedule UI (#3936)
This commit is contained in:
parent
7f770ae559
commit
d69c288e94
|
|
@ -0,0 +1,112 @@
|
|||
# Automation Schedule UI Refresh
|
||||
|
||||
## Problem
|
||||
|
||||
- `AutomationSchedulePicker.tsx` puts cadence, custom schedule editing, weekly day, and a native `type="time"` input into one narrow popover. The native time input is an inconsistent Chromium control and is the weakest part of the automation editor.
|
||||
- `Custom cron` is exposed as a normal creation choice even though cron is storage/provider syntax. Orca still needs to parse cron for imported, external, and legacy schedules, but new users should choose a schedule, not write cron.
|
||||
- `formatAutomationSchedule` currently returns `Custom cron: ...` for every valid cron expression, leaking raw schedule syntax into list/detail surfaces and Hermes run output.
|
||||
- User-facing copy calls Hermes jobs "cron" in dialog titles, warnings, toasts, and source empty states.
|
||||
- Local and external automation rows show schedule, next run, location, agent, and usage, but the scan order does not make the automation behavior obvious.
|
||||
|
||||
## Goal
|
||||
|
||||
Make automations read as scheduled agent runs:
|
||||
|
||||
1. Replace native time input with Orca-styled controls for cadence, run time, weekly day, and hourly minute.
|
||||
2. Hide custom schedules from the normal create path while preserving load/edit/save for existing custom schedules.
|
||||
3. Format supported cron shapes with friendly labels and hide unsupported valid cron behind `Custom schedule`.
|
||||
4. Remove provider-cron wording from user-facing copy.
|
||||
5. Improve list/detail scan order for local and external automations without changing scheduler semantics or adding timezone UI.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- Do not change persisted automation schema, IPC payloads, scheduler ownership, RRULE generation, cron execution semantics, missed-run grace, or SSH dispatch behavior.
|
||||
- Do not add timezone selection or timezone explanations.
|
||||
- Do not remove valid custom schedule support for existing Orca automations or editable Hermes jobs.
|
||||
- Do not add custom recurrence builders beyond the existing presets.
|
||||
- Do not redesign run history.
|
||||
|
||||
## Design
|
||||
|
||||
### 1. Structured Schedule Picker
|
||||
|
||||
- Keep `AutomationSchedulePicker` controlled by `AutomationDraft`; do not add persisted fields.
|
||||
- For normal schedules, render a cadence `Select` with only `hourly`, `daily`, `weekdays`, and `weekly`. Do not mount a `Select` whose value is `custom` after removing the custom item.
|
||||
- Use existing shadcn primitives: `Select` for cadence/day/hour/minute, `ToggleGroup` or `Select` for AM/PM, `Button` for actions, and the current `Popover`.
|
||||
- Store all changes back into the existing draft shape:
|
||||
- `draft.preset` remains the source of cadence.
|
||||
- `draft.time` remains `HH:mm`.
|
||||
- `draft.dayOfWeek` remains `0`-`6`.
|
||||
- hourly schedules use only the minute from `draft.time`; changing hourly controls must not make the hour meaningful.
|
||||
- Minute controls must support every minute `0`-`59`, not only 5-minute steps. Existing unusual minutes must round-trip visibly.
|
||||
- Use compact responsive grids with `minmax(0, 1fr)` so controls shrink instead of overflowing the dialog/popover.
|
||||
- Keep the trigger label derived from `formatAutomationSchedule(buildAutomationRrule(...))` for non-custom drafts.
|
||||
|
||||
### 2. Advanced Schedule Fallback
|
||||
|
||||
- When `draft.preset === 'custom'`, render an `Advanced schedule` panel instead of the normal cadence controls.
|
||||
- Show the saved expression in an editable field and save the raw trimmed value unchanged when valid. Local Orca advanced schedules should validate with `isValidAutomationSchedule`; provider-backed editors may pass a stricter validator.
|
||||
- Copy must say "advanced schedule", not "cron". Inline invalid text and save toasts should be `Enter a valid advanced schedule before saving.`
|
||||
- Include a secondary `Use simple schedule` action that switches to a supported preset and clears `scheduleWarning`. It may default to weekdays unless the custom expression is classified into a simple preset; either way, do not rewrite the custom schedule until the user explicitly switches.
|
||||
- Existing unsupported schedules still open on the current warning path: `scheduleWarning` blocks save until the user picks a supported preset. Only schedule-picker actions should clear that warning.
|
||||
|
||||
### 3. Shared Schedule Formatting
|
||||
|
||||
- Extend `formatAutomationSchedule(schedule)` to handle both RRULE and cron input; rename the parameter internally if helpful.
|
||||
- RRULE formatting stays unchanged for current presets.
|
||||
- Cron formatting should use the parsed cron sets, not regexes over the original string, so names, ranges, lists, and `7` as Sunday normalize consistently.
|
||||
- Keep cron parsing in `src/shared/automation-schedules.ts` and expose a small cron-only validator/classifier for provider code paths. Do not duplicate a second cron parser in renderer components.
|
||||
- Friendly-format only these cron shapes:
|
||||
- hourly: one minute, all hours, unrestricted day-of-month, month, and day-of-week.
|
||||
- daily: one minute, one hour, unrestricted day-of-month, month, and day-of-week.
|
||||
- weekdays: one minute, one hour, unrestricted day-of-month/month, day-of-week exactly Monday-Friday.
|
||||
- weekly: one minute, one hour, unrestricted day-of-month/month, day-of-week exactly one day.
|
||||
- Return `Custom schedule` for all other valid cron expressions, including intervals, multiple hours/minutes, monthly/yearly restrictions, and cron rules with both day-of-month and day-of-week restricted. Those use cron OR semantics and must not be mislabeled as weekly/daily.
|
||||
- Return `Invalid schedule` for invalid or impossible schedules.
|
||||
- Add focused tests for simple cron labels, `MON-FRI`, Sunday via `7`, interval cron falling back to `Custom schedule`, monthly cron falling back, DOM+DOW OR cron falling back, invalid schedules, and the cron-only helper rejecting RRULE input.
|
||||
- Replace duplicate schedule-description logic in `HermesCronOutputView` with the shared formatter or make it call the same classifier. Schedule metadata should show the friendly label as the visible value; keep the raw provider string only as secondary detail such as a tooltip when useful.
|
||||
|
||||
### 4. Hermes And External Interop
|
||||
|
||||
- User copy should say `Hermes automation`, not `Hermes cron`, in titles, warnings, toasts, source empty states, and same-host validation.
|
||||
- Keep cron strings internally for Hermes create/update. `buildHermesCronSchedule` can keep returning a 5-field schedule; this is provider payload, not user copy.
|
||||
- For Hermes saves, preset schedules must be converted to 5-field cron before the IPC call. A custom advanced expression must pass the shared cron-only provider validator before calling `createExternal`/`updateExternal`; `isValidAutomationSchedule` alone is too broad because it accepts Orca RRULEs.
|
||||
- For external rows/detail, prefer `job.rawSchedule` when it parses and can be displayed without losing provider context, then format it with `formatAutomationSchedule`. Fall back to provider `job.schedule` when raw schedule is missing, provider-specific, or paired with context that the raw expression omits, such as an OpenClaw cron timezone. Do not feed arbitrary provider display strings into validation just to show a label.
|
||||
- External edit must preserve SSH target compatibility: keep the current same-host check between the selected workspace repo connection and the external manager target before calling `createExternal`/`updateExternal`.
|
||||
- After external create/update/action, refresh from `listExternalManagers`; external mutation APIs do not return a normalized job payload.
|
||||
|
||||
### 5. List And Detail Scan Order
|
||||
|
||||
- Local list rows should scan as: name/status, schedule, next run, then quieter metadata for location/agent and usage. Keep the right-side next-run affordance, but make the schedule the primary secondary line.
|
||||
- External list rows and `ExternalAutomationManagers` should use the same schedule display helper when a raw schedule can be shown safely, with provider/location/run-count as quieter metadata.
|
||||
- Detail should group behavior before usage: schedule, next run, run location, session mode, and grace should sit together before cost/tokens/usage coverage.
|
||||
- Preserve the existing SSH availability warning in detail. Do not add timezone selection or explanatory timezone copy.
|
||||
- Use existing tokens, compact typography, lucide icons, and shadcn primitives from `docs/STYLEGUIDE.md`; do not add new colors, shadows, or card nesting.
|
||||
|
||||
## Consistency Requirements
|
||||
|
||||
- Create/update local automations should continue to refresh after save and select the saved automation.
|
||||
- Editing a local automation should keep the current latest-before-open and latest-before-save checks so non-schedule edits do not reset `dtstart` or `nextRunAt`.
|
||||
- Derived labels should be computed from current `automation.rrule` or external job data during render, not cached in draft state.
|
||||
- Focus/visibility and `AUTOMATIONS_CHANGED_EVENT` refresh behavior should remain intact so external mutations and scheduler changes update list/detail surfaces.
|
||||
- On external mutation failure, refresh before leaving the stale editor state visible when possible.
|
||||
|
||||
## Edge Cases
|
||||
|
||||
- Existing valid custom schedules must open as advanced schedules and save unchanged if the user makes no schedule change.
|
||||
- Existing unsupported schedules must remain blocked by `scheduleWarning` until the user chooses a supported schedule.
|
||||
- Non-5-minute values, midnight/noon, and Sunday weekly schedules must round-trip.
|
||||
- Hourly schedules must ignore the stored hour.
|
||||
- Cron with restricted month/day-of-month, multiple run times, or DOM+DOW restrictions must not receive misleading friendly labels.
|
||||
- Local, SSH, Hermes, and OpenClaw rows must tolerate missing worktrees, disconnected SSH sources, missing raw schedules, and provider-specific display strings.
|
||||
- Small dialog widths must not overflow schedule labels or controls.
|
||||
|
||||
## Rollout
|
||||
|
||||
1. Update shared schedule formatting and tests.
|
||||
2. Replace picker controls and advanced fallback copy.
|
||||
3. Update automation dialog/save/external copy.
|
||||
4. Update local and external list/detail schedule scan layout.
|
||||
5. Remove duplicated Hermes output schedule formatting.
|
||||
6. Run focused schedule tests, then lint/typecheck.
|
||||
7. Validate create/edit UI in Electron for local, SSH, existing custom, unsupported saved schedule, and Hermes edit flows.
|
||||
|
|
@ -30,7 +30,7 @@ function DetailMetric({ label, value }: { label: string; value: string }): React
|
|||
return (
|
||||
<div className="min-w-0">
|
||||
<div className="text-[11px] font-medium uppercase text-muted-foreground">{label}</div>
|
||||
<div className="mt-1 text-sm font-medium">{value}</div>
|
||||
<div className="mt-1 break-words text-sm font-medium">{value}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -104,6 +104,12 @@ export function AutomationDetail({
|
|||
: usageSummary.unavailableRuns > 0
|
||||
? 'Unavailable'
|
||||
: 'No runs'
|
||||
const agentLabel =
|
||||
AGENT_CATALOG.find((agent) => agent.id === automation.agentId)?.label ?? automation.agentId
|
||||
const runLocationLabel =
|
||||
automation.workspaceMode === 'new_per_run'
|
||||
? (automation.baseBranch ?? projectDefaultBaseRef ?? 'Project default')
|
||||
: workspaceName
|
||||
|
||||
return (
|
||||
<div className="flex w-full flex-col gap-4">
|
||||
|
|
@ -150,7 +156,8 @@ export function AutomationDetail({
|
|||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="grid grid-cols-6 gap-6 rounded-md border border-border/50 bg-muted/30 px-4 py-3 shadow-sm">
|
||||
<div className="grid grid-cols-[repeat(auto-fit,minmax(9rem,1fr))] gap-5 rounded-md border border-border/50 bg-muted/30 px-4 py-3 shadow-sm">
|
||||
<DetailMetric label="Schedule" value={formatAutomationSchedule(automation.rrule)} />
|
||||
<DetailMetric
|
||||
label="Next run"
|
||||
value={
|
||||
|
|
@ -159,6 +166,25 @@ export function AutomationDetail({
|
|||
: 'Paused'
|
||||
}
|
||||
/>
|
||||
<DetailMetric
|
||||
label={automation.workspaceMode === 'new_per_run' ? 'Create from' : 'Run location'}
|
||||
value={runLocationLabel}
|
||||
/>
|
||||
<DetailMetric
|
||||
label="Session"
|
||||
value={automation.reuseSession ? 'Reuse live session' : 'Fresh each run'}
|
||||
/>
|
||||
<DetailMetric label="Grace" value={formatGrace(automation.missedRunGraceMinutes)} />
|
||||
<div className="min-w-0">
|
||||
<div className="text-[11px] font-medium uppercase text-muted-foreground">Agent</div>
|
||||
<div className="mt-1 flex min-w-0 items-center gap-2 text-sm font-medium">
|
||||
<AgentIcon agent={automation.agentId} size={16} />
|
||||
<span className="truncate">{agentLabel}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-[repeat(auto-fit,minmax(9rem,1fr))] gap-5 rounded-md border border-border/50 bg-muted/20 px-4 py-3 shadow-sm">
|
||||
<DetailMetric
|
||||
label="Last run"
|
||||
value={formatAutomationDateTimeWithRelative(automation.lastRunAt, now)}
|
||||
|
|
@ -169,37 +195,12 @@ export function AutomationDetail({
|
|||
/>
|
||||
<DetailMetric label="Tokens" value={formatAutomationTokens(usageSummary.totalTokens)} />
|
||||
<DetailMetric label="Usage coverage" value={usageCoverage} />
|
||||
<DetailMetric label="Grace" value={formatGrace(automation.missedRunGraceMinutes)} />
|
||||
</div>
|
||||
|
||||
<div className="rounded-md border border-border/50 bg-muted/20 shadow-sm">
|
||||
<div className="border-b border-border/50 px-3 py-2 text-sm font-medium">Configuration</div>
|
||||
<div className="grid grid-cols-[minmax(0,1fr)_minmax(0,1fr)] gap-x-6 gap-y-4 px-3 py-3">
|
||||
<div className="border-b border-border/50 px-3 py-2 text-sm font-medium">Prompt</div>
|
||||
<div className="px-3 py-3">
|
||||
<div className="min-w-0">
|
||||
<div className="text-[11px] font-medium uppercase text-muted-foreground">Agent</div>
|
||||
<div className="mt-1 flex min-w-0 items-center gap-2 text-sm font-medium">
|
||||
<AgentIcon agent={automation.agentId} size={16} />
|
||||
<span className="truncate">
|
||||
{AGENT_CATALOG.find((agent) => agent.id === automation.agentId)?.label ??
|
||||
automation.agentId}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<DetailMetric label="Schedule" value={formatAutomationSchedule(automation.rrule)} />
|
||||
<DetailMetric
|
||||
label={automation.workspaceMode === 'new_per_run' ? 'Create from' : 'Workspace'}
|
||||
value={
|
||||
automation.workspaceMode === 'new_per_run'
|
||||
? (automation.baseBranch ?? projectDefaultBaseRef ?? 'Project default')
|
||||
: workspaceName
|
||||
}
|
||||
/>
|
||||
<DetailMetric
|
||||
label="Session"
|
||||
value={automation.reuseSession ? 'Reuse live session' : 'Fresh each run'}
|
||||
/>
|
||||
<div className="min-w-0">
|
||||
<div className="text-[11px] font-medium uppercase text-muted-foreground">Prompt</div>
|
||||
<p className="mt-1 line-clamp-4 whitespace-pre-wrap text-sm text-foreground">
|
||||
{automation.prompt}
|
||||
</p>
|
||||
|
|
|
|||
|
|
@ -24,6 +24,10 @@ import type {
|
|||
AutomationWorkspaceMode
|
||||
} from '../../../../shared/automations-types'
|
||||
import type { GlobalSettings, Repo, TuiAgent, Worktree } from '../../../../shared/types'
|
||||
import {
|
||||
isValidAutomationCronSchedule,
|
||||
isValidAutomationSchedule
|
||||
} from '../../../../shared/automation-schedules'
|
||||
import { Field } from './automation-page-parts'
|
||||
import { AutomationSchedulePicker } from './AutomationSchedulePicker'
|
||||
import { AutomationSessionField } from './AutomationSessionField'
|
||||
|
|
@ -58,6 +62,7 @@ export type AutomationCreateTarget = 'orca' | 'hermes'
|
|||
type AutomationEditorDialogProps = {
|
||||
open: boolean
|
||||
isEditing: boolean
|
||||
isEditingExternal: boolean
|
||||
isSaving: boolean
|
||||
canSave: boolean
|
||||
createTarget: AutomationCreateTarget
|
||||
|
|
@ -99,6 +104,7 @@ function AutomationTemplateCard({
|
|||
export function AutomationEditorDialog({
|
||||
open,
|
||||
isEditing,
|
||||
isEditingExternal,
|
||||
isSaving,
|
||||
canSave,
|
||||
createTarget,
|
||||
|
|
@ -115,7 +121,9 @@ export function AutomationEditorDialog({
|
|||
onSave
|
||||
}: AutomationEditorDialogProps): React.JSX.Element {
|
||||
const [templateOpen, setTemplateOpen] = React.useState(false)
|
||||
const isHermesCreate = !isEditing && createTarget === 'hermes'
|
||||
const isHermesTarget = createTarget === 'hermes'
|
||||
const isCreateMode = !isEditing && !isEditingExternal
|
||||
const isHermesCreate = isCreateMode && isHermesTarget
|
||||
const visibleAgents = React.useMemo(() => {
|
||||
const enabledIds = new Set(
|
||||
filterEnabledTuiAgents(
|
||||
|
|
@ -140,9 +148,11 @@ export function AutomationEditorDialog({
|
|||
<DialogTitle className="text-sm font-medium">
|
||||
{isEditing
|
||||
? 'Edit automation'
|
||||
: isHermesCreate
|
||||
? 'Create Hermes cron'
|
||||
: 'Create automation'}
|
||||
: isEditingExternal
|
||||
? 'Edit Hermes automation'
|
||||
: isHermesCreate
|
||||
? 'Create Hermes automation'
|
||||
: 'Create automation'}
|
||||
</DialogTitle>
|
||||
<Input
|
||||
value={draft.name}
|
||||
|
|
@ -154,7 +164,7 @@ export function AutomationEditorDialog({
|
|||
}
|
||||
/>
|
||||
</div>
|
||||
{!isEditing ? (
|
||||
{isCreateMode ? (
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<ToggleGroup
|
||||
type="single"
|
||||
|
|
@ -226,7 +236,7 @@ export function AutomationEditorDialog({
|
|||
<div className="border-t border-border/50 px-5 py-4">
|
||||
<div
|
||||
className={
|
||||
isHermesCreate
|
||||
isHermesTarget
|
||||
? 'grid gap-3 md:grid-cols-3'
|
||||
: 'grid gap-3 sm:grid-cols-2 lg:grid-cols-4'
|
||||
}
|
||||
|
|
@ -262,9 +272,9 @@ export function AutomationEditorDialog({
|
|||
</Tooltip>
|
||||
</span>
|
||||
}
|
||||
className={isHermesCreate ? undefined : 'sm:col-span-2 lg:col-span-3'}
|
||||
className={isHermesTarget ? undefined : 'sm:col-span-2 lg:col-span-3'}
|
||||
>
|
||||
{isHermesCreate ? (
|
||||
{isHermesTarget ? (
|
||||
<WorkspaceCombobox
|
||||
worktrees={worktrees}
|
||||
value={draft.workspaceId}
|
||||
|
|
@ -321,7 +331,7 @@ export function AutomationEditorDialog({
|
|||
</div>
|
||||
)}
|
||||
</Field>
|
||||
{isHermesCreate ? null : (
|
||||
{isHermesTarget ? null : (
|
||||
<Field label="Agent">
|
||||
<AgentCombobox
|
||||
agents={visibleAgents}
|
||||
|
|
@ -335,7 +345,7 @@ export function AutomationEditorDialog({
|
|||
/>
|
||||
</Field>
|
||||
)}
|
||||
{isHermesCreate ? null : (
|
||||
{isHermesTarget ? null : (
|
||||
<AutomationSessionField
|
||||
draft={draft}
|
||||
toggleItemClassName={MODE_TOGGLE_ITEM_CLASS}
|
||||
|
|
@ -346,10 +356,13 @@ export function AutomationEditorDialog({
|
|||
<AutomationSchedulePicker
|
||||
draft={draft}
|
||||
triggerClassName={PICKER_TRIGGER_CLASS}
|
||||
validateAdvancedSchedule={
|
||||
isHermesTarget ? isValidAutomationCronSchedule : isValidAutomationSchedule
|
||||
}
|
||||
onDraftChange={onDraftChange}
|
||||
/>
|
||||
</Field>
|
||||
{isHermesCreate ? null : (
|
||||
{isHermesTarget ? null : (
|
||||
<Field
|
||||
label={
|
||||
<span className="inline-flex items-center gap-1">
|
||||
|
|
@ -405,8 +418,14 @@ export function AutomationEditorDialog({
|
|||
disabled={isSaving || repos.length === 0 || !canSave}
|
||||
className="border-foreground/25 bg-foreground/[0.04] text-foreground hover:bg-foreground/[0.08]"
|
||||
>
|
||||
{isEditing || isHermesCreate || isSaving ? null : <Plus className="size-4" />}
|
||||
{isEditing ? 'Save Changes' : isSaving || isHermesCreate ? 'Save' : 'Create'}
|
||||
{isEditing || isEditingExternal || isHermesCreate || isSaving ? null : (
|
||||
<Plus className="size-4" />
|
||||
)}
|
||||
{isEditing || isEditingExternal
|
||||
? 'Save Changes'
|
||||
: isSaving || isHermesCreate
|
||||
? 'Save'
|
||||
: 'Create'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import {
|
|||
import type { AutomationSchedulePreset } from '../../../../shared/automations-types'
|
||||
import {
|
||||
buildAutomationRrule,
|
||||
classifyAutomationCronSchedule,
|
||||
formatAutomationSchedule,
|
||||
isValidAutomationSchedule
|
||||
} from '../../../../shared/automation-schedules'
|
||||
|
|
@ -21,6 +22,14 @@ 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'
|
||||
type SimpleSchedulePreset = Exclude<AutomationSchedulePreset, 'custom'>
|
||||
|
||||
const SIMPLE_PRESETS = [
|
||||
['hourly', 'Hourly'],
|
||||
['daily', 'Daily'],
|
||||
['weekdays', 'Weekdays'],
|
||||
['weekly', 'Weekly']
|
||||
] as const
|
||||
|
||||
const DAY_OPTIONS = [
|
||||
['0', 'Sunday'],
|
||||
|
|
@ -31,20 +40,55 @@ const DAY_OPTIONS = [
|
|||
['5', 'Friday'],
|
||||
['6', 'Saturday']
|
||||
] as const
|
||||
const HOUR_OPTIONS = Array.from({ length: 12 }, (_, index) => String(index + 1))
|
||||
const MINUTE_OPTIONS = Array.from({ length: 60 }, (_, index) => String(index))
|
||||
const PERIOD_OPTIONS = ['AM', 'PM'] 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
|
||||
hour: Number.isInteger(hour) && hour >= 0 && hour <= 23 ? hour : 9,
|
||||
minute: Number.isInteger(minute) && minute >= 0 && minute <= 59 ? minute : 0
|
||||
}
|
||||
}
|
||||
|
||||
function formatTimeInput(hour: number, minute: number): string {
|
||||
return `${String(hour).padStart(2, '0')}:${String(minute).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
function getClockParts(time: string): { hour12: number; minute: number; period: 'AM' | 'PM' } {
|
||||
const { hour, minute } = parseTime(time)
|
||||
return {
|
||||
hour12: hour % 12 === 0 ? 12 : hour % 12,
|
||||
minute,
|
||||
period: hour >= 12 ? 'PM' : 'AM'
|
||||
}
|
||||
}
|
||||
|
||||
function updateTimePart(
|
||||
time: string,
|
||||
patch: { hour12?: number; minute?: number; period?: 'AM' | 'PM' }
|
||||
): string {
|
||||
const current = getClockParts(time)
|
||||
const nextHour12 = patch.hour12 ?? current.hour12
|
||||
const nextPeriod = patch.period ?? current.period
|
||||
const nextMinute = patch.minute ?? current.minute
|
||||
const hour24 =
|
||||
nextPeriod === 'AM'
|
||||
? nextHour12 === 12
|
||||
? 0
|
||||
: nextHour12
|
||||
: nextHour12 === 12
|
||||
? 12
|
||||
: nextHour12 + 12
|
||||
return formatTimeInput(hour24, nextMinute)
|
||||
}
|
||||
|
||||
function getDraftScheduleLabel(draft: AutomationDraft): string {
|
||||
if (draft.preset === 'custom') {
|
||||
return draft.customSchedule.trim()
|
||||
? formatAutomationSchedule(draft.customSchedule)
|
||||
: 'Custom cron'
|
||||
: 'Advanced schedule'
|
||||
}
|
||||
const { hour, minute } = parseTime(draft.time)
|
||||
return formatAutomationSchedule(
|
||||
|
|
@ -57,36 +101,54 @@ function getDraftScheduleLabel(draft: AutomationDraft): string {
|
|||
)
|
||||
}
|
||||
|
||||
function buildCustomCronFromDraft(draft: AutomationDraft): string {
|
||||
const { hour, minute } = parseTime(draft.time)
|
||||
if (draft.preset === 'hourly') {
|
||||
return `${minute} * * * *`
|
||||
function getSimpleScheduleDraft(
|
||||
current: AutomationDraft
|
||||
): Pick<AutomationDraft, 'preset' | 'time' | 'dayOfWeek'> {
|
||||
const classification = classifyAutomationCronSchedule(current.customSchedule)
|
||||
if (classification.kind === 'hourly') {
|
||||
const { hour } = parseTime(current.time)
|
||||
return {
|
||||
preset: 'hourly',
|
||||
time: formatTimeInput(hour, classification.minute),
|
||||
dayOfWeek: current.dayOfWeek
|
||||
}
|
||||
}
|
||||
if (draft.preset === 'weekdays') {
|
||||
return `${minute} ${hour} * * 1-5`
|
||||
if (classification.kind === 'daily' || classification.kind === 'weekdays') {
|
||||
return {
|
||||
preset: classification.kind,
|
||||
time: formatTimeInput(classification.hour, classification.minute),
|
||||
dayOfWeek: current.dayOfWeek
|
||||
}
|
||||
}
|
||||
if (draft.preset === 'weekly') {
|
||||
return `${minute} ${hour} * * ${Number(draft.dayOfWeek)}`
|
||||
if (classification.kind === 'weekly') {
|
||||
return {
|
||||
preset: 'weekly',
|
||||
time: formatTimeInput(classification.hour, classification.minute),
|
||||
dayOfWeek: String(classification.dayOfWeek)
|
||||
}
|
||||
}
|
||||
return `${minute} ${hour} * * *`
|
||||
return { preset: 'weekdays', time: current.time, dayOfWeek: current.dayOfWeek || '1' }
|
||||
}
|
||||
|
||||
export function AutomationSchedulePicker({
|
||||
draft,
|
||||
triggerClassName,
|
||||
validateAdvancedSchedule = isValidAutomationSchedule,
|
||||
onDraftChange
|
||||
}: {
|
||||
draft: AutomationDraft
|
||||
triggerClassName?: string
|
||||
validateAdvancedSchedule?: (schedule: string) => boolean
|
||||
onDraftChange: (updater: (current: AutomationDraft) => AutomationDraft) => void
|
||||
}): React.JSX.Element {
|
||||
const [open, setOpen] = React.useState(false)
|
||||
const label = getDraftScheduleLabel(draft)
|
||||
const clockParts = getClockParts(draft.time)
|
||||
const customSchedule = draft.customSchedule.trim()
|
||||
const customScheduleInvalid =
|
||||
draft.preset === 'custom' &&
|
||||
customSchedule.length > 0 &&
|
||||
!isValidAutomationSchedule(customSchedule)
|
||||
!validateAdvancedSchedule(customSchedule)
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
|
|
@ -98,7 +160,7 @@ export function AutomationSchedulePicker({
|
|||
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">
|
||||
<span className="flex min-w-0 flex-1 items-center gap-2">
|
||||
<CalendarClock className="size-4 text-muted-foreground" />
|
||||
<span className="truncate">{label}</span>
|
||||
</span>
|
||||
|
|
@ -107,118 +169,201 @@ export function AutomationSchedulePicker({
|
|||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
align="start"
|
||||
className="w-[var(--radix-popover-trigger-width)] min-w-[19rem] p-3"
|
||||
className="w-[min(var(--radix-popover-trigger-width),calc(100vw-2rem))] min-w-[min(18rem,calc(100vw-2rem))] max-w-[calc(100vw-2rem)] 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) =>
|
||||
<div className="grid gap-3">
|
||||
<Field label="Advanced schedule">
|
||||
<Input
|
||||
value={draft.customSchedule}
|
||||
placeholder="0 9 * * 1-5"
|
||||
spellCheck={false}
|
||||
className={cn('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">
|
||||
Existing advanced schedules are preserved until you choose a simple schedule.
|
||||
</div>
|
||||
{customScheduleInvalid ? (
|
||||
<div className="mt-1 text-[11px] text-destructive">
|
||||
Enter a valid advanced schedule before saving.
|
||||
</div>
|
||||
) : null}
|
||||
</Field>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
className="justify-start"
|
||||
onClick={() =>
|
||||
onDraftChange((current) => ({
|
||||
...current,
|
||||
customSchedule: event.target.value,
|
||||
...getSimpleScheduleDraft(current),
|
||||
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}
|
||||
Use simple schedule
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<Field label="Cadence">
|
||||
<Select
|
||||
value={draft.preset}
|
||||
onValueChange={(preset) =>
|
||||
onDraftChange((current) => ({
|
||||
...current,
|
||||
preset: preset as SimpleSchedulePreset,
|
||||
scheduleWarning: null
|
||||
}))
|
||||
}
|
||||
>
|
||||
<SelectTrigger className={cn('w-full min-w-0', FIELD_CONTROL_CLASS)}>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{SIMPLE_PRESETS.map(([value, presetLabel]) => (
|
||||
<SelectItem key={value} value={value}>
|
||||
{presetLabel}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
{draft.preset === 'weekly' ? (
|
||||
<Field label="Day">
|
||||
<Select
|
||||
value={draft.dayOfWeek}
|
||||
onValueChange={(dayOfWeek) =>
|
||||
onDraftChange((current) => ({ ...current, dayOfWeek, scheduleWarning: null }))
|
||||
}
|
||||
>
|
||||
<SelectTrigger className={cn('w-full min-w-0', FIELD_CONTROL_CLASS)}>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{DAY_OPTIONS.map(([value, dayLabel]) => (
|
||||
<SelectItem key={value} value={value}>
|
||||
{dayLabel}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
) : null}
|
||||
{draft.preset === 'hourly' ? (
|
||||
<Field label="Minute">
|
||||
<Select
|
||||
value={String(clockParts.minute)}
|
||||
onValueChange={(minute) =>
|
||||
onDraftChange((current) => ({
|
||||
...current,
|
||||
time: updateTimePart(current.time, { minute: Number(minute) }),
|
||||
scheduleWarning: null
|
||||
}))
|
||||
}
|
||||
>
|
||||
<SelectTrigger className={cn('w-full min-w-0', FIELD_CONTROL_CLASS)}>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{MINUTE_OPTIONS.map((minute) => (
|
||||
<SelectItem key={minute} value={minute}>
|
||||
:{minute.padStart(2, '0')}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
) : (
|
||||
<Field label="Time">
|
||||
<div className="grid grid-cols-[minmax(0,1fr)_minmax(0,1fr)_minmax(0,0.8fr)] gap-2">
|
||||
<Select
|
||||
value={String(clockParts.hour12)}
|
||||
onValueChange={(hour12) =>
|
||||
onDraftChange((current) => ({
|
||||
...current,
|
||||
time: updateTimePart(current.time, { hour12: Number(hour12) }),
|
||||
scheduleWarning: null
|
||||
}))
|
||||
}
|
||||
>
|
||||
<SelectTrigger
|
||||
aria-label="Hour"
|
||||
className={cn('w-full min-w-0', FIELD_CONTROL_CLASS)}
|
||||
>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{HOUR_OPTIONS.map((hour) => (
|
||||
<SelectItem key={hour} value={hour}>
|
||||
{hour}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select
|
||||
value={String(clockParts.minute)}
|
||||
onValueChange={(minute) =>
|
||||
onDraftChange((current) => ({
|
||||
...current,
|
||||
time: updateTimePart(current.time, { minute: Number(minute) }),
|
||||
scheduleWarning: null
|
||||
}))
|
||||
}
|
||||
>
|
||||
<SelectTrigger
|
||||
aria-label="Minute"
|
||||
className={cn('w-full min-w-0', FIELD_CONTROL_CLASS)}
|
||||
>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{MINUTE_OPTIONS.map((minute) => (
|
||||
<SelectItem key={minute} value={minute}>
|
||||
{minute.padStart(2, '0')}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select
|
||||
value={clockParts.period}
|
||||
onValueChange={(period) =>
|
||||
onDraftChange((current) => ({
|
||||
...current,
|
||||
time: updateTimePart(current.time, { period: period as 'AM' | 'PM' }),
|
||||
scheduleWarning: null
|
||||
}))
|
||||
}
|
||||
>
|
||||
<SelectTrigger
|
||||
aria-label="AM or PM"
|
||||
className={cn('w-full min-w-0', FIELD_CONTROL_CLASS)}
|
||||
>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{PERIOD_OPTIONS.map((period) => (
|
||||
<SelectItem key={period} value={period}>
|
||||
{period}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</Field>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
|
|
|||
|
|
@ -56,6 +56,7 @@ import { getWorktreePathBasenameFromId } from '../../../../shared/worktree-id'
|
|||
import {
|
||||
buildAutomationRrule,
|
||||
formatAutomationSchedule,
|
||||
isValidAutomationCronSchedule,
|
||||
isValidAutomationSchedule,
|
||||
tryParseAutomationRrule
|
||||
} from '../../../../shared/automation-schedules'
|
||||
|
|
@ -86,6 +87,7 @@ import {
|
|||
import { AutomationRunPageFrame } from './AutomationRunPageFrame'
|
||||
import { AutomationRunHistory } from './AutomationRunHistory'
|
||||
import { AUTOMATION_TEMPLATES, type AutomationTemplate } from './automation-templates'
|
||||
import { getExternalAutomationScheduleDisplay } from './external-automation-schedule-display'
|
||||
import { ExternalAutomationManagers } from './ExternalAutomationManagers'
|
||||
import type { FetchExternalAutomationRuns } from './ExternalAutomationRunTable'
|
||||
|
||||
|
|
@ -774,8 +776,8 @@ export default function AutomationsPage(): React.JSX.Element {
|
|||
job: ExternalAutomationJob
|
||||
): void => {
|
||||
editRequestRef.current += 1
|
||||
const rawSchedule = job.rawSchedule ?? job.schedule
|
||||
const hasCustomSchedule = isValidAutomationSchedule(rawSchedule)
|
||||
const rawSchedule = job.rawSchedule?.trim() ?? ''
|
||||
const hasCustomSchedule = isValidAutomationCronSchedule(rawSchedule)
|
||||
const targetWorktree =
|
||||
Object.values(worktreesByRepo)
|
||||
.flat()
|
||||
|
|
@ -806,7 +808,7 @@ export default function AutomationsPage(): React.JSX.Element {
|
|||
missedRunGraceMinutes: '720',
|
||||
scheduleWarning: hasCustomSchedule
|
||||
? null
|
||||
: 'This Hermes cron has an unsupported saved schedule. Pick a supported schedule before saving changes.'
|
||||
: 'This Hermes automation has an unsupported saved schedule. Pick a supported schedule before saving changes.'
|
||||
}
|
||||
setEditingAutomationId(null)
|
||||
setEditingExternalTarget({ manager, job })
|
||||
|
|
@ -861,8 +863,11 @@ export default function AutomationsPage(): React.JSX.Element {
|
|||
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.')
|
||||
const validateAdvancedSchedule = isHermesSave
|
||||
? isValidAutomationCronSchedule
|
||||
: isValidAutomationSchedule
|
||||
if (draft.preset === 'custom' && !validateAdvancedSchedule(draft.customSchedule)) {
|
||||
toast.error('Enter a valid advanced schedule before saving.')
|
||||
return
|
||||
}
|
||||
if (
|
||||
|
|
@ -897,7 +902,7 @@ export default function AutomationsPage(): React.JSX.Element {
|
|||
const repoTargetMatches =
|
||||
target.type === 'local' ? !repo.connectionId : repo.connectionId === target.connectionId
|
||||
if (!repoTargetMatches) {
|
||||
toast.error('Choose a workspace on the same host as this Hermes cron.')
|
||||
toast.error('Choose a workspace on the same host as this Hermes automation.')
|
||||
return
|
||||
}
|
||||
const schedule = buildHermesCronSchedule(draft)
|
||||
|
|
@ -930,7 +935,9 @@ export default function AutomationsPage(): React.JSX.Element {
|
|||
? getExternalAutomationKey(editingExternalTarget.manager, editingExternalTarget.job)
|
||||
: null
|
||||
)
|
||||
toast.success(editingExternalTarget ? 'Hermes cron updated.' : 'Hermes cron created.')
|
||||
toast.success(
|
||||
editingExternalTarget ? 'Hermes automation updated.' : 'Hermes automation created.'
|
||||
)
|
||||
return
|
||||
}
|
||||
const now = Date.now()
|
||||
|
|
@ -1010,6 +1017,9 @@ export default function AutomationsPage(): React.JSX.Element {
|
|||
setCreateOpen(false)
|
||||
toast.success(editingAutomationId ? 'Automation updated.' : 'Automation saved.')
|
||||
} catch (error) {
|
||||
if (isHermesSave) {
|
||||
await refresh().catch(() => undefined)
|
||||
}
|
||||
toast.error(error instanceof Error ? error.message : 'Failed to save automation.')
|
||||
} finally {
|
||||
setIsSaving(false)
|
||||
|
|
@ -1134,6 +1144,7 @@ export default function AutomationsPage(): React.JSX.Element {
|
|||
: 'External automation resumed.'
|
||||
)
|
||||
} catch (error) {
|
||||
await refresh().catch(() => undefined)
|
||||
toast.error(error instanceof Error ? error.message : 'External automation action failed.')
|
||||
} finally {
|
||||
setExternalActionKey(null)
|
||||
|
|
@ -1366,6 +1377,7 @@ export default function AutomationsPage(): React.JSX.Element {
|
|||
isEditing={editingAutomationId !== null}
|
||||
isSaving={isSaving}
|
||||
canSave={canSaveDraft}
|
||||
isEditingExternal={editingExternalTarget !== null}
|
||||
createTarget={createTarget}
|
||||
repos={repos}
|
||||
repoMap={repoMap}
|
||||
|
|
@ -1489,7 +1501,14 @@ export default function AutomationsPage(): React.JSX.Element {
|
|||
<div className="break-all font-medium text-foreground">
|
||||
{externalDeleteTarget.job.name}
|
||||
</div>
|
||||
<div className="mt-1 text-muted-foreground">{externalDeleteTarget.job.schedule}</div>
|
||||
<div className="mt-1 text-muted-foreground">
|
||||
{
|
||||
getExternalAutomationScheduleDisplay(
|
||||
externalDeleteTarget.manager,
|
||||
externalDeleteTarget.job
|
||||
).label
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
<DialogFooter>
|
||||
|
|
@ -1540,6 +1559,7 @@ export default function AutomationsPage(): React.JSX.Element {
|
|||
const nextRunLabel = automation.enabled
|
||||
? formatAutomationDateTimeWithRelative(automation.nextRunAt, relativeNow)
|
||||
: 'Paused'
|
||||
const scheduleLabel = formatAutomationSchedule(automation.rrule)
|
||||
return (
|
||||
<ContextMenu key={automation.id}>
|
||||
<ContextMenuTrigger asChild>
|
||||
|
|
@ -1566,6 +1586,9 @@ export default function AutomationsPage(): React.JSX.Element {
|
|||
/>
|
||||
<span className="truncate font-medium">{automation.name}</span>
|
||||
</span>
|
||||
<span className="mt-1 block truncate text-xs font-medium text-foreground/80">
|
||||
{scheduleLabel}
|
||||
</span>
|
||||
<span className="mt-1 flex min-w-0 items-center gap-1.5 text-xs text-muted-foreground">
|
||||
{automationRepo ? (
|
||||
<RepoBadgeLabel
|
||||
|
|
@ -1578,11 +1601,6 @@ export default function AutomationsPage(): React.JSX.Element {
|
|||
)}
|
||||
<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>
|
||||
|
|
@ -1674,6 +1692,7 @@ export default function AutomationsPage(): React.JSX.Element {
|
|||
? formatExternalDate(entry.job.nextRunAt, relativeNow)
|
||||
: 'Paused'
|
||||
const actionDisabled = !entry.manager.canManage || externalActionKey !== null
|
||||
const scheduleDisplay = getExternalAutomationScheduleDisplay(entry.manager, entry.job)
|
||||
return (
|
||||
<ContextMenu key={entry.key}>
|
||||
<ContextMenuTrigger asChild>
|
||||
|
|
@ -1700,13 +1719,13 @@ export default function AutomationsPage(): React.JSX.Element {
|
|||
/>
|
||||
<span className="truncate font-medium">{entry.job.name}</span>
|
||||
</span>
|
||||
<span className="mt-1 flex min-w-0 items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<span>{providerLabel}</span>
|
||||
<span className="shrink-0">/</span>
|
||||
<span className="truncate">{entry.manager.targetLabel}</span>
|
||||
<span className="mt-1 block truncate text-xs font-medium text-foreground/80">
|
||||
{scheduleDisplay.label}
|
||||
</span>
|
||||
<span className="mt-1 flex min-w-0 items-center gap-2 text-xs text-muted-foreground">
|
||||
<span className="truncate">{entry.job.schedule}</span>
|
||||
<span className="truncate">
|
||||
{providerLabel} / {entry.manager.targetLabel}
|
||||
</span>
|
||||
<span className="shrink-0">·</span>
|
||||
<span className="truncate">
|
||||
{entry.manager.provider === 'hermes'
|
||||
|
|
@ -1870,7 +1889,7 @@ export default function AutomationsPage(): React.JSX.Element {
|
|||
) : null}
|
||||
</div>
|
||||
<div className="px-3 py-6 text-sm text-muted-foreground">
|
||||
Connect this source to check for Hermes cron jobs in the remote profile.
|
||||
Connect this source to check for Hermes automations in the remote profile.
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import {
|
|||
ExternalAutomationRunTable,
|
||||
type FetchExternalAutomationRuns
|
||||
} from './ExternalAutomationRunTable'
|
||||
import { getExternalAutomationScheduleDisplay } from './external-automation-schedule-display'
|
||||
|
||||
type ExternalAutomationManagersProps = {
|
||||
managers: ExternalAutomationManager[]
|
||||
|
|
@ -136,102 +137,109 @@ export function ExternalAutomationManagers({
|
|||
</Badge>
|
||||
</div>
|
||||
<div className="divide-y divide-border/40">
|
||||
{manager.jobs.map((job) => (
|
||||
<div
|
||||
key={job.id}
|
||||
className="grid grid-cols-[minmax(0,1fr)_minmax(8rem,auto)_auto] items-center gap-3 px-3 py-2 text-sm"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<span className="truncate font-medium">{job.name}</span>
|
||||
<Badge variant={job.enabled ? 'secondary' : 'outline'}>
|
||||
{job.enabled ? 'Active' : 'Paused'}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="mt-1 truncate text-xs text-muted-foreground">
|
||||
{job.schedule} · next {formatExternalDate(job.nextRunAt, now)}
|
||||
</div>
|
||||
{manager.provider === 'hermes' ? (
|
||||
<div className="mt-1 truncate text-xs text-muted-foreground">
|
||||
{job.runCount} {job.runCount === 1 ? 'run' : 'runs'} found
|
||||
{manager.jobs.map((job) => {
|
||||
const scheduleDisplay = getExternalAutomationScheduleDisplay(manager, job)
|
||||
return (
|
||||
<div
|
||||
key={job.id}
|
||||
className="grid grid-cols-[minmax(0,1fr)_minmax(8rem,auto)_auto] items-center gap-3 px-3 py-2 text-sm"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<span className="truncate font-medium">{job.name}</span>
|
||||
<Badge variant={job.enabled ? 'secondary' : 'outline'}>
|
||||
{job.enabled ? 'Active' : 'Paused'}
|
||||
</Badge>
|
||||
</div>
|
||||
) : null}
|
||||
{job.promptPreview || job.lastError ? (
|
||||
<div className="mt-1 truncate text-xs text-muted-foreground">
|
||||
{job.lastError ?? job.promptPreview}
|
||||
<div className="mt-1 truncate text-xs font-medium text-foreground/80">
|
||||
{scheduleDisplay.label}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="hidden min-w-0 text-xs text-muted-foreground md:block">
|
||||
Last {formatExternalDate(job.lastRunAt, now)}
|
||||
{job.lastStatus ? ` · ${job.lastStatus}` : null}
|
||||
</div>
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
<ExternalActionButton
|
||||
label="Run external automation"
|
||||
disabled={!manager.canManage || runningActionKey !== null}
|
||||
onClick={() => onAction(manager, job, 'run')}
|
||||
>
|
||||
{runningActionKey === actionKey(manager, job, 'run') ? (
|
||||
<RefreshCw className="size-3.5 animate-spin" />
|
||||
) : (
|
||||
<Play className="size-3.5" />
|
||||
)}
|
||||
</ExternalActionButton>
|
||||
{manager.provider === 'hermes' ? (
|
||||
<div className="mt-1 truncate text-xs text-muted-foreground">
|
||||
next {formatExternalDate(job.nextRunAt, now)} · {getProviderLabel(manager)}{' '}
|
||||
/ {manager.targetLabel}
|
||||
</div>
|
||||
{manager.provider === 'hermes' ? (
|
||||
<div className="mt-1 truncate text-xs text-muted-foreground">
|
||||
{job.runCount} {job.runCount === 1 ? 'run' : 'runs'} found
|
||||
</div>
|
||||
) : null}
|
||||
{job.promptPreview || job.lastError ? (
|
||||
<div className="mt-1 truncate text-xs text-muted-foreground">
|
||||
{job.lastError ?? job.promptPreview}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="hidden min-w-0 text-xs text-muted-foreground md:block">
|
||||
Last {formatExternalDate(job.lastRunAt, now)}
|
||||
{job.lastStatus ? ` · ${job.lastStatus}` : null}
|
||||
</div>
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
<ExternalActionButton
|
||||
label="Edit external automation"
|
||||
label="Run external automation"
|
||||
disabled={!manager.canManage || runningActionKey !== null}
|
||||
onClick={() => onEdit?.(manager, job)}
|
||||
onClick={() => onAction(manager, job, 'run')}
|
||||
>
|
||||
<Pencil className="size-3.5" />
|
||||
{runningActionKey === actionKey(manager, job, 'run') ? (
|
||||
<RefreshCw className="size-3.5 animate-spin" />
|
||||
) : (
|
||||
<Play className="size-3.5" />
|
||||
)}
|
||||
</ExternalActionButton>
|
||||
{manager.provider === 'hermes' ? (
|
||||
<ExternalActionButton
|
||||
label="Edit external automation"
|
||||
disabled={!manager.canManage || runningActionKey !== null}
|
||||
onClick={() => onEdit?.(manager, job)}
|
||||
>
|
||||
<Pencil className="size-3.5" />
|
||||
</ExternalActionButton>
|
||||
) : null}
|
||||
<ExternalActionButton
|
||||
label={
|
||||
job.enabled ? 'Pause external automation' : 'Resume external automation'
|
||||
}
|
||||
disabled={!manager.canManage || runningActionKey !== null}
|
||||
onClick={() => onAction(manager, job, job.enabled ? 'pause' : 'resume')}
|
||||
>
|
||||
{runningActionKey ===
|
||||
actionKey(manager, job, job.enabled ? 'pause' : 'resume') ? (
|
||||
<RefreshCw className="size-3.5 animate-spin" />
|
||||
) : job.enabled ? (
|
||||
<Pause className="size-3.5" />
|
||||
) : (
|
||||
<Play className="size-3.5" />
|
||||
)}
|
||||
</ExternalActionButton>
|
||||
<ExternalActionButton
|
||||
label="Delete external automation"
|
||||
className="text-destructive hover:text-destructive"
|
||||
disabled={!manager.canManage || runningActionKey !== null}
|
||||
onClick={() => onAction(manager, job, 'delete')}
|
||||
>
|
||||
{runningActionKey === actionKey(manager, job, 'delete') ? (
|
||||
<RefreshCw className="size-3.5 animate-spin" />
|
||||
) : (
|
||||
<Trash2 className="size-3.5" />
|
||||
)}
|
||||
</ExternalActionButton>
|
||||
) : null}
|
||||
<ExternalActionButton
|
||||
label={
|
||||
job.enabled ? 'Pause external automation' : 'Resume external automation'
|
||||
}
|
||||
disabled={!manager.canManage || runningActionKey !== null}
|
||||
onClick={() => onAction(manager, job, job.enabled ? 'pause' : 'resume')}
|
||||
>
|
||||
{runningActionKey ===
|
||||
actionKey(manager, job, job.enabled ? 'pause' : 'resume') ? (
|
||||
<RefreshCw className="size-3.5 animate-spin" />
|
||||
) : job.enabled ? (
|
||||
<Pause className="size-3.5" />
|
||||
) : (
|
||||
<Play className="size-3.5" />
|
||||
)}
|
||||
</ExternalActionButton>
|
||||
<ExternalActionButton
|
||||
label="Delete external automation"
|
||||
className="text-destructive hover:text-destructive"
|
||||
disabled={!manager.canManage || runningActionKey !== null}
|
||||
onClick={() => onAction(manager, job, 'delete')}
|
||||
>
|
||||
{runningActionKey === actionKey(manager, job, 'delete') ? (
|
||||
<RefreshCw className="size-3.5 animate-spin" />
|
||||
) : (
|
||||
<Trash2 className="size-3.5" />
|
||||
)}
|
||||
</ExternalActionButton>
|
||||
</div>
|
||||
{manager.provider === 'hermes' ? (
|
||||
<div className="col-span-3">
|
||||
<ExternalAutomationRunTable
|
||||
manager={manager}
|
||||
job={job}
|
||||
now={now}
|
||||
onFetchRuns={onFetchRuns}
|
||||
onOpenRun={(run) => onOpenRun?.(manager, job, run)}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
{manager.provider === 'hermes' ? (
|
||||
<div className="col-span-3">
|
||||
<ExternalAutomationRunTable
|
||||
manager={manager}
|
||||
job={job}
|
||||
now={now}
|
||||
onFetchRuns={onFetchRuns}
|
||||
onOpenRun={(run) => onOpenRun?.(manager, job, run)}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
{manager.jobs.length === 0 ? (
|
||||
<div className="px-3 py-4 text-sm text-muted-foreground">
|
||||
No {manager.provider === 'hermes' ? 'Hermes' : 'OpenClaw'} jobs found.
|
||||
No {manager.provider === 'hermes' ? 'Hermes' : 'OpenClaw'} automations found.
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -13,7 +13,6 @@ import {
|
|||
} from 'lucide-react'
|
||||
import type { LucideIcon } from 'lucide-react'
|
||||
import CommentMarkdown from '@/components/sidebar/CommentMarkdown'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import { cn } from '@/lib/utils'
|
||||
import {
|
||||
formatAutomationSchedule,
|
||||
|
|
@ -33,8 +32,6 @@ type ParsedHermesOutput = {
|
|||
}
|
||||
|
||||
const METADATA_LINE_PATTERN = /^\*\*([^*]+):\*\*\s+(.+?)\s*$/
|
||||
const CRON_FIELD_NAMES = ['minute', 'hour', 'day of month', 'month', 'weekday'] as const
|
||||
const WEEKDAY_NAMES = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday']
|
||||
|
||||
function splitSections(content: string): ParsedSection[] {
|
||||
const lines = content.split(/\r?\n/)
|
||||
|
|
@ -113,83 +110,22 @@ function isErrorSection(section: ParsedSection): boolean {
|
|||
return /^error$/i.test(section.heading.trim())
|
||||
}
|
||||
|
||||
function formatCronTime(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 parseSingleCronNumber(value: string, min: number, max: number): number | null {
|
||||
const parsed = Number(value)
|
||||
if (!Number.isInteger(parsed) || parsed < min || parsed > max) {
|
||||
return null
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
|
||||
function describeSimpleCron(parts: string[]): string | null {
|
||||
const [minuteField, hourField, dayOfMonthField, monthField, weekdayField] = parts
|
||||
const minute = parseSingleCronNumber(minuteField, 0, 59)
|
||||
const hour = parseSingleCronNumber(hourField, 0, 23)
|
||||
const time = minute !== null && hour !== null ? formatCronTime(hour, minute) : null
|
||||
|
||||
if (time && dayOfMonthField === '*' && monthField === '*' && weekdayField === '*') {
|
||||
return `Runs daily at ${time}.`
|
||||
}
|
||||
if (
|
||||
minute !== null &&
|
||||
hourField === '*' &&
|
||||
dayOfMonthField === '*' &&
|
||||
monthField === '*' &&
|
||||
weekdayField === '*'
|
||||
) {
|
||||
return `Runs hourly at :${String(minute).padStart(2, '0')}.`
|
||||
}
|
||||
if (
|
||||
time &&
|
||||
dayOfMonthField === '*' &&
|
||||
monthField === '*' &&
|
||||
/^(?:1-5|MON-FRI)$/i.test(weekdayField)
|
||||
) {
|
||||
return `Runs on weekdays at ${time}.`
|
||||
}
|
||||
const weekday = parseSingleCronNumber(weekdayField, 0, 7)
|
||||
if (time && dayOfMonthField === '*' && monthField === '*' && weekday !== null) {
|
||||
return `Runs every ${WEEKDAY_NAMES[weekday === 7 ? 0 : weekday]} at ${time}.`
|
||||
}
|
||||
const dayOfMonth = parseSingleCronNumber(dayOfMonthField, 1, 31)
|
||||
if (time && dayOfMonth !== null && monthField === '*' && weekdayField === '*') {
|
||||
return `Runs monthly on day ${dayOfMonth} at ${time}.`
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function describeCronFields(parts: string[]): string {
|
||||
return parts.map((part, index) => `${CRON_FIELD_NAMES[index]} ${part}`).join(', ')
|
||||
}
|
||||
|
||||
function getScheduleDescription(value: string): string | null {
|
||||
function getScheduleDisplay(value: string): string | null {
|
||||
const trimmed = value.trim()
|
||||
if (!isValidAutomationSchedule(trimmed)) {
|
||||
return null
|
||||
}
|
||||
if (trimmed.includes('=')) {
|
||||
return formatAutomationSchedule(trimmed)
|
||||
}
|
||||
const parts = trimmed.split(/\s+/)
|
||||
if (parts.length !== 5) {
|
||||
return null
|
||||
}
|
||||
return describeSimpleCron(parts) ?? `Cron fields: ${describeCronFields(parts)}.`
|
||||
return formatAutomationSchedule(trimmed)
|
||||
}
|
||||
|
||||
function isScheduleMetadataLabel(label: string): boolean {
|
||||
return /^(?:schedule|cron schedule|cron)$/i.test(label.trim())
|
||||
}
|
||||
|
||||
function getMetadataDisplayLabel(label: string): string {
|
||||
return isScheduleMetadataLabel(label) ? 'Schedule' : label
|
||||
}
|
||||
|
||||
type MetadataIconStyle = { icon: LucideIcon; iconClass: string; ringClass: string }
|
||||
|
||||
function getMetadataIconStyle(label: string): MetadataIconStyle {
|
||||
|
|
@ -299,26 +235,14 @@ function SectionCard({ title, accent = 'default', children }: SectionCardProps):
|
|||
}
|
||||
|
||||
function MetadataValue({ label, value }: { label: string; value: string }): React.JSX.Element {
|
||||
const scheduleDescription = isScheduleMetadataLabel(label) ? getScheduleDescription(value) : null
|
||||
const scheduleDisplay = isScheduleMetadataLabel(label) ? getScheduleDisplay(value) : null
|
||||
|
||||
if (!scheduleDescription) {
|
||||
if (!scheduleDisplay) {
|
||||
return <dd className="mt-0.5 break-all font-mono text-xs text-foreground">{value}</dd>
|
||||
}
|
||||
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<dd
|
||||
tabIndex={0}
|
||||
className="mt-0.5 break-all rounded-sm font-mono text-xs text-foreground underline decoration-dotted underline-offset-2 outline-none transition-colors focus-visible:ring-2 focus-visible:ring-ring"
|
||||
>
|
||||
{value}
|
||||
</dd>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" sideOffset={4} className="max-w-64 text-left">
|
||||
{scheduleDescription}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<dd className="mt-0.5 break-words text-xs font-medium text-foreground">{scheduleDisplay}</dd>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -367,7 +291,7 @@ export function HermesCronOutputView({ content }: { content: string }): React.JS
|
|||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<dt className="text-[10px] font-medium uppercase tracking-wide text-muted-foreground">
|
||||
{entry.label}
|
||||
{getMetadataDisplayLabel(entry.label)}
|
||||
</dt>
|
||||
<MetadataValue label={entry.label} value={entry.value} />
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,45 @@
|
|||
import type {
|
||||
ExternalAutomationJob,
|
||||
ExternalAutomationManager
|
||||
} from '../../../../shared/automations-types'
|
||||
import {
|
||||
formatAutomationSchedule,
|
||||
isValidAutomationCronSchedule
|
||||
} from '../../../../shared/automation-schedules'
|
||||
|
||||
export type ExternalAutomationScheduleDisplay = {
|
||||
label: string
|
||||
}
|
||||
|
||||
function getDisplayableProviderSchedule(schedule: string): string | null {
|
||||
const trimmed = schedule.trim()
|
||||
const cronMatch = /^cron\s+(.+?)(?:\s+@\s+.+)?$/i.exec(trimmed)
|
||||
return cronMatch?.[1]?.trim() ?? trimmed
|
||||
}
|
||||
|
||||
export function getExternalAutomationScheduleDisplay(
|
||||
_manager: ExternalAutomationManager,
|
||||
job: ExternalAutomationJob
|
||||
): ExternalAutomationScheduleDisplay {
|
||||
const providerSchedule = job.schedule.trim()
|
||||
const candidateSchedules = [
|
||||
job.rawSchedule?.trim(),
|
||||
getDisplayableProviderSchedule(providerSchedule)
|
||||
]
|
||||
|
||||
for (const candidate of candidateSchedules) {
|
||||
if (candidate && isValidAutomationCronSchedule(candidate)) {
|
||||
return { label: formatAutomationSchedule(candidate) }
|
||||
}
|
||||
}
|
||||
|
||||
if (providerSchedule) {
|
||||
return {
|
||||
label: providerSchedule.replace(/\s+@\s+.+$/, '')
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
label: 'Schedule unavailable'
|
||||
}
|
||||
}
|
||||
|
|
@ -1,7 +1,9 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
buildAutomationRrule,
|
||||
classifyAutomationCronSchedule,
|
||||
formatAutomationSchedule,
|
||||
isValidAutomationCronSchedule,
|
||||
isValidAutomationSchedule,
|
||||
latestAutomationOccurrenceAtOrBefore,
|
||||
nextAutomationOccurrenceAfter,
|
||||
|
|
@ -9,6 +11,15 @@ import {
|
|||
tryParseAutomationRrule
|
||||
} from './automation-schedules'
|
||||
|
||||
function formatTimeForTest(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)
|
||||
}
|
||||
|
||||
describe('automation schedules', () => {
|
||||
it('uses the latest overdue hourly occurrence for missed-run grace decisions', () => {
|
||||
const rrule = buildAutomationRrule({ preset: 'hourly', hour: 9, minute: 0 })
|
||||
|
|
@ -83,10 +94,34 @@ describe('automation schedules', () => {
|
|||
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('formats simple cron schedules with friendly labels', () => {
|
||||
expect(formatAutomationSchedule('5 * * * *')).toBe('Hourly at :05')
|
||||
expect(formatAutomationSchedule('15 10 * * *')).toBe(`Daily at ${formatTimeForTest(10, 15)}`)
|
||||
expect(formatAutomationSchedule('15 10 * * MON-FRI')).toBe(
|
||||
`Weekdays at ${formatTimeForTest(10, 15)}`
|
||||
)
|
||||
expect(formatAutomationSchedule('30 12 * * 7')).toBe(`Sundays at ${formatTimeForTest(12, 30)}`)
|
||||
})
|
||||
|
||||
it('classifies simple cron schedules for provider edit flows', () => {
|
||||
expect(classifyAutomationCronSchedule('15 10 * * MON-FRI')).toMatchObject({
|
||||
kind: 'weekdays',
|
||||
hour: 10,
|
||||
minute: 15
|
||||
})
|
||||
expect(classifyAutomationCronSchedule('30 12 * * 7')).toMatchObject({
|
||||
kind: 'weekly',
|
||||
hour: 12,
|
||||
minute: 30,
|
||||
dayOfWeek: 0
|
||||
})
|
||||
})
|
||||
|
||||
it('labels valid unsupported cron schedules as custom schedules', () => {
|
||||
expect(formatAutomationSchedule('*/30 9-17 * * MON-FRI')).toBe('Custom schedule')
|
||||
expect(formatAutomationSchedule('0 9 1 * *')).toBe('Custom schedule')
|
||||
expect(formatAutomationSchedule('0 9 1 * MON')).toBe('Custom schedule')
|
||||
expect(formatAutomationSchedule('0 9,17 * * MON-FRI')).toBe('Custom schedule')
|
||||
})
|
||||
|
||||
it('treats all-value cron day fields as unrestricted for DOM/DOW matching', () => {
|
||||
|
|
@ -107,6 +142,13 @@ describe('automation schedules', () => {
|
|||
|
||||
it('rejects syntactically valid cron schedules with no possible run', () => {
|
||||
expect(isValidAutomationSchedule('0 0 31 2 *')).toBe(false)
|
||||
expect(formatAutomationSchedule('0 0 31 2 *')).toBe('Invalid schedule')
|
||||
})
|
||||
|
||||
it('rejects RRULE input with the cron-only validator', () => {
|
||||
const rrule = buildAutomationRrule({ preset: 'daily', hour: 9, minute: 0 })
|
||||
expect(isValidAutomationSchedule(rrule)).toBe(true)
|
||||
expect(isValidAutomationCronSchedule(rrule)).toBe(false)
|
||||
})
|
||||
|
||||
it('finds rare but valid leap-day custom cron schedules', () => {
|
||||
|
|
|
|||
|
|
@ -30,6 +30,14 @@ type ParsedCron = {
|
|||
|
||||
type ParsedSchedule = ParsedRrule | ParsedCron
|
||||
|
||||
export type AutomationCronScheduleClassification =
|
||||
| { kind: 'hourly'; minute: number; label: string }
|
||||
| { kind: 'daily'; hour: number; minute: number; label: string }
|
||||
| { kind: 'weekdays'; hour: number; minute: number; label: string }
|
||||
| { kind: 'weekly'; hour: number; minute: number; dayOfWeek: number; label: string }
|
||||
| { kind: 'custom'; label: string }
|
||||
| { kind: 'invalid'; label: string }
|
||||
|
||||
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([
|
||||
|
|
@ -213,6 +221,15 @@ export function isValidAutomationSchedule(schedule: string): boolean {
|
|||
}
|
||||
}
|
||||
|
||||
export function isValidAutomationCronSchedule(schedule: string): boolean {
|
||||
try {
|
||||
const parsed = parseCronExpression(schedule.trim())
|
||||
return cronHasPossibleOccurrence(parsed, Date.now())
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export function parseAutomationRrule(rrule: string): {
|
||||
preset: AutomationSchedulePreset
|
||||
hour: number
|
||||
|
|
@ -264,11 +281,33 @@ function formatTime(hour: number, minute: number): string {
|
|||
}).format(date)
|
||||
}
|
||||
|
||||
export function formatAutomationSchedule(rrule: string): string {
|
||||
const schedule = tryParseAutomationRrule(rrule)
|
||||
if (!schedule) {
|
||||
return isValidAutomationSchedule(rrule) ? `Custom cron: ${rrule.trim()}` : 'Invalid schedule'
|
||||
function getSingleSetValue(values: Set<number>): number | null {
|
||||
if (values.size !== 1) {
|
||||
return null
|
||||
}
|
||||
return values.values().next().value as number
|
||||
}
|
||||
|
||||
function setContainsExactly(values: Set<number>, expected: readonly number[]): boolean {
|
||||
if (values.size !== expected.length) {
|
||||
return false
|
||||
}
|
||||
return expected.every((value) => values.has(value))
|
||||
}
|
||||
|
||||
function setContainsRange(values: Set<number>, min: number, max: number): boolean {
|
||||
if (values.size !== max - min + 1) {
|
||||
return false
|
||||
}
|
||||
for (let value = min; value <= max; value += 1) {
|
||||
if (!values.has(value)) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
function formatParsedRruleSchedule(schedule: ReturnType<typeof parseAutomationRrule>): string {
|
||||
if (schedule.preset === 'hourly') {
|
||||
return `Hourly at :${String(schedule.minute).padStart(2, '0')}`
|
||||
}
|
||||
|
|
@ -285,6 +324,76 @@ export function formatAutomationSchedule(rrule: string): string {
|
|||
return `${day}s at ${time}`
|
||||
}
|
||||
|
||||
function classifyParsedCronSchedule(rule: ParsedCron): AutomationCronScheduleClassification {
|
||||
if (!cronHasPossibleOccurrence(rule, Date.now())) {
|
||||
return { kind: 'invalid', label: 'Invalid schedule' }
|
||||
}
|
||||
const minute = getSingleSetValue(rule.minutes)
|
||||
const hour = getSingleSetValue(rule.hours)
|
||||
const unrestrictedDayOfMonth = !rule.dayOfMonthRestricted
|
||||
const unrestrictedMonth = setContainsRange(rule.months, 1, 12)
|
||||
const unrestrictedDayOfWeek = !rule.dayOfWeekRestricted
|
||||
const unrestrictedCalendar = unrestrictedDayOfMonth && unrestrictedMonth
|
||||
if (
|
||||
minute !== null &&
|
||||
setContainsRange(rule.hours, 0, 23) &&
|
||||
unrestrictedCalendar &&
|
||||
unrestrictedDayOfWeek
|
||||
) {
|
||||
return {
|
||||
kind: 'hourly',
|
||||
minute,
|
||||
label: `Hourly at :${String(minute).padStart(2, '0')}`
|
||||
}
|
||||
}
|
||||
if (minute !== null && hour !== null && unrestrictedCalendar) {
|
||||
const time = formatTime(hour, minute)
|
||||
if (unrestrictedDayOfWeek) {
|
||||
return { kind: 'daily', hour, minute, label: `Daily at ${time}` }
|
||||
}
|
||||
if (setContainsExactly(rule.daysOfWeek, [1, 2, 3, 4, 5])) {
|
||||
return { kind: 'weekdays', hour, minute, label: `Weekdays at ${time}` }
|
||||
}
|
||||
const dayOfWeek = getSingleSetValue(rule.daysOfWeek)
|
||||
if (dayOfWeek !== null) {
|
||||
const day = new Intl.DateTimeFormat(undefined, { weekday: 'long' }).format(
|
||||
new Date(2026, 0, 4 + dayOfWeek)
|
||||
)
|
||||
return {
|
||||
kind: 'weekly',
|
||||
hour,
|
||||
minute,
|
||||
dayOfWeek,
|
||||
label: `${day}s at ${time}`
|
||||
}
|
||||
}
|
||||
}
|
||||
return { kind: 'custom', label: 'Custom schedule' }
|
||||
}
|
||||
|
||||
export function classifyAutomationCronSchedule(
|
||||
schedule: string
|
||||
): AutomationCronScheduleClassification {
|
||||
try {
|
||||
return classifyParsedCronSchedule(parseCronExpression(schedule.trim()))
|
||||
} catch {
|
||||
return { kind: 'invalid', label: 'Invalid schedule' }
|
||||
}
|
||||
}
|
||||
|
||||
export function formatAutomationSchedule(scheduleExpression: string): string {
|
||||
try {
|
||||
const trimmed = scheduleExpression.trim()
|
||||
const schedule = parseSchedule(trimmed)
|
||||
if (schedule.kind === 'cron') {
|
||||
return classifyParsedCronSchedule(schedule).label
|
||||
}
|
||||
return formatParsedRruleSchedule(parseAutomationRrule(trimmed))
|
||||
} catch {
|
||||
return 'Invalid schedule'
|
||||
}
|
||||
}
|
||||
|
||||
function atLocalTime(dayMs: number, hour: number, minute: number): number {
|
||||
const date = new Date(dayMs)
|
||||
date.setHours(hour, minute, 0, 0)
|
||||
|
|
|
|||
Loading…
Reference in New Issue