diff --git a/README.md b/README.md index 3848361b..1ae82918 100644 --- a/README.md +++ b/README.md @@ -221,6 +221,8 @@ Summaries are written by agents — either self-initiated when a significant dis ### Scheduled messages Schedule one-shot or recurring messages from the split send button. Click the clock icon next to Send to open the schedule popover — pick a date/time for one-shot, or check Recurring and set an interval (minutes, hours, or days). Scheduled messages fire as real chat messages from you, complete with @mentions that trigger agents automatically. +Instead of a fixed date and time, check **In a while from now** and give it hours and minutes to send relative to the moment you schedule it, which helps when you are waiting on something rather than aiming at a clock time. A one-shot time that has already passed is refused with a reason instead of firing straight away. + A schedule strip above the composer shows active and paused schedules. For a single schedule, inline pause and delete controls appear directly in the strip. For multiple schedules, expand the strip to manage them. Schedules persist across server restarts (stored in `data/schedules.json`). The schedule popover validates that at least one agent is toggled before enabling the Schedule button — a yellow warning tells you what's needed. diff --git a/app.py b/app.py index 60a6434e..f2f9b2e5 100644 --- a/app.py +++ b/app.py @@ -1595,29 +1595,46 @@ async def create_schedule(request: Request): targets = body.get("targets", []) channel = body.get("channel", "general") spec = body.get("spec", "") - one_shot = body.get("one_shot", False) + one_shot = bool(body.get("one_shot", False)) send_at_date = body.get("send_at_date", "") # "YYYY-MM-DD" for one-shot created_by = body.get("created_by", "user") if not prompt or not targets or not spec: return JSONResponse({"error": "prompt, targets, and spec are required"}, status_code=400) + # A relative send ("in 2h 30m") is resolved by the client, which posts the + # resulting moment directly; there is no recurrence to parse out of it. + # A send_at names a single moment, so it only means anything for a one-shot. + # A recurring request ignores it, exactly as it did before the field + # existed; that keeps an unparseable spec falling through to the 400 below + # instead of being stored as an unasked-for daily repeat. + explicit_send_at = None + if one_shot and body.get("send_at") is not None: + try: + explicit_send_at = float(body["send_at"]) + except (TypeError, ValueError, OverflowError): + return JSONResponse( + {"error": "send_at must be an epoch timestamp"}, status_code=400 + ) interval_sec, daily_at = parse_schedule_spec(spec) - if interval_sec is None: + if interval_sec is None and explicit_send_at is None: return JSONResponse({"error": f"Invalid schedule spec: {spec}"}, status_code=400) # For one-shot, compute exact send_at timestamp from date + daily_at time - send_at = None - if one_shot and daily_at and send_at_date: + send_at = explicit_send_at + if send_at is None and one_shot and daily_at and send_at_date: import datetime as _dt try: dt = _dt.datetime.strptime(f"{send_at_date} {daily_at}", "%Y-%m-%d %H:%M") send_at = dt.timestamp() except ValueError: pass - s = schedules.create( - prompt=prompt, targets=targets, channel=channel, - interval_seconds=interval_sec, daily_at=daily_at, - one_shot=one_shot, send_at=send_at, - created_by=created_by, - ) + try: + s = schedules.create( + prompt=prompt, targets=targets, channel=channel, + interval_seconds=interval_sec, daily_at=daily_at, + one_shot=one_shot, send_at=send_at, + created_by=created_by, + ) + except ValueError as exc: + return JSONResponse({"error": str(exc)}, status_code=400) return JSONResponse(s) diff --git a/schedules.py b/schedules.py index 5cf71694..8a9421cd 100644 --- a/schedules.py +++ b/schedules.py @@ -1,6 +1,7 @@ """Schedule store — recurring prompts fired without human intervention.""" import json +import math import re import time import threading @@ -18,6 +19,10 @@ re.IGNORECASE ) +# Upper bound for an explicit send time (2100-01-01). Anything beyond this is +# a mistake or an attack: it never fires, so the row sticks in the list forever. +MAX_SEND_AT = 4102444800.0 + def parse_schedule_spec(spec: str) -> tuple[int | None, str | None]: """Parse natural-language schedule spec. @@ -155,8 +160,26 @@ def create( last_run = None if daily_at: interval_seconds = 86400 - if send_at: + if send_at is not None: + try: + send_at = float(send_at) + except (TypeError, ValueError, OverflowError): + # OverflowError: JSON ints are arbitrary precision, so a + # 400-digit literal reaches float() and blows up there. + raise ValueError("That time is not a real moment") + # NaN loses every comparison, so an unchecked NaN would slip past + # the past-time guard below and then be written to the JSON store + # as a bare NaN, which no later read of that file can parse. + if not math.isfinite(send_at) or send_at > MAX_SEND_AT: + raise ValueError("That time is not a real moment") + if send_at <= now: + raise ValueError("That time has already passed - pick a later one") next_run = send_at + if one_shot: + # A one-shot is defined solely by next_run. Keeping daily_at + # would claim a daily recurrence the schedule does not have, + # and the schedules bar renders that field verbatim. + daily_at = None else: next_run = compute_next_run( interval_seconds or 86400, diff --git a/static/chat.js b/static/chat.js index 338653c4..2ca03dc0 100644 --- a/static/chat.js +++ b/static/chat.js @@ -2349,6 +2349,9 @@ function setupInput() { updateSlashMenu(input.value); updateMentionMenu(); updateSendButton(); + // Targets come from the composer, so the schedule popover's state goes + // stale as soon as this changes. It no-ops while the popover is closed. + updateSchedulePopoverState(); } input.addEventListener('input', onInputChange); // Voice typing doesn't always fire 'input' — catch with additional events @@ -3429,7 +3432,22 @@ function formatScheduleTime(ts) { return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' }); } +function formatOneShotWhen(ts) { + if (!ts) return 'once'; + const d = new Date(ts * 1000); + const time = d.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' }); + const today = new Date(); + const tomorrow = new Date(today); + tomorrow.setDate(tomorrow.getDate() + 1); + if (d.toDateString() === today.toDateString()) return 'once at ' + time; + if (d.toDateString() === tomorrow.toDateString()) return 'once tomorrow at ' + time; + const day = d.toLocaleDateString(undefined, { weekday: 'short', month: 'short', day: 'numeric' }); + return 'once on ' + day + ' at ' + time; +} + function formatScheduleInterval(s) { + // A one-shot has no recurrence; its next_run is the whole story. + if (s.one_shot) return formatOneShotWhen(s.next_run); if (s.daily_at) return 'daily at ' + s.daily_at; const sec = s.interval_seconds || 0; if (sec < 3600) return 'every ' + Math.round(sec / 60) + 'm'; @@ -3488,6 +3506,13 @@ function toggleSchedulePopover(e) { pop.classList.toggle('hidden'); if (opening) { populateScheduleDropdowns(); + // Always reopen on the absolute path. Leaving "in a while" checked + // from a previous send would carry its past-time guard off with it. + const rel = document.getElementById('sched-relative'); + if (rel && rel.checked) { + rel.checked = false; + toggleRelativeFields(); + } updateSchedulePopoverState(); } } @@ -3500,9 +3525,11 @@ function closeSchedulePopover() { function stepNumInput(id, delta) { const el = document.getElementById(id); if (!el) return; - const min = parseInt(el.min) || 1; - const max = parseInt(el.max) || 99; - const val = Math.max(min, Math.min(max, (parseInt(el.value) || min) + delta)); + // Number.isFinite, not `||`: a legitimate min/value of 0 is falsy. + const min = Number.isFinite(parseInt(el.min)) ? parseInt(el.min) : 1; + const max = Number.isFinite(parseInt(el.max)) ? parseInt(el.max) : 99; + const current = Number.isFinite(parseInt(el.value)) ? parseInt(el.value) : min; + const val = Math.max(min, Math.min(max, current + delta)); el.value = val; el.dispatchEvent(new Event('input', { bubbles: true })); el.dispatchEvent(new Event('change', { bubbles: true })); @@ -3512,15 +3539,67 @@ function stepSchedNum(delta) { stepNumInput('sched-interval-val', delta); } +let _scheduleSubmitInFlight = false; + +function isSchedulePopoverRecurring() { + return !!document.getElementById('sched-recurring')?.checked; +} + +function isSchedulePopoverRelative() { + return !!document.getElementById('sched-relative')?.checked; +} + +function readClampedNum(id) { + // There is no