diff --git a/backend/apps/agents/schedule_mcp_server.py b/backend/apps/agents/schedule_mcp_server.py index e900333e..e5c9c064 100644 --- a/backend/apps/agents/schedule_mcp_server.py +++ b/backend/apps/agents/schedule_mcp_server.py @@ -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')."}, }, diff --git a/backend/apps/workflows/models.py b/backend/apps/workflows/models.py index db8b25fa..1d51faf5 100644 --- a/backend/apps/workflows/models.py +++ b/backend/apps/workflows/models.py @@ -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 diff --git a/backend/apps/workflows/scheduler.py b/backend/apps/workflows/scheduler.py index d05a5be3..cbee5521 100644 --- a/backend/apps/workflows/scheduler.py +++ b/backend/apps/workflows/scheduler.py @@ -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 diff --git a/backend/apps/workflows/workflows.py b/backend/apps/workflows/workflows.py index b87eef12..91901bc9 100644 --- a/backend/apps/workflows/workflows.py +++ b/backend/apps/workflows/workflows.py @@ -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 " diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 92d511d7..8e97f035 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -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", diff --git a/frontend/src/app/pages/Workflows/ScheduleFacet.tsx b/frontend/src/app/pages/Workflows/ScheduleFacet.tsx index 2bce61ff..a1668c70 100644 --- a/frontend/src/app/pages/Workflows/ScheduleFacet.tsx +++ b/frontend/src/app/pages/Workflows/ScheduleFacet.tsx @@ -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 {s.enabled && ( - + )} {/* Section: When should this workflow run? */} @@ -205,14 +217,29 @@ export default function ScheduleFacet({ draft, setDraft }: { draft: Workflow; se 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 }} /> setSched({ minute: Number(e.target.value) })} + sx={{ fontSize: LABEL_FS, '& .MuiSelect-select': { py: 0.4 } }}> + {[0, 15, 30, 45].map((m) => ( + {String(m).padStart(2, '0')} + ))} + + past the hour + + )} + {(s.repeat_unit === 'day' || s.repeat_unit === 'week' || s.repeat_unit === 'month') && ( At