[aidan] feat/task-scheduling: add hourly and minute (15-min minimum) schedule intervals (#93)

This commit is contained in:
Aidan
2026-06-16 23:50:52 -07:00
committed by GitHub
parent 11bbabd0ed
commit e84eceee5b
11 changed files with 173 additions and 27 deletions
+6 -6
View File
@@ -57,12 +57,12 @@ TOOLS = [
"preset": {
"type": "string",
"enum": ["daily_morning", "weekdays_morning", "weekly_monday", "weekly_friday", "monthly_first", "custom"],
"description": "Cadence preset. Use 'custom' to specify your own hour/minute/days.",
"description": "Cadence preset. Use 'custom' for anything else, including sub-day cadences like 'every 20 minutes' (repeat_unit='minute') or 'every 3 hours' (repeat_unit='hour').",
},
"hour": {"type": "integer", "description": "Hour 0-23 in the user's local time. Required when preset='custom'."},
"minute": {"type": "integer", "description": "Minute 0/15/30/45. Required when preset='custom'."},
"repeat_unit": {"type": "string", "enum": ["day", "week", "month"], "description": "Required when preset='custom'."},
"repeat_every": {"type": "integer", "description": "Interval count for repeat_unit when preset='custom' (e.g. repeat_unit='week' + repeat_every=2 means every other week). Defaults to 1."},
"minute": {"type": "integer", "description": "Minute 0/15/30/45. For repeat_unit='hour' this is the minute past the hour; ignored for repeat_unit='minute'. Required when preset='custom'."},
"repeat_unit": {"type": "string", "enum": ["minute", "hour", "day", "week", "month"], "description": "Required when preset='custom'. 'minute' fires every repeat_every minutes (min 15); 'hour' fires every repeat_every hours."},
"repeat_every": {"type": "integer", "description": "Interval count for repeat_unit when preset='custom' (e.g. repeat_unit='week' + repeat_every=2 means every other week; repeat_unit='minute' + repeat_every=15 means every 15 minutes). Defaults to 1; minimum 15 when repeat_unit='minute'."},
"on_days": {
"type": "array",
"items": {"type": "integer"},
@@ -91,8 +91,8 @@ TOOLS = [
"schedule_enabled": {"type": "boolean", "description": "Quick on/off without changing other schedule fields."},
"hour": {"type": "integer", "description": "Hour 0-23 in the schedule's timezone."},
"minute": {"type": "integer", "description": "Minute 0-59."},
"repeat_unit": {"type": "string", "enum": ["day", "week", "month"]},
"repeat_every": {"type": "integer", "description": "Interval count for repeat_unit (e.g. 2 with repeat_unit='week' means every other week)."},
"repeat_unit": {"type": "string", "enum": ["minute", "hour", "day", "week", "month"]},
"repeat_every": {"type": "integer", "description": "Interval count for repeat_unit (e.g. 2 with repeat_unit='week' means every other week; 15 with repeat_unit='minute' means every 15 minutes, the minimum)."},
"on_days": {"type": "array", "items": {"type": "integer"}, "description": "Weekdays (Sun=0..Sat=6) when repeat_unit='week'."},
"timezone": {"type": "string", "description": "IANA timezone name (e.g. 'America/Los_Angeles')."},
},
+18 -4
View File
@@ -1,4 +1,4 @@
from pydantic import BaseModel, ConfigDict, Field, field_validator
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
from typing import Optional, Literal, Any
from datetime import datetime
from uuid import uuid4
@@ -18,9 +18,11 @@ class ScheduleConfig(BaseModel):
# Bounds keep the scheduler from blowing up on malformed input. The
# FE clamps these too, but defense-in-depth: a misbehaving agent
# tool, an old JSON file, or a curl-wielding power user shouldn't
# be able to crash _next_fire_after by passing hour=99.
repeat_every: int = Field(default=1, ge=1, le=365)
repeat_unit: Literal["day", "week", "month"] = "week"
# be able to crash _next_fire_after by passing hour=99. The per-unit
# upper bound on repeat_every is clamped (not rejected) in
# _enforce_interval_bounds below, so only the floor lives on the Field.
repeat_every: int = Field(default=1, ge=1)
repeat_unit: Literal["minute", "hour", "day", "week", "month"] = "week"
on_days: list[int] = Field(default_factory=list)
hour: int = Field(default=9, ge=0, le=23)
minute: int = Field(default=0, ge=0, le=59)
@@ -51,6 +53,18 @@ class ScheduleConfig(BaseModel):
out.append(d)
return out
@model_validator(mode="after")
def _enforce_interval_bounds(self) -> "ScheduleConfig":
# Per-unit bounds, clamped rather than rejected so a stray value from
# an agent tool or old record can't crash the scheduler. The minute
# unit floors at 15 (no once-a-minute token-burning loop) and ceilings
# at 1440 (24h); every other unit keeps the original 365 ceiling.
if self.repeat_unit == "minute":
self.repeat_every = max(15, min(self.repeat_every, 1440))
else:
self.repeat_every = min(self.repeat_every, 365)
return self
class ActionsConfig(BaseModel):
prevent_unused: bool = False
+25 -7
View File
@@ -7,9 +7,11 @@ startup we walk persisted workflows once, decide what to do about missed
fires via on_missed, and queue each.
Schedule semantics:
unit=day: fires every repeat_every days at hour:minute
unit=week: fires on the listed weekday(s) every repeat_every weeks
unit=month: fires on the original day-of-month every repeat_every months
unit=minute: fires every repeat_every minutes (15 is the enforced floor)
unit=hour: fires every repeat_every hours at :minute past the hour
unit=day: fires every repeat_every days at hour:minute
unit=week: fires on the listed weekday(s) every repeat_every weeks
unit=month: fires on the original day-of-month every repeat_every months
Wall-clock math runs in the workflow's IANA timezone, then we convert to
UTC at the boundary. This is the only safe way to honor DST (a "9am
@@ -105,6 +107,21 @@ def _next_fire_after(sched: ScheduleConfig, ref_utc: datetime) -> Optional[datet
tz = _resolve_tz(sched.timezone)
ref_local = ref_utc.astimezone(tz)
base = ref_local.replace(second=0, microsecond=0)
if sched.repeat_unit == "minute":
step = max(15, sched.repeat_every)
c = base
while c <= ref_local:
c = c + timedelta(minutes=step)
return c.astimezone(timezone.utc)
if sched.repeat_unit == "hour":
step = max(1, sched.repeat_every)
c = base.replace(minute=sched.minute)
while c <= ref_local:
c = c + timedelta(hours=step)
return c.astimezone(timezone.utc)
candidate = base.replace(hour=sched.hour, minute=sched.minute)
if candidate <= ref_local:
candidate = candidate + timedelta(days=1)
@@ -144,8 +161,9 @@ def compute_next_fire(wf: Workflow, ref: Optional[datetime] = None) -> Optional[
def fires_in_window(wf: Workflow, days: int = 30) -> int:
"""Count fires from now through `days` days from now. Used by the
cost-estimate response. Honors end conditions so the projection doesn't
over-count after ends_at or max_runs. Caps the walk at 1000 fires to
guard pathological sub-day schedules (none today, but cheap insurance).
over-count after ends_at or max_runs. Caps the walk at 5000 fires: a
15-minute schedule fires ~2880x in 30 days, so the cap has to clear that
to keep the estimate honest while still bounding the loop.
"""
sched = wf.schedule
if not sched.enabled:
@@ -158,10 +176,10 @@ def fires_in_window(wf: Workflow, days: int = 30) -> int:
if ends_at_utc is not None and ends_at_utc < end_utc:
end_utc = ends_at_utc
remaining_budget = (
sched.max_runs - sched.runs_count if sched.max_runs is not None else 1000
sched.max_runs - sched.runs_count if sched.max_runs is not None else 5000
)
count = 0
while count < min(1000, remaining_budget):
while count < min(5000, remaining_budget):
nxt = _next_fire_after(sched, cursor_utc)
if nxt is None or nxt > end_utc:
break
+3 -2
View File
@@ -734,8 +734,9 @@ async def schedule_agent_session(workflow_id: str):
f" - workflow_id: \"{wf.id}\"\n"
" - schedule_enabled: true\n"
" - hour (0-23) and minute (0-59) in the user's local time\n"
" - repeat_unit: \"day\" | \"week\" | \"month\"\n"
" - repeat_every: the interval count (1 unless they say e.g. \"every other\")\n"
" - repeat_unit: \"minute\" | \"hour\" | \"day\" | \"week\" | \"month\"\n"
" - repeat_every: the interval count (1 unless they say e.g. \"every other\"; "
"for repeat_unit=\"minute\" the minimum is 15, e.g. \"every 15 minutes\")\n"
" - on_days: weekday indices when repeat_unit=\"week\" (Sun=0, Mon=1, ... Sat=6)\n"
" - timezone: an IANA name only if the user names a specific zone\n\n"
"If no AM/PM is given, assume PM for 1-7 and AM for 8-12. If the cadence "
+18 -1
View File
@@ -84,6 +84,7 @@
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/code-frame": "^7.29.0",
"@babel/generator": "^7.29.0",
@@ -1964,6 +1965,7 @@
"resolved": "https://registry.npmjs.org/@emotion/react/-/react-11.14.0.tgz",
"integrity": "sha512-O000MLDBDdk/EohJPFUqvnp4qnHeYkVP5B0xEG0D/L7cOKP9kefu2DXn8dj74cQfsEzUqh+sr1RzFqiL1o+PpA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/runtime": "^7.18.3",
"@emotion/babel-plugin": "^11.13.5",
@@ -2007,6 +2009,7 @@
"resolved": "https://registry.npmjs.org/@emotion/styled/-/styled-11.14.1.tgz",
"integrity": "sha512-qEEJt42DuToa3gurlH4Qqc1kVpNq8wO8cJtDzU46TjlzWjDlsVyevtYCRijVq3SrHsROS+gVQ8Fnea108GnKzw==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/runtime": "^7.18.3",
"@emotion/babel-plugin": "^11.13.5",
@@ -2242,6 +2245,7 @@
"resolved": "https://registry.npmjs.org/@mui/material/-/material-7.3.10.tgz",
"integrity": "sha512-cHvGOk2ZEfbQt3LnGe0ZKd/ETs9gsUpkW66DCO+GSjMZhpdKU4XsuIr7zJ/B/2XaN8ihxuzHfYAR4zPtCN4RYg==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/runtime": "^7.28.6",
"@mui/core-downloads-tracker": "^7.3.10",
@@ -3374,6 +3378,7 @@
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.28.tgz",
"integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==",
"license": "MIT",
"peer": true,
"dependencies": {
"@types/prop-types": "*",
"csstype": "^3.2.2"
@@ -3770,6 +3775,7 @@
"integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -3809,6 +3815,7 @@
"integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"fast-deep-equal": "^3.1.3",
"fast-uri": "^3.0.1",
@@ -4137,6 +4144,7 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.10.12",
"caniuse-lite": "^1.0.30001782",
@@ -8184,6 +8192,7 @@
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -8240,6 +8249,7 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"nanoid": "^3.3.11",
"picocolors": "^1.1.1",
@@ -8458,6 +8468,7 @@
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
"integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"loose-envify": "^1.1.0"
},
@@ -8470,6 +8481,7 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
"integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
"license": "MIT",
"peer": true,
"dependencies": {
"loose-envify": "^1.1.0",
"scheduler": "^0.23.2"
@@ -8516,6 +8528,7 @@
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz",
"integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==",
"license": "MIT",
"peer": true,
"dependencies": {
"@types/use-sync-external-store": "^0.0.6",
"use-sync-external-store": "^1.4.0"
@@ -8654,7 +8667,8 @@
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/redux-thunk": {
"version": "3.1.0",
@@ -8966,6 +8980,7 @@
"integrity": "sha512-kgW13M54DUB7IsIRM5LvJkNlpH+WhMpooUcaWGFARkF1Tc82v9mIWkCbCYf+MBvpIUBSeSOTilpZjEPr2VYE6Q==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"chokidar": "^4.0.0",
"immutable": "^5.1.5",
@@ -10069,6 +10084,7 @@
"integrity": "sha512-wGN3qcrBQIFmQ/c0AiOAQBvrZ5lmY8vbbMv4Mxfgzqd/B6+9pXtLo73WuS1dSGXM5QYY3hZnIbvx+K1xxe6FyA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@types/eslint-scope": "^3.7.7",
"@types/estree": "^1.0.8",
@@ -10117,6 +10133,7 @@
"integrity": "sha512-pIDJHIEI9LR0yxHXQ+Qh95k2EvXpWzZ5l+d+jIo+RdSm9MiHfzazIxwwni/p7+x4eJZuvG1AJwgC4TNQ7NRgsg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@discoveryjs/json-ext": "^0.5.0",
"@webpack-cli/configtest": "^2.1.1",
@@ -58,6 +58,18 @@ function previewNextRun(sched: ScheduleConfig): Date | null {
if (!Number.isNaN(ends.getTime()) && ends.getTime() <= now.getTime()) return null;
}
if (sched.max_runs != null && sched.runs_count >= sched.max_runs) return null;
if (sched.repeat_unit === 'minute') {
const step = Math.max(15, sched.repeat_every);
let c = new Date(now.getFullYear(), now.getMonth(), now.getDate(), now.getHours(), now.getMinutes(), 0, 0);
while (c <= now) c = new Date(c.getTime() + step * 60000);
return c;
}
if (sched.repeat_unit === 'hour') {
const step = Math.max(1, sched.repeat_every);
let c = new Date(now.getFullYear(), now.getMonth(), now.getDate(), now.getHours(), sched.minute, 0, 0);
while (c <= now) c = new Date(c.getTime() + step * 3600000);
return c;
}
let candidate = new Date(now.getFullYear(), now.getMonth(), now.getDate(), sched.hour, sched.minute, 0, 0);
if (candidate <= now) candidate = new Date(candidate.getTime() + 86400000);
if (sched.repeat_unit === 'day') {
@@ -192,7 +204,7 @@ export default function ScheduleFacet({ draft, setDraft }: { draft: Workflow; se
</Box>
{s.enabled && (
<AppOpenStatusBadge info={appOpen} hour={s.hour} minute={s.minute} onFix={fixAppOpen} />
<AppOpenStatusBadge info={appOpen} hour={s.hour} minute={s.minute} frequent={s.repeat_unit === 'minute' || s.repeat_unit === 'hour'} onFix={fixAppOpen} />
)}
{/* Section: When should this workflow run? */}
@@ -205,14 +217,29 @@ export default function ScheduleFacet({ draft, setDraft }: { draft: Workflow; se
<InputBase
type="number"
value={s.repeat_every}
onChange={(e) => setSched({ repeat_every: Math.max(1, Number(e.target.value) || 1) })}
onChange={(e) => {
// Per-unit bounds mirror the backend: minute 15..1440 (24h),
// every other unit 1..365.
const min = s.repeat_unit === 'minute' ? 15 : 1;
const max = s.repeat_unit === 'minute' ? 1440 : 365;
setSched({ repeat_every: Math.min(max, Math.max(min, Number(e.target.value) || min)) });
}}
sx={{ width: 56, fontSize: INPUT_FS, border: `1px solid ${c.border.subtle}`, borderRadius: `${c.radius.md}px`, px: 0.75, py: 0.4 }}
/>
<Select
size="small"
value={s.repeat_unit}
onChange={(e) => setSched({ repeat_unit: e.target.value as ScheduleConfig['repeat_unit'] })}
onChange={(e) => {
const unit = e.target.value as ScheduleConfig['repeat_unit'];
// 15 is the floor for the minute unit; bump repeat_every up
// when switching in so the input never shows an invalid value.
const patch: Partial<ScheduleConfig> = { repeat_unit: unit };
if (unit === 'minute' && s.repeat_every < 15) patch.repeat_every = 15;
setSched(patch);
}}
sx={{ fontSize: LABEL_FS, '& .MuiSelect-select': { py: 0.5 } }}>
<MenuItem value="minute">minute</MenuItem>
<MenuItem value="hour">hour</MenuItem>
<MenuItem value="day">day</MenuItem>
<MenuItem value="week">week</MenuItem>
<MenuItem value="month">month</MenuItem>
@@ -233,6 +260,25 @@ export default function ScheduleFacet({ draft, setDraft }: { draft: Workflow; se
})}
</Box>
)}
{/* minute-unit schedules have no anchor time; hour-unit schedules
only need the minute offset; the rest pick a full clock time. */}
{s.repeat_unit === 'hour' && (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, flexWrap: 'wrap' }}>
<Typography sx={{ fontSize: LABEL_FS, color: c.text.secondary, minWidth: 96 }}>At</Typography>
<Typography sx={{ fontSize: INPUT_FS, color: c.text.muted }}>:</Typography>
<Select
size="small"
value={s.minute}
onChange={(e) => setSched({ minute: Number(e.target.value) })}
sx={{ fontSize: LABEL_FS, '& .MuiSelect-select': { py: 0.4 } }}>
{[0, 15, 30, 45].map((m) => (
<MenuItem key={m} value={m}>{String(m).padStart(2, '0')}</MenuItem>
))}
</Select>
<Typography sx={{ fontSize: HINT_FS, color: c.text.muted }}>past the hour</Typography>
</Box>
)}
{(s.repeat_unit === 'day' || s.repeat_unit === 'week' || s.repeat_unit === 'month') && (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, flexWrap: 'wrap' }}>
<Typography sx={{ fontSize: LABEL_FS, color: c.text.secondary, minWidth: 96 }}>At</Typography>
<Select
@@ -274,6 +320,7 @@ export default function ScheduleFacet({ draft, setDraft }: { draft: Workflow; se
</Select>
<Typography sx={{ fontSize: HINT_FS, color: c.text.ghost, ml: 0.5 }}>{friendlyTzLabel(s.timezone)}</Typography>
</Box>
)}
{nextPreview && s.enabled && (
<Typography sx={{ fontSize: HINT_FS, color: c.accent.primary, pl: 12, fontWeight: 500 }}>
Next run: {formatNextRun(nextPreview)}
@@ -391,7 +438,7 @@ export default function ScheduleFacet({ draft, setDraft }: { draft: Workflow; se
);
}
function AppOpenStatusBadge({ info, hour, minute, onFix }: { info: AppOpenInfo; hour: number; minute: number; onFix: () => void }) {
function AppOpenStatusBadge({ info, hour, minute, frequent, onFix }: { info: AppOpenInfo; hour: number; minute: number; frequent: boolean; onFix: () => void }) {
const c = useClaudeTokens();
const good = info.alwaysOn;
const fmt = formatTime(hour, minute);
@@ -404,7 +451,7 @@ function AppOpenStatusBadge({ info, hour, minute, onFix }: { info: AppOpenInfo;
}}>
<Box sx={{ width: 8, height: 8, borderRadius: '50%', bgcolor: good ? c.status.success : (c.status.warning || c.text.muted) }} />
<Typography sx={{ flex: 1, fontSize: HINT_FS, color: c.text.primary }}>
{good ? 'Will run even if you close OpenSwarm.' : `OpenSwarm must be open at ${fmt} for this to run.`}
{good ? 'Will run even if you close OpenSwarm.' : (frequent ? 'OpenSwarm must be open for this to run.' : `OpenSwarm must be open at ${fmt} for this to run.`)}
</Typography>
{!good && (
<Tooltip title="One click: start OpenSwarm automatically when you log in, and keep a small icon in your menubar so it stays running when you close the window. You can undo both later in Settings.">
@@ -263,6 +263,8 @@ function describeSchedule(workflow: Workflow): string {
const h12 = ((s.hour + 11) % 12) + 1;
const ampm = s.hour < 12 ? 'am' : 'pm';
const time = s.minute === 0 ? `${h12}${ampm}` : `${h12}:${String(s.minute).padStart(2, '0')}${ampm}`;
if (s.repeat_unit === 'minute') return `Every ${s.repeat_every} minutes`;
if (s.repeat_unit === 'hour') return s.repeat_every === 1 ? `Hourly at :${String(s.minute).padStart(2, '0')}` : `Every ${s.repeat_every} hours`;
if (s.repeat_unit === 'day') return s.repeat_every === 1 ? `Daily at ${time}` : `Every ${s.repeat_every} days at ${time}`;
if (s.repeat_unit === 'month') return s.repeat_every === 1 ? `Monthly at ${time}` : `Every ${s.repeat_every} months at ${time}`;
if (s.on_days.length === 5 && [1,2,3,4,5].every((d) => s.on_days.includes(d))) return `Weekdays at ${time}`;
@@ -36,9 +36,12 @@ export function detectSchedule(text: string): DetectedSchedule | null {
const isWeekdays = /\b(weekdays?|each weekday|every weekday|mon(?:day)?\s*(?:to|-|through|)\s*fri(?:day)?)\b/.test(t);
const isWeekly = /\b(every week|weekly|each week|once a week)\b/.test(t);
const isMonthly = /\b(every month|monthly|each month|once a month)\b/.test(t);
const hourlyMatch = t.match(/\bevery\s+(\d{1,2})\s+hours?\b/);
const isHourly = /\b(every hour|hourly|each hour|once an hour)\b/.test(t) || !!hourlyMatch;
const minutesMatch = t.match(/\bevery\s+(\d{1,3})\s+min(?:ute)?s?\b/);
const dayMatches = Array.from(t.matchAll(DAY_RE)).map((m) => DAY_MAP[m[1].toLowerCase().slice(0, 3)]);
const hasExplicitDays = dayMatches.length > 0;
if (!isDaily && !isWeekdays && !isWeekly && !isMonthly && !hasExplicitDays) return null;
if (!isDaily && !isWeekdays && !isWeekly && !isMonthly && !hasExplicitDays && !isHourly && !minutesMatch) return null;
// Extract hour:minute.
let hour = 9;
@@ -65,6 +68,21 @@ export function detectSchedule(text: string): DetectedSchedule | null {
}
const base = defaultSchedule();
// Sub-day cadences are the most specific; match them before daily/weekly.
if (minutesMatch) {
const every = Math.max(15, parseInt(minutesMatch[1], 10) || 15);
return {
schedule: { ...base, enabled: true, repeat_unit: 'minute', repeat_every: every },
presetLabel: `Every ${every} minutes`,
};
}
if (isHourly) {
const every = Math.max(1, hourlyMatch ? parseInt(hourlyMatch[1], 10) || 1 : 1);
return {
schedule: { ...base, enabled: true, repeat_unit: 'hour', repeat_every: every, minute },
presetLabel: every === 1 ? 'Every hour' : `Every ${every} hours`,
};
}
if (isMonthly) {
return {
schedule: { ...base, enabled: true, repeat_unit: 'month', repeat_every: 1, hour, minute },
@@ -43,6 +43,13 @@ export function formatHourLabel(hour: number): string {
export function describeSchedule(sched: ScheduleConfig): string {
if (!sched.enabled) return 'Not scheduled';
const time = formatTime(sched.hour, sched.minute);
if (sched.repeat_unit === 'minute') {
return `Every ${sched.repeat_every} minutes`;
}
if (sched.repeat_unit === 'hour') {
const at = sched.minute === 0 ? '' : ` at :${String(sched.minute).padStart(2, '0')}`;
return sched.repeat_every === 1 ? `Every hour${at}` : `Every ${sched.repeat_every} hours${at}`;
}
if (sched.repeat_unit === 'day') {
return sched.repeat_every === 1 ? `Every day at ${time}` : `Every ${sched.repeat_every} days at ${time}`;
}
@@ -128,6 +135,26 @@ export function fireTimesWithin(workflow: Workflow, from: Date, to: Date, cap =
const cursor = new Date(from);
cursor.setHours(0, 0, 0, 0);
if (sched.repeat_unit === 'minute') {
const step = Math.max(15, sched.repeat_every);
const d = new Date(from);
d.setSeconds(0, 0);
for (; d <= to && out.length < effectiveCap; d.setTime(d.getTime() + step * 60000)) {
if (d >= from) out.push(new Date(d));
}
return out;
}
if (sched.repeat_unit === 'hour') {
const step = Math.max(1, sched.repeat_every);
const d = new Date(from);
d.setMinutes(sched.minute, 0, 0);
for (; d <= to && out.length < effectiveCap; d.setTime(d.getTime() + step * 3600000)) {
if (d >= from) out.push(new Date(d));
}
return out;
}
if (sched.repeat_unit === 'day') {
const step = Math.max(1, sched.repeat_every);
for (let i = 0; i < 366 && out.length < effectiveCap; i += step) {
@@ -84,6 +84,8 @@ export function StatusDot({ status }: { status: LastRunStatus | null | undefined
function scheduleShort(sched: ScheduleConfig): string {
if (!sched.enabled) return 'Not scheduled';
const time = formatTime(sched.hour, sched.minute);
if (sched.repeat_unit === 'minute') return `Every ${sched.repeat_every}m`;
if (sched.repeat_unit === 'hour') return sched.repeat_every === 1 ? 'Hourly' : `Every ${sched.repeat_every}h`;
if (sched.repeat_unit === 'day') {
return sched.repeat_every === 1 ? `Daily ${time}` : `Every ${sched.repeat_every}d ${time}`;
}
+1 -1
View File
@@ -14,7 +14,7 @@ export interface PermissionTier {
export interface ScheduleConfig {
enabled: boolean;
repeat_every: number;
repeat_unit: 'day' | 'week' | 'month';
repeat_unit: 'minute' | 'hour' | 'day' | 'week' | 'month';
on_days: number[];
hour: number;
minute: number;