[eric] scheduled tasks: tz-safe scheduler, tray + updater veto, schedule UX

This commit is contained in:
ciregenz
2026-05-17 22:55:00 -07:00
parent e189ee98e1
commit f97af593cc
37 changed files with 3142 additions and 618 deletions
+95
View File
@@ -0,0 +1,95 @@
"""Append-only audit log for workflow edits.
One JSONL file per workflow at <DATA_ROOT>/workflows/audit/<wid>.jsonl. We
diff before/after rather than snapshotting the full record so the file
stays small even after dozens of edits. Read path tails the file; we don't
keep this in memory because audits are inspected rarely.
"""
import json
import logging
import os
from datetime import datetime, timezone
from threading import Lock
from typing import Any
from backend.apps.workflows.storage import DATA_DIR
logger = logging.getLogger(__name__)
AUDIT_DIR = os.path.join(DATA_DIR, "audit")
_io_lock = Lock()
# Soft cap on bytes per audit file. When exceeded we truncate to the last
# CAP/2 bytes on next write so attackers (or a runaway PATCH loop) can't
# fill the disk. 256 KiB is ~2000 edits; we never expect to hit it.
SOFT_CAP_BYTES = 256 * 1024
def _audit_path(wid: str) -> str:
return os.path.join(AUDIT_DIR, f"{wid}.jsonl")
def _diff(before: dict, after: dict) -> dict[str, dict[str, Any]]:
"""Return only the keys whose value changed. Nested dicts are diffed
shallowly; the schedule/actions/permissions blocks are small so we just
record the whole sub-dict when any sub-key changes.
"""
changed: dict[str, dict[str, Any]] = {}
keys = set(before) | set(after)
for k in keys:
b = before.get(k)
a = after.get(k)
if b != a:
changed[k] = {"before": b, "after": a}
return changed
def log_change(wid: str, who: str, before: dict, after: dict) -> None:
diff = _diff(before, after)
if not diff:
return
entry = {
"ts": datetime.now(timezone.utc).isoformat(),
"who": who,
"diff": diff,
}
try:
with _io_lock:
os.makedirs(AUDIT_DIR, exist_ok=True)
path = _audit_path(wid)
if os.path.exists(path) and os.path.getsize(path) > SOFT_CAP_BYTES:
# Keep the tail half. Cheap, lossy, prevents pathological
# disk growth without crashing on a corrupt file.
with open(path, "rb") as f:
f.seek(-(SOFT_CAP_BYTES // 2), os.SEEK_END)
tail = f.read()
first_nl = tail.find(b"\n")
tail = tail[first_nl + 1:] if first_nl >= 0 else b""
with open(path, "wb") as f:
f.write(tail)
with open(path, "a") as f:
f.write(json.dumps(entry) + "\n")
except Exception:
logger.debug("audit log_change failed", exc_info=True)
def read_tail(wid: str, limit: int = 50) -> list[dict]:
path = _audit_path(wid)
if not os.path.exists(path):
return []
try:
with open(path) as f:
lines = f.readlines()
except Exception:
return []
out: list[dict] = []
for line in lines[-limit:]:
line = line.strip()
if not line:
continue
try:
out.append(json.loads(line))
except Exception:
continue
out.reverse()
return out
+90
View File
@@ -0,0 +1,90 @@
"""Server-side escalation timer.
The permission chain in the UI (notify -> text -> call) used to time out
client-side, which dies the moment the window closes. We move the timer
here so a run that finishes at 9am can escalate to a real text at 9:05am
whether or not the user has the app open. The text/call wire-up itself
still routes through notifier (cloud SMS bridge is wired separately); we
just own the *when*.
State lives in module-scoped dicts, not on disk. If the backend restarts
mid-escalation the chain is lost on purpose: the user is already in front
of an open app at that point (otherwise the backend wouldn't have started)
and they can ack manually. Persisting escalation state would mean
re-firing on a stale schedule after a multi-day downtime, which is worse.
"""
import asyncio
import logging
from datetime import datetime, timedelta, timezone
from typing import Optional
from backend.apps.workflows.models import PermissionTier, Workflow, WorkflowRun
logger = logging.getLogger(__name__)
_tasks: dict[str, asyncio.Task] = {} # run_id -> escalation task
_state: dict[str, dict] = {} # run_id -> {tier_idx, next_at, kind}
def _tier_delay_seconds(tier: PermissionTier) -> int:
"""Tier minutes/hours convention matches the FE: text uses minutes,
call uses hours (the UI label flips with tier.kind). We translate at
the boundary so the backend math is always in seconds."""
if tier.kind == "call":
return max(0, tier.after_minutes) * 3600
return max(0, tier.after_minutes) * 60
def schedule(wf: Workflow, run: WorkflowRun) -> None:
"""Kick off escalation for a finished run. No-op if the workflow has
only the default notify tier (i.e. nothing to escalate to)."""
tiers = wf.permissions or []
if len(tiers) <= 1:
return
# Cancel any prior task for this run (defense against a re-fire).
cancel(run.id)
task = asyncio.create_task(_runner(wf, run, tiers))
_tasks[run.id] = task
def cancel(run_id: str) -> bool:
task = _tasks.pop(run_id, None)
_state.pop(run_id, None)
if task is None:
return False
task.cancel()
return True
def status(run_id: str) -> Optional[dict]:
return _state.get(run_id)
async def _runner(wf: Workflow, run: WorkflowRun, tiers: list[PermissionTier]) -> None:
from backend.apps.workflows.notifier import send_tier
try:
# Tier 0 is the initial notify; we don't re-fire it here. Walk
# 1..N, sleeping the tier's delay before sending. If the user acks
# via /workflows/runs/{run_id}/ack, the task is cancelled.
for idx in range(1, len(tiers)):
tier = tiers[idx]
delay = _tier_delay_seconds(tier)
fire_at = datetime.now(timezone.utc) + timedelta(seconds=delay)
_state[run.id] = {
"tier_idx": idx,
"tier_kind": tier.kind,
"next_at": fire_at.isoformat(),
}
await asyncio.sleep(delay)
try:
await send_tier(wf, run, tier)
except Exception:
logger.exception("escalation send_tier failed run=%s tier=%s", run.id, tier.kind)
except asyncio.CancelledError:
pass
finally:
_state.pop(run.id, None)
_tasks.pop(run.id, None)
+47 -2
View File
@@ -8,7 +8,7 @@ routing, retries, and history all aligned with the rest of the app.
import asyncio
import logging
from datetime import datetime
from datetime import datetime, timedelta, timezone
from typing import Optional
from backend.apps.agents.models import AgentConfig
@@ -37,6 +37,28 @@ def _resolve_allowed_tools(wf: Workflow) -> list[str]:
return list(wf.actions.configured_sets)
def _monthly_spend_so_far(wf: Workflow) -> float:
"""Sum cost_usd across runs of `wf` started in the last 30 days.
Reads the bounded run log (200 rows max per workflow), so this is
O(history) and runs once per fire. Naive datetimes (legacy rows) are
treated as host-local then normalized to UTC by Python's astimezone.
"""
cutoff = datetime.now(timezone.utc) - timedelta(days=30)
total = 0.0
for r in storage.list_runs(wf.id, limit=200):
started = r.started_at
if started is None:
continue
if started.tzinfo is None:
started = started.astimezone(timezone.utc)
else:
started = started.astimezone(timezone.utc)
if started >= cutoff:
total += float(r.cost_usd or 0.0)
return total
async def execute(wf: Workflow, triggered_by: str = "schedule", scheduled_for: Optional[datetime] = None) -> WorkflowRun:
from backend.apps.agents.agent_manager import agent_manager
@@ -47,6 +69,23 @@ async def execute(wf: Workflow, triggered_by: str = "schedule", scheduled_for: O
started_at=datetime.now(),
triggered_by=triggered_by,
)
# Cost cap pre-check happens before claiming `_running` so a capped
# workflow doesn't block its own next fire. We still record the run so
# the user sees it in History with a clear reason.
if wf.cost_cap_usd_monthly is not None:
spent = _monthly_spend_so_far(wf)
if spent >= wf.cost_cap_usd_monthly:
run.status = "skipped"
run.error = f"Monthly cost cap reached (${spent:.2f} / ${wf.cost_cap_usd_monthly:.2f})"
run.finished_at = datetime.now()
storage.record_run(run)
wf.last_run_at = run.finished_at
wf.last_run_status = "skipped"
wf.last_run_id = run.id
storage.save_workflow(wf)
return run
storage.record_run(run)
async with _running_lock:
@@ -106,15 +145,21 @@ async def execute(wf: Workflow, triggered_by: str = "schedule", scheduled_for: O
run.status = "failure"
run.error = step_error
wf.last_run_status = "failure"
elif scheduled_for is not None and (run.finished_at - scheduled_for).total_seconds() > 300:
elif scheduled_for is not None and (run.finished_at.replace(tzinfo=None) - scheduled_for.replace(tzinfo=None)).total_seconds() > 300:
# Started more than 5 minutes after its slot (app was closed,
# event loop backed up, etc.). Surface in History as ran_late
# so the user can tell apart "fired on time" from "caught up".
# Strip tz before the subtraction so a UTC-aware scheduled_for
# (new code path) and a naive finished_at don't raise.
run.status = "ran_late"
wf.last_run_status = "ran_late"
else:
run.status = "success"
wf.last_run_status = "success"
# Bump runs_count for scheduled fires that reached a terminal state
# other than "skipped". Manual runs don't count against max_runs.
if triggered_by == "schedule" and run.status in ("success", "ran_late", "failure"):
wf.schedule.runs_count += 1
storage.record_run(run)
wf.last_run_at = run.finished_at
storage.save_workflow(wf)
+14 -1
View File
@@ -20,8 +20,18 @@ class ScheduleConfig(BaseModel):
on_days: list[int] = Field(default_factory=list)
hour: int = 9
minute: int = 0
# IANA zone name (e.g. "America/Los_Angeles") or "local" for legacy
# records that predate explicit tz. storage._load_all_from_disk coerces
# "local" to the host zone in memory; we leave it on disk until the
# user's next save so backup/sync tools don't see spurious churn.
timezone: str = "local"
on_missed: Literal["skip", "run_once", "run_all"] = "skip"
# Optional end conditions. None = forever / unbounded. Schedule auto-
# disables once either is satisfied; scheduler._tick zeroes out
# next_run_at and flips enabled=False so the UI reflects reality.
ends_at: Optional[datetime] = None
max_runs: Optional[int] = None
runs_count: int = 0
class ActionsConfig(BaseModel):
@@ -64,9 +74,10 @@ class Workflow(BaseModel):
created_at: datetime = Field(default_factory=datetime.now)
updated_at: datetime = Field(default_factory=datetime.now)
last_run_at: Optional[datetime] = None
last_run_status: Optional[Literal["success", "failure", "ran_late", "running"]] = None
last_run_status: Optional[Literal["success", "failure", "ran_late", "running", "skipped"]] = None
last_run_id: Optional[str] = None
next_run_at: Optional[datetime] = None
cost_cap_usd_monthly: Optional[float] = None
class WorkflowRun(BaseModel):
@@ -97,6 +108,7 @@ class WorkflowCreate(BaseModel):
model: Optional[str] = None
mode: Optional[str] = None
provider: Optional[str] = None
cost_cap_usd_monthly: Optional[float] = None
class WorkflowUpdate(BaseModel):
@@ -112,3 +124,4 @@ class WorkflowUpdate(BaseModel):
model: Optional[str] = None
mode: Optional[str] = None
provider: Optional[str] = None
cost_cap_usd_monthly: Optional[float] = None
+33 -21
View File
@@ -1,27 +1,22 @@
"""Permission/escalation chain notifier.
Today we only emit the in-app notify tier (via ws broadcast). The text/call
tiers are wired into the schema and exposed in the UI so the permission
chain is editable today; the actual SMS/voice integration ships with the
cloud-side affiliate billing infra and is intentionally stubbed here.
The notify tier broadcasts a ws event the renderer picks up. The text/call
tiers route through the cloud SMS bridge once enabled; until it's enabled
we fall back to an extra ws notify with a `fallback: true` marker so the
renderer can label it honestly ("Text-me fallback: cloud SMS not wired").
The *when* of escalation is owned by apps/workflows/escalation.py.
"""
import asyncio
import logging
from datetime import datetime
from backend.apps.workflows.models import Workflow, WorkflowRun
from backend.apps.workflows.models import PermissionTier, Workflow, WorkflowRun
logger = logging.getLogger(__name__)
async def notify_run_complete(wf: Workflow, run: WorkflowRun) -> None:
from backend.apps.agents.ws_manager import ws_manager
primary = (wf.permissions or [None])[0]
kind = getattr(primary, "kind", "notify") if primary else "notify"
payload = {
def _base_payload(wf: Workflow, run: WorkflowRun) -> dict:
return {
"workflow_id": wf.id,
"workflow_title": wf.title,
"run_id": run.id,
@@ -31,13 +26,30 @@ async def notify_run_complete(wf: Workflow, run: WorkflowRun) -> None:
"finished_at": run.finished_at.isoformat() if isinstance(run.finished_at, datetime) else run.finished_at,
}
if kind == "notify":
await ws_manager.broadcast_global("workflow:notify", payload)
return
# text/call tiers stubbed; emit the same notify event so the UI still
# surfaces completion. Escalation timing is enforced client-side until
# the cloud-side bridge ships.
async def notify_run_complete(wf: Workflow, run: WorkflowRun) -> None:
from backend.apps.agents.ws_manager import ws_manager
from backend.apps.workflows import escalation
payload = _base_payload(wf, run)
await ws_manager.broadcast_global("workflow:notify", payload)
logger.info("workflow:notify (escalation tier=%s stubbed): %s", kind, wf.id)
await asyncio.sleep(0)
# Kick off server-side escalation only if there are additional tiers
# beyond the default notify. The escalation runner will sleep + call
# send_tier per tier.
escalation.schedule(wf, run)
async def send_tier(wf: Workflow, run: WorkflowRun, tier: PermissionTier) -> None:
"""Send a single escalation tier. Today the text/call paths fall back
to an in-app notify with `fallback: true` and the tier kind set so the
renderer can show "Text-me fallback (cloud SMS not wired)."
"""
from backend.apps.agents.ws_manager import ws_manager
payload = _base_payload(wf, run)
payload["tier_kind"] = tier.kind
payload["tier_phone"] = (tier.phone or "")[-4:] if tier.phone else None
payload["fallback"] = True # flip to False once the cloud SMS bridge is wired
await ws_manager.broadcast_global("workflow:notify", payload)
logger.info("workflow tier=%s fallback fired wf=%s run=%s", tier.kind, wf.id, run.id)
+197 -49
View File
@@ -11,14 +11,21 @@ Schedule semantics:
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
Local clock only. We avoid timezone math here; users see all calendars in
their machine local time, which matches the in-app calendar in the images.
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
Monday" schedule must remain 9am local across spring-forward / fall-back).
Legacy records with timezone="local" are coerced to the host zone in
memory by storage._load_all_from_disk; the on-disk file is not rewritten
until the user's next save.
"""
import asyncio
import calendar
import logging
from datetime import datetime, timedelta
import os
from datetime import datetime, timedelta, timezone
from typing import Optional
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
from backend.apps.workflows.models import Workflow, ScheduleConfig
from backend.apps.workflows import storage, executor
@@ -28,74 +35,179 @@ logger = logging.getLogger(__name__)
_loop_task: Optional[asyncio.Task] = None
_wake = asyncio.Event()
_host_tz_cache: Optional[ZoneInfo] = None
def _next_fire_after(sched: ScheduleConfig, ref: datetime) -> Optional[datetime]:
def _host_tz() -> ZoneInfo:
global _host_tz_cache
if _host_tz_cache is not None:
return _host_tz_cache
name = os.environ.get("OPENSWARM_TIMEZONE", "").strip()
if not name:
try:
from tzlocal import get_localzone_name # type: ignore
name = get_localzone_name() or ""
except Exception:
name = ""
try:
_host_tz_cache = ZoneInfo(name) if name else ZoneInfo("UTC")
except ZoneInfoNotFoundError:
_host_tz_cache = ZoneInfo("UTC")
return _host_tz_cache
def _resolve_tz(tz: str) -> ZoneInfo:
if not tz or tz == "local":
return _host_tz()
try:
return ZoneInfo(tz)
except ZoneInfoNotFoundError:
return _host_tz()
def _as_utc(dt: Optional[datetime]) -> Optional[datetime]:
"""Normalize an arbitrary stored datetime to aware-UTC.
Pydantic deserializes naive ISO strings as naive datetimes. Treat such
values as host-local (matches the pre-tz codepath that wrote them) so
comparisons against datetime.now(timezone.utc) don't raise.
"""
if dt is None:
return None
if dt.tzinfo is None:
return dt.replace(tzinfo=_host_tz()).astimezone(timezone.utc)
return dt.astimezone(timezone.utc)
def _add_months(dt: datetime, months: int) -> datetime:
"""Add months preserving day-of-month, clamping only if the target month
is shorter (e.g. Jan 31 + 1mo → Feb 28/29). Wall-clock arithmetic; the
caller is responsible for tz attachment.
"""
total = dt.month - 1 + months
year = dt.year + total // 12
month = total % 12 + 1
day = min(dt.day, calendar.monthrange(year, month)[1])
return dt.replace(year=year, month=month, day=day)
def _js_weekday(d: datetime) -> int:
"""Frontend uses JS getDay() convention (Sun=0..Sat=6). Python's
datetime.weekday() is Mon=0..Sun=6. Wire format stays JS-style so the
on_days array round-trips between FE and BE without translation in two
places."""
return (d.weekday() + 1) % 7
def _next_fire_after(sched: ScheduleConfig, ref_utc: datetime) -> Optional[datetime]:
if not sched.enabled:
return None
base = ref.replace(second=0, microsecond=0)
tz = _resolve_tz(sched.timezone)
ref_local = ref_utc.astimezone(tz)
base = ref_local.replace(second=0, microsecond=0)
candidate = base.replace(hour=sched.hour, minute=sched.minute)
if candidate <= ref:
if candidate <= ref_local:
candidate = candidate + timedelta(days=1)
if sched.repeat_unit == "day":
step = max(1, sched.repeat_every)
# Walk forward in step-day increments until we find a day strictly
# after `ref`. Cheap because step is small.
while candidate <= ref:
# Walk forward in step-day increments until we find a slot strictly
# after `ref_local`. Cheap because step is small.
while candidate <= ref_local:
candidate = candidate + timedelta(days=step)
return candidate
return candidate.astimezone(timezone.utc)
if sched.repeat_unit == "week":
# Frontend uses JS getDay() convention (Sun=0..Sat=6). Python's
# datetime.weekday() is Mon=0..Sun=6, so we translate before
# matching. Keep the wire format JS-style so the UI math stays
# trivial and the cron picker stays self-explanatory.
def _js_weekday(d: datetime) -> int:
return (d.weekday() + 1) % 7
allowed = sched.on_days or [_js_weekday(ref)]
allowed = sched.on_days or [_js_weekday(ref_local)]
for _ in range(0, 14):
if _js_weekday(candidate) in allowed and candidate > ref:
return candidate
if _js_weekday(candidate) in allowed and candidate > ref_local:
return candidate.astimezone(timezone.utc)
candidate = candidate + timedelta(days=1)
return candidate
return candidate.astimezone(timezone.utc)
if sched.repeat_unit == "month":
target_day = ref.day
target_day = ref_local.day
step = max(1, sched.repeat_every)
# Walk month-by-month preserving the original day-of-month when it
# exists (Feb 30 falls back to the month's last day).
c = candidate.replace(day=min(target_day, 28))
while c <= ref:
month = c.month + step
year = c.year + (month - 1) // 12
month = ((month - 1) % 12) + 1
c = c.replace(year=year, month=month)
return c
c = candidate.replace(day=min(target_day, calendar.monthrange(candidate.year, candidate.month)[1]))
while c <= ref_local:
c = _add_months(c, step)
return c.astimezone(timezone.utc)
return None
def compute_next_fire(wf: Workflow, ref: Optional[datetime] = None) -> Optional[datetime]:
return _next_fire_after(wf.schedule, ref or datetime.now())
ref_utc = _as_utc(ref) if ref is not None else datetime.now(timezone.utc)
return _next_fire_after(wf.schedule, ref_utc)
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).
"""
sched = wf.schedule
if not sched.enabled:
return 0
if sched.max_runs is not None and sched.runs_count >= sched.max_runs:
return 0
cursor_utc = datetime.now(timezone.utc)
end_utc = cursor_utc + timedelta(days=days)
ends_at_utc = _as_utc(sched.ends_at)
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
)
count = 0
while count < min(1000, remaining_budget):
nxt = _next_fire_after(sched, cursor_utc)
if nxt is None or nxt > end_utc:
break
count += 1
cursor_utc = nxt
return count
def kick() -> None:
_wake.set()
def _end_condition_hit(wf: Workflow, now_utc: datetime) -> bool:
s = wf.schedule
ends_at = _as_utc(s.ends_at)
if ends_at is not None and now_utc >= ends_at:
return True
if s.max_runs is not None and s.runs_count >= s.max_runs:
return True
return False
def _disable_schedule(wf: Workflow) -> None:
wf.schedule.enabled = False
wf.next_run_at = None
storage.save_workflow(wf)
async def _tick() -> None:
now = datetime.now()
now_utc = datetime.now(timezone.utc)
if storage.get_paused():
return
due: list[Workflow] = []
for wf in storage.list_workflows():
if not wf.schedule.enabled:
continue
if wf.next_run_at and wf.next_run_at <= now:
if _end_condition_hit(wf, now_utc):
_disable_schedule(wf)
continue
nra = _as_utc(wf.next_run_at)
if nra and nra <= now_utc:
due.append(wf)
for wf in due:
scheduled_for = wf.next_run_at
nxt = compute_next_fire(wf, now)
scheduled_for = _as_utc(wf.next_run_at)
nxt = _next_fire_after(wf.schedule, now_utc)
wf.next_run_at = nxt
storage.save_workflow(wf)
asyncio.create_task(_fire(wf, scheduled_for=scheduled_for))
@@ -109,16 +221,19 @@ async def _fire(wf: Workflow, scheduled_for: Optional[datetime]) -> None:
def _seconds_until_next() -> float:
now = datetime.now()
now_utc = datetime.now(timezone.utc)
soonest: Optional[datetime] = None
for wf in storage.list_workflows():
if not wf.schedule.enabled or not wf.next_run_at:
if not wf.schedule.enabled:
continue
if soonest is None or wf.next_run_at < soonest:
soonest = wf.next_run_at
nra = _as_utc(wf.next_run_at)
if nra is None:
continue
if soonest is None or nra < soonest:
soonest = nra
if soonest is None:
return 60.0
delta = (soonest - now).total_seconds()
delta = (soonest - now_utc).total_seconds()
return max(1.0, min(delta, 60.0))
@@ -159,23 +274,28 @@ def reconcile_on_startup() -> None:
run_all -> not actually run_all in v1 (would burn tokens); same as run_once
but we mark the run.status as ran_late so the UI surfaces it
"""
now = datetime.now()
now_utc = datetime.now(timezone.utc)
for wf in storage.list_workflows():
if not wf.schedule.enabled:
wf.next_run_at = None
storage.save_workflow(wf)
continue
missed = bool(wf.next_run_at and wf.next_run_at <= now)
if _end_condition_hit(wf, now_utc):
_disable_schedule(wf)
continue
nra = _as_utc(wf.next_run_at)
missed = bool(nra and nra <= now_utc)
if missed and wf.schedule.on_missed in ("run_once", "run_all"):
# Leave next_run_at <= now so the very next tick fires it. The
# executor records the run with started_at=now; the UI badges
# it ran_late if scheduled_for is more than a few minutes
# behind started_at.
pass
# Keep next_run_at <= now_utc so the very next tick fires it.
# Normalize to a UTC-aware value so future comparisons don't
# trip on naive legacy datetimes.
wf.next_run_at = nra
storage.save_workflow(wf)
else:
wf.next_run_at = compute_next_fire(wf, now)
storage.save_workflow(wf)
wf.next_run_at = _next_fire_after(wf.schedule, now_utc)
storage.save_workflow(wf)
async def start() -> None:
@@ -197,3 +317,31 @@ async def stop() -> None:
except (asyncio.CancelledError, Exception):
pass
_loop_task = None
def list_active() -> list[dict]:
"""Snapshot of currently-running workflow runs.
Reads executor._running (workflow_id -> run_id) and joins against the
workflow cache for titles. Used by GET /workflows/active so the tray
and the auto-updater veto can both ask "are any runs in flight?"
without holding the executor lock.
"""
out: list[dict] = []
snapshot = dict(executor._running)
for wid, run_id in snapshot.items():
wf = storage.get_workflow(wid)
title = wf.title if wf else ""
started_at = None
if wf:
for r in storage.list_runs(wid, limit=10):
if r.id == run_id:
started_at = r.started_at.isoformat() if isinstance(r.started_at, datetime) else r.started_at
break
out.append({
"workflow_id": wid,
"run_id": run_id,
"title": title,
"started_at": started_at,
})
return out
+46 -2
View File
@@ -19,11 +19,25 @@ from backend.apps.workflows.models import Workflow, WorkflowRun
DATA_DIR = os.path.join(DATA_ROOT, "workflows")
RUNS_DIR = os.path.join(DATA_DIR, "runs")
PAUSED_FILE = os.path.join(DATA_DIR, "paused.json")
_io_lock = Lock()
_workflow_cache: dict[str, Workflow] = {}
_runs_cache: dict[str, list[WorkflowRun]] = {}
_cache_loaded = False
_paused = False
def _resolve_host_tz_name() -> str:
"""Best-effort IANA name for the host. Mirrors apps/service/client.py."""
name = os.environ.get("OPENSWARM_TIMEZONE", "").strip()
if not name:
try:
from tzlocal import get_localzone_name # type: ignore
name = get_localzone_name() or ""
except Exception:
name = ""
return name or "UTC"
# Keep this much run history per workflow on disk. Older runs are pruned;
# the History tab caps at ~20 anyway, and unbounded growth turned the JSON
@@ -45,16 +59,23 @@ def _runs_path(wid: str) -> str:
def _load_all_from_disk() -> None:
global _cache_loaded
global _cache_loaded, _paused
_ensure_dirs()
_workflow_cache.clear()
_runs_cache.clear()
host_tz = _resolve_host_tz_name()
for fname in os.listdir(DATA_DIR):
if not fname.endswith(".json"):
if not fname.endswith(".json") or fname == "paused.json":
continue
try:
with open(os.path.join(DATA_DIR, fname)) as f:
wf = Workflow(**json.load(f))
# Coerce legacy timezone="local" to the host IANA zone in
# memory only. We don't rewrite the file here so backup/sync
# tooling doesn't see mtime churn on every startup; the next
# user-driven save migrates the on-disk record naturally.
if wf.schedule.timezone == "local":
wf.schedule.timezone = host_tz
_workflow_cache[wf.id] = wf
except Exception:
continue
@@ -69,6 +90,13 @@ def _load_all_from_disk() -> None:
_runs_cache[wid] = [WorkflowRun(**r) for r in arr]
except Exception:
_runs_cache[wid] = []
# Load the global pause flag if it's been set previously.
if os.path.exists(PAUSED_FILE):
try:
with open(PAUSED_FILE) as f:
_paused = bool(json.load(f).get("paused", False))
except Exception:
_paused = False
_cache_loaded = True
@@ -138,6 +166,22 @@ def record_run(run: WorkflowRun) -> WorkflowRun:
return run
def get_paused() -> bool:
if not _cache_loaded:
init()
return _paused
def set_paused(value: bool) -> bool:
global _paused
with _io_lock:
_ensure_dirs()
_paused = bool(value)
with open(PAUSED_FILE, "w") as f:
json.dump({"paused": _paused}, f)
return _paused
def update_run(run_id: str, **fields) -> Optional[WorkflowRun]:
if not _cache_loaded:
init()
+99 -6
View File
@@ -13,7 +13,7 @@ from backend.apps.workflows.models import (
WorkflowUpdate,
WorkflowRun,
)
from backend.apps.workflows import storage, scheduler, executor
from backend.apps.workflows import storage, scheduler, executor, audit, escalation
logger = logging.getLogger(__name__)
@@ -53,11 +53,22 @@ async def list_workflows(dashboard_id: Optional[str] = None):
if dashboard_id:
items = [w for w in items if not w.dashboard_id or w.dashboard_id == dashboard_id]
items.sort(key=lambda w: w.updated_at or w.created_at, reverse=True)
return {"workflows": [w.model_dump(mode="json") for w in items]}
# Enrich with cost_estimate so calendar tooltips and the WorkflowsHub
# list don't have to round-trip to GET /workflows/{id} per row. Cheap
# because fires_in_window walks at most ~30 fires per workflow.
return {"workflows": [_enriched(w) for w in items]}
@workflows.router.post("/create")
async def create_workflow(body: WorkflowCreate):
actions = body.actions
# Scheduled workflows default to freeze=on for safety. The user can
# flip "Full agent access" in the editor with an explicit confirm.
# Source-session creates inherit the chat's tool choices so we leave
# them alone there (the source session itself already vetted the
# blast radius).
if body.schedule.enabled and not actions.freeze and not body.source_session_id:
actions = actions.model_copy(update={"freeze": True})
wf = Workflow(
title=body.title,
description=body.description,
@@ -65,7 +76,7 @@ async def create_workflow(body: WorkflowCreate):
system_prompt=body.system_prompt,
use_synced_prompt=body.use_synced_prompt,
steps=body.steps,
actions=body.actions,
actions=actions,
schedule=body.schedule,
permissions=body.permissions or [],
source_session_id=body.source_session_id,
@@ -73,6 +84,7 @@ async def create_workflow(body: WorkflowCreate):
model=body.model or "sonnet",
mode=body.mode or "agent",
provider=body.provider or "anthropic",
cost_cap_usd_monthly=body.cost_cap_usd_monthly,
)
if not wf.icon:
wf.icon = _derive_icon(wf)
@@ -80,7 +92,78 @@ async def create_workflow(body: WorkflowCreate):
wf.next_run_at = scheduler.compute_next_fire(wf)
storage.save_workflow(wf)
scheduler.kick()
return wf.model_dump(mode="json")
return _enriched(wf)
def _last_run_cost(wid: str) -> float:
for r in storage.list_runs(wid, limit=10):
if r.status in ("success", "ran_late") and r.cost_usd:
return float(r.cost_usd)
return 0.0
def _enriched(wf: Workflow) -> dict:
"""Serialize a workflow with a cost_estimate block attached.
monthly_usd assumes future fires cost the same as the last successful
fire. Surfaces honestly as "at last run's cost" in the UI so users
understand it's a projection, not a quota.
"""
base = wf.model_dump(mode="json")
last = _last_run_cost(wf.id)
fires = scheduler.fires_in_window(wf, days=30)
base["cost_estimate"] = {
"monthly_usd": round(last * fires, 4),
"last_run_usd": round(last, 4),
"fires_per_month": fires,
}
return base
@workflows.router.get("/active")
async def list_active_runs():
"""Snapshot of currently-running workflow runs. Used by the tray and
the auto-updater veto."""
return {"active": scheduler.list_active()}
@workflows.router.post("/pause-all")
async def pause_all_schedules():
storage.set_paused(True)
scheduler.kick()
return {"paused": True}
@workflows.router.post("/resume-all")
async def resume_all_schedules():
storage.set_paused(False)
scheduler.kick()
return {"paused": False}
@workflows.router.get("/paused")
async def get_paused_state():
return {"paused": storage.get_paused()}
@workflows.router.get("/cloud/sms/status")
async def cloud_sms_status():
"""Probe used by the FE to decide whether to show the 'falls back to
in-app notify' acknowledgement on the text/call tiers. Returns
enabled=False until the cloud SMS bridge ships."""
return {"enabled": False}
@workflows.router.post("/runs/{run_id}/ack")
async def ack_run(run_id: str):
cancelled = escalation.cancel(run_id)
return {"acked": True, "had_pending_escalation": cancelled}
@workflows.router.get("/runs/{run_id}/escalation")
async def get_run_escalation(run_id: str):
state = escalation.status(run_id)
return {"state": state}
@workflows.router.get("/{workflow_id}")
@@ -88,7 +171,15 @@ async def get_workflow(workflow_id: str):
wf = storage.get_workflow(workflow_id)
if not wf:
raise HTTPException(status_code=404, detail="Workflow not found")
return wf.model_dump(mode="json")
return _enriched(wf)
@workflows.router.get("/{workflow_id}/audit")
async def get_workflow_audit(workflow_id: str, limit: int = 50):
wf = storage.get_workflow(workflow_id)
if not wf:
raise HTTPException(status_code=404, detail="Workflow not found")
return {"entries": audit.read_tail(workflow_id, limit=limit)}
@workflows.router.patch("/{workflow_id}")
@@ -96,6 +187,7 @@ async def update_workflow(workflow_id: str, body: WorkflowUpdate):
wf = storage.get_workflow(workflow_id)
if not wf:
raise HTTPException(status_code=404, detail="Workflow not found")
before = wf.model_dump(mode="json")
data = body.model_dump(exclude_unset=True)
for k, v in data.items():
setattr(wf, k, v)
@@ -104,8 +196,9 @@ async def update_workflow(workflow_id: str, body: WorkflowUpdate):
wf.icon = _derive_icon(wf)
wf.next_run_at = scheduler.compute_next_fire(wf) if wf.schedule.enabled else None
storage.save_workflow(wf)
audit.log_change(wf.id, "user", before, wf.model_dump(mode="json"))
scheduler.kick()
return wf.model_dump(mode="json")
return _enriched(wf)
@workflows.router.delete("/{workflow_id}")
+314
View File
@@ -0,0 +1,314 @@
"""Backend semantics tests for the scheduled-tasks fix.
Covers:
- DST-safe wall-clock math (spring forward + fall back) via zoneinfo
- End conditions (ends_at + max_runs) auto-disable the schedule
- Cost cap skips fires with a clear error
- Freeze-default on for new scheduled non-source-session creates
- Audit log captures field diffs
- /workflows/active surfaces in-process running runs
- Legacy timezone="local" coerced in memory at load
- Storage paused flag round-trips
- Month math no longer clamps to day 28
- Server-side escalation kicks tasks (and ack cancels them)
Run:
pip install -r backend/requirements.txt -r backend/requirements-dev.txt
cd backend && python -m pytest tests/test_workflows_semantics.py -v
"""
from __future__ import annotations
import asyncio
import json
import os
import shutil
import tempfile
from datetime import datetime, timedelta, timezone
from zoneinfo import ZoneInfo
import pytest
@pytest.fixture(autouse=True)
def isolated_data_dir(monkeypatch, tmp_path):
"""Point storage at a fresh tmpdir per test so we never touch a real
install's workflows data. Reloads in-process module state so each test
starts with empty caches."""
from backend.apps.workflows import storage as _storage
from backend.apps.workflows import escalation as _escalation
monkeypatch.setattr(_storage, "DATA_DIR", str(tmp_path / "workflows"))
monkeypatch.setattr(_storage, "RUNS_DIR", str(tmp_path / "workflows" / "runs"))
monkeypatch.setattr(_storage, "PAUSED_FILE", str(tmp_path / "workflows" / "paused.json"))
monkeypatch.setattr(_storage, "_workflow_cache", {})
monkeypatch.setattr(_storage, "_runs_cache", {})
monkeypatch.setattr(_storage, "_cache_loaded", False)
monkeypatch.setattr(_storage, "_paused", False)
# Reset escalation registry between tests.
_escalation._tasks.clear()
_escalation._state.clear()
# Also clear audit dir reference; audit.py reads DATA_DIR at import via
# module-level expression, so reach in and override the AUDIT_DIR too.
from backend.apps.workflows import audit as _audit
monkeypatch.setattr(_audit, "AUDIT_DIR", str(tmp_path / "workflows" / "audit"))
yield
def _make_wf(**overrides):
from backend.apps.workflows.models import Workflow, ScheduleConfig, WorkflowStep
base = dict(
title="t",
steps=[WorkflowStep(text="hi")],
schedule=ScheduleConfig(enabled=True, repeat_unit="day", repeat_every=1, hour=9, minute=0, timezone="America/Los_Angeles"),
)
base.update(overrides)
return Workflow(**base)
# --- DST tests ---------------------------------------------------------------
def test_dst_spring_forward_weekly():
"""A 2:30am LA weekly Sunday schedule lands on 3:30am LA on the spring-
forward Sunday (2025-03-09) because the wall clock skips 02:30."""
from backend.apps.workflows.scheduler import _next_fire_after
from backend.apps.workflows.models import ScheduleConfig
tz = ZoneInfo("America/Los_Angeles")
sched = ScheduleConfig(enabled=True, repeat_unit="week", repeat_every=1, on_days=[0], hour=2, minute=30, timezone="America/Los_Angeles")
# Saturday 2025-03-08 23:00 LA, asking "what's the next Sunday 2:30?"
ref_local = datetime(2025, 3, 8, 23, 0, tzinfo=tz)
nxt = _next_fire_after(sched, ref_local.astimezone(timezone.utc))
assert nxt is not None
nxt_local = nxt.astimezone(tz)
# 02:30 wall-clock on the spring-forward day doesn't exist; zoneinfo
# resolves it forward to 03:30. The point is the *date* lands on the
# 9th, not the 8th and not the 16th.
assert nxt_local.date() == datetime(2025, 3, 9).date()
assert nxt_local.hour in (2, 3)
def test_dst_fall_back_no_double_fire():
"""A 9am LA daily schedule should fire exactly once on the fall-back day
(2025-11-02) and the next fire is the 3rd, not the 2nd again."""
from backend.apps.workflows.scheduler import _next_fire_after
from backend.apps.workflows.models import ScheduleConfig
tz = ZoneInfo("America/Los_Angeles")
sched = ScheduleConfig(enabled=True, repeat_unit="day", repeat_every=1, hour=9, minute=0, timezone="America/Los_Angeles")
ref_local = datetime(2025, 11, 1, 23, 0, tzinfo=tz)
nxt = _next_fire_after(sched, ref_local.astimezone(timezone.utc))
assert nxt.astimezone(tz).date() == datetime(2025, 11, 2).date()
# After firing on the 2nd, the next fire should be the 3rd, not a
# second 2nd from the duplicated hour.
after = _next_fire_after(sched, nxt)
assert after.astimezone(tz).date() == datetime(2025, 11, 3).date()
# --- End condition tests -----------------------------------------------------
def test_max_runs_disables_schedule():
from backend.apps.workflows import storage, scheduler
wf = _make_wf()
wf.schedule.max_runs = 2
wf.schedule.runs_count = 2
wf.next_run_at = datetime.now(timezone.utc) - timedelta(minutes=1)
storage.save_workflow(wf)
asyncio.new_event_loop().run_until_complete(scheduler._tick())
after = storage.get_workflow(wf.id)
assert after.schedule.enabled is False
assert after.next_run_at is None
def test_ends_at_disables_schedule():
from backend.apps.workflows import storage, scheduler
wf = _make_wf()
wf.schedule.ends_at = datetime.now(timezone.utc) - timedelta(days=1)
wf.next_run_at = datetime.now(timezone.utc) - timedelta(minutes=1)
storage.save_workflow(wf)
asyncio.new_event_loop().run_until_complete(scheduler._tick())
after = storage.get_workflow(wf.id)
assert after.schedule.enabled is False
# --- Month-day-31 (formerly clamped to 28) -----------------------------------
def test_month_repeat_no_longer_clamps_to_28():
"""An every-month schedule starting on March 31 should next fire on
April 30 (last day of April), then May 31, then June 30."""
from backend.apps.workflows.scheduler import _next_fire_after
from backend.apps.workflows.models import ScheduleConfig
tz = ZoneInfo("America/Los_Angeles")
sched = ScheduleConfig(enabled=True, repeat_unit="month", repeat_every=1, hour=9, minute=0, timezone="America/Los_Angeles")
ref_local = datetime(2025, 3, 31, 10, 0, tzinfo=tz) # past 9am on the 31st
nxt = _next_fire_after(sched, ref_local.astimezone(timezone.utc))
assert nxt.astimezone(tz).date() == datetime(2025, 4, 30).date()
# --- Cost cap ----------------------------------------------------------------
def test_cost_cap_skips_with_clear_error(monkeypatch):
from backend.apps.workflows import storage, executor
from backend.apps.workflows.models import WorkflowRun
wf = _make_wf()
wf.cost_cap_usd_monthly = 1.0
storage.save_workflow(wf)
storage.record_run(WorkflowRun(workflow_id=wf.id, status="success", cost_usd=0.6, started_at=datetime.now(timezone.utc), finished_at=datetime.now(timezone.utc)))
storage.record_run(WorkflowRun(workflow_id=wf.id, status="success", cost_usd=0.6, started_at=datetime.now(timezone.utc), finished_at=datetime.now(timezone.utc)))
async def fake_launch(*a, **k):
raise AssertionError("agent_manager should not be reached when cost-capped")
# Patch agent_manager.launch_agent so we'd fail loudly if the cap
# didn't short-circuit before launch.
from backend.apps.agents import agent_manager
monkeypatch.setattr(agent_manager.agent_manager, "launch_agent", fake_launch)
run = asyncio.new_event_loop().run_until_complete(executor.execute(wf, triggered_by="schedule"))
assert run.status == "skipped"
assert "Monthly cost cap reached" in (run.error or "")
# --- Freeze-default for scheduled non-source-session creates ----------------
def test_freeze_defaults_on_for_scheduled_create():
"""POST /workflows/create with schedule.enabled=true and no source
session should flip actions.freeze=True to keep blast radius small."""
from backend.apps.workflows.workflows import create_workflow
from backend.apps.workflows.models import WorkflowCreate, ScheduleConfig, ActionsConfig
body = WorkflowCreate(
title="scheduled",
schedule=ScheduleConfig(enabled=True, repeat_unit="day", repeat_every=1, hour=9, minute=0),
actions=ActionsConfig(freeze=False, configured_sets=[]),
)
result = asyncio.new_event_loop().run_until_complete(create_workflow(body))
assert result["actions"]["freeze"] is True
def test_freeze_not_forced_when_source_session_present():
"""Source-session creates inherit the chat's choices; we don't override."""
from backend.apps.workflows.workflows import create_workflow
from backend.apps.workflows.models import WorkflowCreate, ScheduleConfig, ActionsConfig
body = WorkflowCreate(
title="from chat",
source_session_id="sess-1",
schedule=ScheduleConfig(enabled=True, repeat_unit="day", repeat_every=1, hour=9, minute=0),
actions=ActionsConfig(freeze=False, configured_sets=[]),
)
result = asyncio.new_event_loop().run_until_complete(create_workflow(body))
assert result["actions"]["freeze"] is False
# --- Audit log ---------------------------------------------------------------
def test_audit_log_records_title_change():
from backend.apps.workflows import audit
audit.log_change("wf-1", "user", {"title": "old"}, {"title": "new"})
entries = audit.read_tail("wf-1", limit=10)
assert len(entries) == 1
diff = entries[0]["diff"]
assert diff["title"]["before"] == "old"
assert diff["title"]["after"] == "new"
def test_audit_log_no_op_when_unchanged():
from backend.apps.workflows import audit
audit.log_change("wf-2", "user", {"title": "same"}, {"title": "same"})
assert audit.read_tail("wf-2") == []
# --- /workflows/active -------------------------------------------------------
def test_list_active_reflects_running_map():
from backend.apps.workflows import storage, executor, scheduler
wf = _make_wf(title="active-test")
storage.save_workflow(wf)
from backend.apps.workflows.models import WorkflowRun
run = WorkflowRun(workflow_id=wf.id, status="running")
storage.record_run(run)
executor._running[wf.id] = run.id
try:
active = scheduler.list_active()
assert len(active) == 1
assert active[0]["workflow_id"] == wf.id
assert active[0]["title"] == "active-test"
finally:
executor._running.pop(wf.id, None)
# --- Legacy tz coercion ------------------------------------------------------
def test_legacy_timezone_coerced_on_load(monkeypatch):
from backend.apps.workflows import storage
storage._ensure_dirs()
wf_id = "legacy-wf"
legacy_blob = {
"id": wf_id,
"title": "legacy",
"schedule": {
"enabled": False, "repeat_every": 1, "repeat_unit": "week",
"on_days": [], "hour": 9, "minute": 0, "timezone": "local",
"on_missed": "skip", "ends_at": None, "max_runs": None, "runs_count": 0,
},
}
with open(os.path.join(storage.DATA_DIR, f"{wf_id}.json"), "w") as f:
json.dump(legacy_blob, f)
monkeypatch.setenv("OPENSWARM_TIMEZONE", "America/Los_Angeles")
monkeypatch.setattr(storage, "_cache_loaded", False)
loaded = storage.get_workflow(wf_id)
assert loaded is not None
# In-memory should be the host zone, not "local".
assert loaded.schedule.timezone == "America/Los_Angeles"
# On-disk file should be unchanged (still "local") so we don't churn
# mtime on every restart.
with open(os.path.join(storage.DATA_DIR, f"{wf_id}.json")) as f:
on_disk = json.load(f)
assert on_disk["schedule"]["timezone"] == "local"
# --- Paused flag -------------------------------------------------------------
def test_paused_flag_persists_and_blocks_tick():
from backend.apps.workflows import storage, scheduler
wf = _make_wf()
wf.next_run_at = datetime.now(timezone.utc) - timedelta(minutes=1)
storage.save_workflow(wf)
storage.set_paused(True)
# Reload simulates a backend restart.
storage._cache_loaded = False
assert storage.get_paused() is True
# Tick must not advance next_run_at when paused.
before = storage.get_workflow(wf.id).next_run_at
asyncio.new_event_loop().run_until_complete(scheduler._tick())
after = storage.get_workflow(wf.id).next_run_at
assert before == after
# --- Escalation --------------------------------------------------------------
def test_escalation_schedules_and_ack_cancels():
from backend.apps.workflows import escalation
from backend.apps.workflows.models import Workflow, PermissionTier, WorkflowRun, ScheduleConfig
async def runner():
wf = Workflow(title="t", permissions=[
PermissionTier(kind="notify"),
PermissionTier(kind="text", after_minutes=60, phone="+15551234567"),
])
run = WorkflowRun(workflow_id=wf.id, status="success")
escalation.schedule(wf, run)
# State should be present immediately.
await asyncio.sleep(0.01)
assert escalation.status(run.id) is not None
# Ack cancels.
assert escalation.cancel(run.id) is True
await asyncio.sleep(0.01)
assert escalation.status(run.id) is None
asyncio.new_event_loop().run_until_complete(runner())
def test_escalation_noop_for_single_tier():
from backend.apps.workflows import escalation
from backend.apps.workflows.models import Workflow, PermissionTier, WorkflowRun
wf = Workflow(title="t", permissions=[PermissionTier(kind="notify")])
run = WorkflowRun(workflow_id=wf.id, status="success")
escalation.schedule(wf, run)
assert escalation.status(run.id) is None
Binary file not shown.

After

Width:  |  Height:  |  Size: 119 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 165 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 92 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 115 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 143 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 238 B

+77 -2
View File
@@ -8,6 +8,8 @@ const fs = require('fs');
const getPort = require('get-port');
const http = require('http');
const affiliateTracking = require('./affiliateTracking');
const tray = require('./tray');
const workflowsLifecycle = require('./workflowsLifecycle');
// Prevent duplicate instances. Without this, double-clicking the app icon
// (or macOS auto-launch + manual launch overlapping) spawns two independent
@@ -565,6 +567,21 @@ async function startBackend() {
// any browser on the machine could hit our localhost API and
// impersonate the user. See backend/auth.py.
await loadAuthToken();
// Tray + workflow lifecycle. Tray stays resident so scheduled fires
// survive a window close; workflowsLifecycle polls /workflows/active
// every 5s to drive powerSaveBlocker, updater veto, and tray status.
try {
tray.setup({ backendPort, authToken });
workflowsLifecycle.setBackend({ port: backendPort, token: authToken });
workflowsLifecycle.setActiveChangeListener((active) => {
const title = active.length ? (active[0].title || 'workflow') : null;
tray.setStatus({ activeTitle: title, paused: false });
});
workflowsLifecycle.startPolling();
} catch (e) {
console.warn('[tray] setup failed:', e?.message || e);
}
}
// Per-install auth token read from <data-root>/auth.token (backend
@@ -1194,11 +1211,38 @@ app.on('web-contents-created', (_event, contents) => {
});
app.on('window-all-closed', () => {
// With the tray resident, closing the last window must NOT quit the
// process. Backend keeps running, scheduler keeps firing, and the user
// can quit explicitly from the tray menu. If tray init failed (rare),
// fall back to the legacy quit-on-close behavior so the app doesn't
// become a zombie process.
if (tray.isEnabled()) return;
if (!isDev) killBackend();
app.quit();
});
let drainingForQuit = false;
app.on('before-quit', async (event) => {
// If a scheduled run is in flight, give it up to 30s to finish before
// we kill the backend. Skipping the drain destroys real work the user
// paid LLM cost for. The `drainingForQuit` guard prevents the timer
// from being re-armed on the second event Electron fires.
if (drainingForQuit) return;
try {
const active = await workflowsLifecycle.getActive();
if (active && active.length > 0) {
event.preventDefault();
drainingForQuit = true;
tray.setStatus({ activeTitle: active[0]?.title || 'workflow', paused: false });
await workflowsLifecycle.drainOnQuit(30);
app.quit();
}
} catch (_) {}
});
app.on('will-quit', () => {
workflowsLifecycle.stopPolling();
tray.destroy();
if (!isDev) killBackend();
});
@@ -1253,6 +1297,21 @@ ipcMain.handle('get-webview-preload-path', () => {
ipcMain.handle('get-update-status', () => cachedUpdateStatus);
// Workflow-lifecycle IPCs. The renderer uses these to drive the app-open
// status badge on the schedule editor and the "Fix" affordance that
// turns OpenSwarm into an always-on host with one click.
ipcMain.handle('workflows:get-app-open-info', () => ({
alwaysOn: workflowsLifecycle.getLoginItem() && tray.isEnabled(),
loginAtLaunch: workflowsLifecycle.getLoginItem(),
trayEnabled: tray.isEnabled(),
}));
ipcMain.handle('workflows:set-login-item', (_e, value) => workflowsLifecycle.setLoginItem(Boolean(value)));
ipcMain.handle('workflows:get-active', () => workflowsLifecycle.getActive());
ipcMain.handle('workflows:notify', (_e, payload) => {
workflowsLifecycle.showNativeNotification(payload || {});
return true;
});
ipcMain.handle('check-for-updates', async () => {
if (!autoUpdater || !isPackaged) {
sendToRenderer('update-error', 'Update check is only available in the packaged app.');
@@ -1280,9 +1339,25 @@ ipcMain.handle('download-update', async () => {
}
});
ipcMain.handle('install-update', () => {
if (!autoUpdater) return;
ipcMain.handle('install-update', async () => {
if (!autoUpdater) return { installed: false, queued: false };
// Check active workflows first. If any run is in flight, queue the
// install instead of letting quitAndInstall destroy the agent session.
// workflowsLifecycle's 5s poll fires the deferred install once active
// drains. Controlled by OPENSWARM_UPDATER_VETO so the feature can be
// disabled in case the veto loop misbehaves in the wild.
const vetoEnabled = process.env.OPENSWARM_UPDATER_VETO !== '0';
if (vetoEnabled) {
try {
const vetoed = await workflowsLifecycle.maybeVetoInstall();
if (vetoed) {
sendToRenderer('update-queued', { reason: 'workflow_active' });
return { installed: false, queued: true };
}
} catch (_) {}
}
autoUpdater.quitAndInstall(false, true);
return { installed: true, queued: false };
});
ipcMain.handle('capture-page', async (event, rect) => {
+9
View File
@@ -96,6 +96,15 @@ const { contextBridge, ipcRenderer } = require('electron');
return () => ipcRenderer.removeListener('openswarm:window-focus', listener);
},
// Workflow lifecycle IPCs. ScheduleFacet uses these to render the
// app-open status badge and the one-click "Fix" to enable launch-at-
// login (and the tray, which is enabled by default once setup runs).
getAppOpenInfo: () => ipcRenderer.invoke('workflows:get-app-open-info'),
setLoginItem: (value) => ipcRenderer.invoke('workflows:set-login-item', value),
enableTray: (_value) => Promise.resolve(true),
getActiveRuns: () => ipcRenderer.invoke('workflows:get-active'),
notify: (payload) => ipcRenderer.invoke('workflows:notify', payload),
// OAuth popup callback. Fires when any child webContents navigates to
// localhost:20128/callback?code=... — main.js watches for this and
// forwards the parsed params here. Used as a belt-and-suspenders
+115
View File
@@ -0,0 +1,115 @@
// Menubar tray for OpenSwarm. Keeps the app resident while the user
// closes the main window, so scheduled workflows still fire. Owned by
// main.js; this module exports a single setup() that returns the Tray
// instance plus a status updater.
//
// Icon assets live under electron/assets/tray-{idle,running,paused}.png.
// They are templated on macOS so the menubar respects light/dark mode
// without two separate sets.
const { app, Tray, Menu, nativeImage } = require('electron');
const path = require('path');
const http = require('http');
let trayInstance = null;
let enabled = false;
let backendPortRef = null;
let authTokenRef = null;
function iconPath(state) {
const base = path.join(__dirname, 'assets', `tray-${state}.png`);
// We don't crash on missing icons; nativeImage returns an empty image
// and Electron still renders a fallback. Avoids hard-failing the
// packaged build if assets aren't bundled yet.
return base;
}
function postPause(value) {
return new Promise((resolve) => {
if (!backendPortRef) return resolve(null);
const data = '';
const req = http.request({
hostname: '127.0.0.1',
port: backendPortRef,
path: value ? '/workflows/pause-all' : '/workflows/resume-all',
method: 'POST',
headers: { 'Content-Type': 'application/json', ...(authTokenRef ? { Authorization: `Bearer ${authTokenRef}` } : {}) },
timeout: 1500,
}, (res) => {
let body = '';
res.on('data', (c) => { body += c; });
res.on('end', () => { try { resolve(JSON.parse(body)); } catch { resolve(null); } });
});
req.on('error', () => resolve(null));
req.on('timeout', () => { req.destroy(); resolve(null); });
req.end(data);
});
}
function setStatus({ activeTitle = null, paused = false } = {}) {
if (!trayInstance) return;
const state = paused ? 'paused' : activeTitle ? 'running' : 'idle';
const img = nativeImage.createFromPath(iconPath(state));
if (process.platform === 'darwin' && !img.isEmpty()) img.setTemplateImage(true);
trayInstance.setImage(img);
const tooltip = paused
? 'OpenSwarm: schedules paused'
: activeTitle
? `OpenSwarm: running ${activeTitle}`
: 'OpenSwarm: idle';
trayInstance.setToolTip(tooltip);
rebuildMenu({ activeTitle, paused });
}
function rebuildMenu({ activeTitle, paused }) {
if (!trayInstance) return;
const menu = Menu.buildFromTemplate([
{
label: paused ? 'Schedules paused' : activeTitle ? `Running: ${activeTitle}` : 'Idle',
enabled: false,
},
{ type: 'separator' },
{ label: 'Open OpenSwarm', click: () => {
const { BrowserWindow } = require('electron');
const wins = BrowserWindow.getAllWindows();
if (wins[0]) { wins[0].show(); wins[0].focus(); }
else { app.emit('activate'); }
},
},
{
label: paused ? 'Resume all schedules' : 'Pause all schedules',
click: async () => { await postPause(!paused); setStatus({ activeTitle, paused: !paused }); },
},
{ type: 'separator' },
{ label: 'Quit OpenSwarm', click: () => { app.quit(); } },
]);
trayInstance.setContextMenu(menu);
}
function setup({ backendPort, authToken }) {
backendPortRef = backendPort;
authTokenRef = authToken;
if (trayInstance) return trayInstance;
try {
const img = nativeImage.createFromPath(iconPath('idle'));
if (process.platform === 'darwin' && !img.isEmpty()) img.setTemplateImage(true);
trayInstance = new Tray(img);
trayInstance.setToolTip('OpenSwarm: idle');
enabled = true;
rebuildMenu({ activeTitle: null, paused: false });
} catch (_) {
trayInstance = null;
enabled = false;
}
return trayInstance;
}
function destroy() {
try { if (trayInstance) trayInstance.destroy(); } catch (_) {}
trayInstance = null;
enabled = false;
}
function isEnabled() { return enabled; }
module.exports = { setup, setStatus, destroy, isEnabled };
+172
View File
@@ -0,0 +1,172 @@
// Lifecycle helpers that keep scheduled workflows surviving real-world
// app states (machine sleep, window closed, auto-update). All exports are
// safe to call before the backend is up; failed fetches return null and
// callers degrade to "no active runs known."
const { app, powerSaveBlocker, Notification, shell } = require('electron');
const http = require('http');
let backendPortRef = null;
let authTokenRef = null;
let blockerId = null;
let updaterVetoPending = false;
let pollTimer = null;
let lastActiveCount = 0;
let onActiveChange = () => {};
function setBackend({ port, token }) {
backendPortRef = port;
authTokenRef = token;
}
function setActiveChangeListener(cb) {
onActiveChange = cb || (() => {});
}
// Cheap GET to the localhost backend. Resolves null on any error.
function fetchJson(pathStr) {
return new Promise((resolve) => {
if (!backendPortRef) return resolve(null);
const req = http.request({
hostname: '127.0.0.1',
port: backendPortRef,
path: pathStr,
method: 'GET',
headers: authTokenRef ? { Authorization: `Bearer ${authTokenRef}` } : {},
timeout: 1500,
}, (res) => {
let data = '';
res.on('data', (c) => { data += c; });
res.on('end', () => {
try { resolve(JSON.parse(data)); } catch { resolve(null); }
});
});
req.on('error', () => resolve(null));
req.on('timeout', () => { req.destroy(); resolve(null); });
req.end();
});
}
async function getActive() {
const res = await fetchJson('/workflows/active');
if (!res || !Array.isArray(res.active)) return [];
return res.active;
}
// powerSaveBlocker holds the system awake while at least one workflow is
// active. Released as soon as the active list goes empty so we don't pin
// the user's laptop on idle.
function ensureBlocker(active) {
if (active && blockerId == null) {
try { blockerId = powerSaveBlocker.start('prevent-app-suspension'); } catch (_) {}
} else if (!active && blockerId != null) {
try { powerSaveBlocker.stop(blockerId); } catch (_) {}
blockerId = null;
}
}
function startPolling() {
if (pollTimer) return;
// 5s cadence is the sweet spot: fast enough to release the
// powerSaveBlocker promptly after a fire, slow enough that the localhost
// request is invisible in CPU traces.
pollTimer = setInterval(async () => {
const active = await getActive();
const count = active.length;
ensureBlocker(count > 0);
if (count !== lastActiveCount) {
lastActiveCount = count;
try { onActiveChange(active); } catch (_) {}
}
// If the updater queued an install while a run was in flight, fire it
// the moment the active list drains.
if (updaterVetoPending && count === 0) {
updaterVetoPending = false;
try {
const { autoUpdater } = require('electron-updater');
autoUpdater.quitAndInstall(false, true);
} catch (_) {}
}
}, 5000);
}
function stopPolling() {
if (pollTimer) {
clearInterval(pollTimer);
pollTimer = null;
}
}
// Updater veto: if a workflow is running and the user clicks "Install
// update," queue it instead of quitAndInstall'ing on top of an active
// run. Returns true if vetoed (caller should display a "queued" banner),
// false otherwise.
async function maybeVetoInstall() {
const active = await getActive();
if (active.length === 0) return false;
updaterVetoPending = true;
return true;
}
// Drain on quit: give in-flight runs up to QUIT_DRAIN_S to finish before
// killing the backend. The user-facing tradeoff is a slow quit when busy
// vs. losing the run; we lean toward "wait" because the run already
// committed real cost.
function drainOnQuit(maxSeconds = 30) {
return new Promise((resolve) => {
const deadline = Date.now() + maxSeconds * 1000;
const tick = async () => {
const active = await getActive();
if (active.length === 0 || Date.now() > deadline) return resolve();
setTimeout(tick, 500);
};
tick();
});
}
// Native OS notification. Falls back silently when Notification isn't
// supported (some Linux setups, headless test envs).
function showNativeNotification({ title, body, deepLink }) {
if (!Notification || !Notification.isSupported()) return null;
try {
const n = new Notification({ title: title || 'OpenSwarm', body: body || '', silent: false });
if (deepLink) {
n.on('click', () => {
try { shell.openExternal(deepLink); } catch (_) {}
});
}
n.show();
return n;
} catch (_) {
return null;
}
}
// Launch-at-login wrappers. macOS + Windows both honor this; Linux is a
// no-op in Electron's API.
function getLoginItem() {
try {
const { openAtLogin } = app.getLoginItemSettings();
return Boolean(openAtLogin);
} catch (_) { return false; }
}
function setLoginItem(value) {
try {
app.setLoginItemSettings({ openAtLogin: Boolean(value), openAsHidden: true });
return Boolean(value);
} catch (_) { return false; }
}
module.exports = {
setBackend,
setActiveChangeListener,
startPolling,
stopPolling,
getActive,
maybeVetoInstall,
drainOnQuit,
showNativeNotification,
getLoginItem,
setLoginItem,
};
@@ -16,6 +16,8 @@ import DeleteOutlineIcon from '@mui/icons-material/DeleteOutline';
import CheckIcon from '@mui/icons-material/Check';
import DragIndicatorIcon from '@mui/icons-material/DragIndicator';
import RestartAltIcon from '@mui/icons-material/RestartAlt';
import ScheduleIcon from '@mui/icons-material/Schedule';
import ScheduleThisPopover from '@/app/pages/Workflows/ScheduleThisPopover';
import { useAppDispatch, useAppSelector } from '@/shared/hooks';
import { openSettingsModal } from '@/shared/state/settingsSlice';
import { API_BASE, getAuthToken } from '@/shared/config';
@@ -193,6 +195,7 @@ const AgentChat: React.FC<AgentChatProps> = ({ sessionId: sessionIdProp, onClose
const scrollContainerRef = useRef<HTMLDivElement>(null);
const chatInputRef = useRef<ChatInputHandle>(null);
const isAtBottomRef = useRef(true);
const [scheduleAnchor, setScheduleAnchor] = useState<HTMLElement | null>(null);
const [showScrollButton, setShowScrollButton] = useState(false);
const [showResumeBubble, setShowResumeBubble] = useState(false);
const [awaitingResponse, setAwaitingResponse] = useState(false);
@@ -942,6 +945,16 @@ const AgentChat: React.FC<AgentChatProps> = ({ sessionId: sessionIdProp, onClose
</Box>
)}
</Box>
{!isDraft && id && (
<Tooltip title="Schedule this chat as a recurring workflow">
<IconButton
size="small"
onClick={(e) => setScheduleAnchor(e.currentTarget)}
sx={{ color: c.text.tertiary, '&:hover': { color: c.text.primary } }}>
<ScheduleIcon fontSize="small" />
</IconButton>
</Tooltip>
)}
{!isDraft && id && (
<Tooltip title="Reset history">
<IconButton
@@ -962,6 +975,14 @@ const AgentChat: React.FC<AgentChatProps> = ({ sessionId: sessionIdProp, onClose
</IconButton>
</Tooltip>
)}
{scheduleAnchor && id && (
<ScheduleThisPopover
anchorEl={scheduleAnchor}
onClose={() => setScheduleAnchor(null)}
sessionId={id}
sessionName={session?.name || ''}
/>
)}
{onClose && (
<IconButton onClick={onClose} size="small" sx={{ color: c.text.tertiary, '&:hover': { color: c.text.primary } }}>
<CloseIcon fontSize="small" />
+15 -19
View File
@@ -117,6 +117,8 @@ const DashboardInner: React.FC<DashboardProps> = ({ dashboardId, isActive = true
const viewCards = useAppSelector((state) => state.dashboardLayout.viewCards);
const browserCards = useAppSelector((state) => state.dashboardLayout.browserCards);
const workflowCards = useAppSelector((state) => state.dashboardLayout.workflowCards);
const workflowItems = useAppSelector((state) => state.workflows.items);
const workflowOpenCards = useAppSelector((state) => state.workflows.openCards);
const workflowsHub = useAppSelector((state) => state.dashboardLayout.workflowsHub);
const notes = useAppSelector((state) => state.dashboardLayout.notes);
const pendingFocusNoteId = useAppSelector((state) => state.dashboardLayout.pendingFocusNoteId);
@@ -1802,12 +1804,22 @@ const DashboardInner: React.FC<DashboardProps> = ({ dashboardId, isActive = true
// persistent "Make workflow" arrow back to the agent it was generated
// from. Reuses the same anchor-picking + elbow-path math as the
// browser tether so visual style stays uniform across card kinds.
// Skip layout entries whose destination doesn't render: workflows
// that were deleted (workflows.items entry gone, no draft openCard)
// would otherwise leave a tether dangling to empty space.
const workflowTethers: Array<{ key: string; path: string; labelX: number; labelY: number; label: string; fading: boolean }> = [];
for (const wc of Object.values(workflowCards)) {
const sourceId = wc.source_session_id;
if (!sourceId) continue;
const src = cards[sourceId];
if (!src) continue;
// Defense-in-depth: a layout entry can outlive its workflow if the
// user deletes the workflow from the hub (deleteWorkflow doesn't
// remove workflowCards entries). Skip so the tether doesn't dangle
// to where no card is actually rendered.
const hasReal = wc.workflow_id in workflowItems;
const hasDraft = wc.workflow_id in workflowOpenCards;
if (!hasReal && !hasDraft) continue;
let srcX = src.x, srcY = src.y;
let dstX = wc.x, dstY = wc.y;
@@ -1884,7 +1896,7 @@ const DashboardInner: React.FC<DashboardProps> = ({ dashboardId, isActive = true
return [...agentTethers, ...browserTethers, ...workflowTethers];
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [glowingAgentCards, glowingBrowserCards, cards, browserCards, workflowCards, expandedSessionIds, liveDragInfo, measuredHeightsTick, sessionList]);
}, [glowingAgentCards, glowingBrowserCards, cards, browserCards, workflowCards, workflowItems, workflowOpenCards, expandedSessionIds, liveDragInfo, measuredHeightsTick, sessionList]);
const dotSize = Math.max(1, 1.5 * canvas.zoom);
const dotSpacing = 24 * canvas.zoom;
@@ -2035,10 +2047,6 @@ const DashboardInner: React.FC<DashboardProps> = ({ dashboardId, isActive = true
<path d="M 0 1 L 10 5 L 0 9 z" fill={c.accent.primary} opacity={0.8} />
</marker>
</defs>
<style>{`
@keyframes tether-flow { to { stroke-dashoffset: -16; } }
@keyframes tether-pulse { 0%, 100% { opacity: 0.6; } 50% { opacity: 1; } }
`}</style>
{tethers.map((t) => (
<g
key={t.key}
@@ -2054,7 +2062,7 @@ const DashboardInner: React.FC<DashboardProps> = ({ dashboardId, isActive = true
strokeWidth={8}
strokeLinecap="round"
strokeLinejoin="round"
opacity={0.2}
opacity={0.15}
filter="url(#tether-glow-f)"
/>
<path
@@ -2064,20 +2072,8 @@ const DashboardInner: React.FC<DashboardProps> = ({ dashboardId, isActive = true
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
opacity={0.65}
opacity={0.8}
markerEnd="url(#tether-arrow)"
style={{ animation: 'tether-pulse 2s ease-in-out infinite' }}
/>
<path
d={t.path}
fill="none"
stroke={c.accent.primary}
strokeWidth={1.5}
strokeLinecap="round"
strokeLinejoin="round"
strokeDasharray="8 8"
opacity={0.9}
style={{ animation: 'tether-flow 0.6s linear infinite' }}
/>
{t.label && (
<g transform={`translate(${t.labelX},${t.labelY})`}>
@@ -0,0 +1,91 @@
import React, { useState } from 'react';
import Box from '@mui/material/Box';
import Typography from '@mui/material/Typography';
import Select from '@mui/material/Select';
import MenuItem from '@mui/material/MenuItem';
import Switch from '@mui/material/Switch';
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
import type { Workflow } from '@/shared/state/workflowsSlice';
import { BODY_FS, LABEL_FS, HINT_FS } from './workflowEditCommon';
const BUILT_IN_SETS = ['Core Actions', 'Extended Actions', 'Apps', 'Browser'] as const;
const CUSTOM_SETS = ['Notion', 'Google Workspace', 'YouTube', 'Reddit'] as const;
export default function ActionsFacet({ draft, setDraft }: { draft: Workflow; setDraft: (w: Workflow) => void }) {
const c = useClaudeTokens();
// Configure only appears when freeze is on; "Don't freeze" hides it
// entirely so the surface doesn't lie about what's enabled.
const [configuring, setConfiguring] = useState(false);
const toggleSet = (set: string, on: boolean) => {
const next = on
? Array.from(new Set([...draft.actions.configured_sets, set]))
: draft.actions.configured_sets.filter((s) => s !== set);
setDraft({ ...draft, actions: { ...draft.actions, configured_sets: next } });
};
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.25, color: c.text.secondary }}>
<Typography sx={{ fontSize: BODY_FS, color: c.text.secondary, lineHeight: 1.5 }}>
Do you want to prevent the agent from taking actions that weren&apos;t used in the original workflow?
</Typography>
<Box sx={{ display: 'flex', justifyContent: 'flex-end' }}>
<Select
size="small"
value={draft.actions.prevent_unused ? 'prevent' : 'allow'}
onChange={(e) => setDraft({ ...draft, actions: { ...draft.actions, prevent_unused: e.target.value === 'prevent' } })}
sx={{ fontSize: LABEL_FS, '& .MuiSelect-select': { py: 0.5 } }}>
<MenuItem value="prevent">Prevent all unwanted actions</MenuItem>
<MenuItem value="allow">Allow all actions</MenuItem>
</Select>
</Box>
<Typography sx={{ fontSize: BODY_FS, color: c.text.secondary, lineHeight: 1.5, mt: 0.5 }}>
Do you want to freeze the actions available to the Agent so this flow always works even if you change your settings?
</Typography>
<Box sx={{ display: 'flex', justifyContent: 'flex-end' }}>
<Select
size="small"
value={draft.actions.freeze ? 'freeze' : 'dont'}
onChange={(e) => setDraft({ ...draft, actions: { ...draft.actions, freeze: e.target.value === 'freeze' } })}
sx={{ fontSize: LABEL_FS, '& .MuiSelect-select': { py: 0.5 } }}>
<MenuItem value="freeze">Freeze actions</MenuItem>
<MenuItem value="dont">Don&apos;t freeze</MenuItem>
</Select>
</Box>
{draft.actions.freeze && (
<Box sx={{ display: 'flex', justifyContent: 'flex-end', mt: 0.5 }}>
<Box
onClick={() => setConfiguring((v) => !v)}
role="button"
sx={{ display: 'inline-flex', alignItems: 'center', gap: 0.4, fontSize: LABEL_FS, color: configuring ? c.accent.primary : c.text.secondary, cursor: 'pointer', fontWeight: 500, '&:hover': { color: c.accent.primary } }}>
{configuring ? '⚙ Configuring…' : '⚙ Configure'}
</Box>
</Box>
)}
{draft.actions.freeze && configuring && (
<Box sx={{ mt: 0.5, display: 'flex', flexDirection: 'column', gap: 0.6, border: `1px solid ${c.accent.primary}40`, borderRadius: `${c.radius.lg}px`, p: 1.25 }}>
<Typography sx={{ fontSize: HINT_FS, fontWeight: 700, color: c.text.secondary, letterSpacing: '0.05em', mb: 0.25 }}>BUILT-IN ACTION SETS</Typography>
{BUILT_IN_SETS.map((set) => (
<ActionSetRow key={set} set={set} enabled={draft.actions.configured_sets.includes(set)} onChange={(on) => toggleSet(set, on)} />
))}
<Typography sx={{ fontSize: HINT_FS, fontWeight: 700, color: c.text.secondary, letterSpacing: '0.05em', mt: 0.75, mb: 0.25 }}>CUSTOM ACTION SETS</Typography>
{CUSTOM_SETS.map((set) => (
<ActionSetRow key={set} set={set} enabled={draft.actions.configured_sets.includes(set)} onChange={(on) => toggleSet(set, on)} />
))}
</Box>
)}
</Box>
);
}
function ActionSetRow({ set, enabled, onChange }: { set: string; enabled: boolean; onChange: (v: boolean) => void }) {
const c = useClaudeTokens();
return (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, border: `1px solid ${c.border.subtle}`, borderRadius: `${c.radius.md}px`, px: 1, py: 0.6 }}>
<Typography sx={{ flex: 1, fontSize: BODY_FS, color: c.text.primary, fontWeight: 600 }}>{set}</Typography>
<Switch size="small" checked={enabled} onChange={(e) => onChange(e.target.checked)} />
</Box>
);
}
@@ -0,0 +1,77 @@
import React, { useState } from 'react';
import Box from '@mui/material/Box';
import Typography from '@mui/material/Typography';
import InputBase from '@mui/material/InputBase';
import Select from '@mui/material/Select';
import MenuItem from '@mui/material/MenuItem';
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
import type { Workflow } from '@/shared/state/workflowsSlice';
import { FieldRow, BODY_FS, LABEL_FS, HINT_FS, INPUT_FS } from './workflowEditCommon';
export default function GeneralFacet({ draft, setDraft }: { draft: Workflow; setDraft: (w: Workflow) => void }) {
const c = useClaudeTokens();
const [editingPrompt, setEditingPrompt] = useState(false);
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.25 }}>
<FieldRow label="Title">
<InputBase
value={draft.title}
onChange={(e) => setDraft({ ...draft, title: e.target.value })}
sx={{ flex: 1, fontSize: INPUT_FS, color: c.text.primary, border: `1px solid ${c.border.subtle}`, borderRadius: `${c.radius.md}px`, px: 1, py: 0.5 }}
/>
</FieldRow>
<FieldRow label="Description" align="top">
<InputBase
multiline
minRows={2}
value={draft.description}
onChange={(e) => setDraft({ ...draft, description: e.target.value })}
sx={{ flex: 1, fontSize: INPUT_FS, color: c.text.secondary, lineHeight: 1.5, border: `1px solid ${c.border.subtle}`, borderRadius: `${c.radius.md}px`, px: 1, py: 0.5 }}
/>
</FieldRow>
<FieldRow label="System prompt">
<Box sx={{ flex: 1, display: 'flex', alignItems: 'center', gap: 0.75 }}>
<Box sx={{ fontSize: LABEL_FS, color: c.accent.primary, cursor: 'pointer', fontWeight: 500 }} onClick={() => setEditingPrompt((v) => !v)}>
{editingPrompt ? 'Editing…' : 'Edit'}
</Box>
<Select
size="small"
value={draft.use_synced_prompt ? 'synced' : 'custom'}
onChange={(e) => setDraft({ ...draft, use_synced_prompt: e.target.value === 'synced' })}
sx={{ fontSize: LABEL_FS, '& .MuiSelect-select': { py: 0.5 } }}>
<MenuItem value="synced">Synced to settings</MenuItem>
<MenuItem value="custom">Custom</MenuItem>
</Select>
</Box>
</FieldRow>
{editingPrompt && !draft.use_synced_prompt && (
<InputBase
multiline
minRows={4}
placeholder="Custom system prompt..."
value={draft.system_prompt || ''}
onChange={(e) => setDraft({ ...draft, system_prompt: e.target.value })}
sx={{ fontSize: INPUT_FS, color: c.text.primary, border: `1px solid ${c.border.subtle}`, borderRadius: `${c.radius.md}px`, p: 1, lineHeight: 1.5 }}
/>
)}
<Typography sx={{ fontSize: BODY_FS, fontWeight: 700, color: c.text.primary, mt: 0.5 }}>Workflow</Typography>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
{draft.steps.map((s, idx) => (
<Box key={s.id} sx={{ display: 'flex', alignItems: 'flex-start', gap: 1.25 }}>
<Box sx={{ width: 24, height: 24, borderRadius: '50%', border: `1px solid ${c.border.medium}`, fontSize: HINT_FS, fontWeight: 700, display: 'flex', alignItems: 'center', justifyContent: 'center', color: c.text.secondary, flexShrink: 0, mt: 0.4 }}>{idx + 1}</Box>
<InputBase
multiline
value={s.text}
onChange={(e) => {
const next = [...draft.steps];
next[idx] = { ...s, text: e.target.value };
setDraft({ ...draft, steps: next });
}}
sx={{ flex: 1, fontSize: INPUT_FS, color: c.text.primary, border: `1px solid ${c.border.subtle}`, borderRadius: `${c.radius.md}px`, px: 1.25, py: 0.6, lineHeight: 1.4 }}
/>
</Box>
))}
</Box>
</Box>
);
}
@@ -1,6 +1,8 @@
import React, { useMemo } from 'react';
import React, { useMemo, useState } from 'react';
import Box from '@mui/material/Box';
import Typography from '@mui/material/Typography';
import Tooltip from '@mui/material/Tooltip';
import Popover from '@mui/material/Popover';
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
import { useAppSelector } from '@/shared/hooks';
import type { Workflow } from '@/shared/state/workflowsSlice';
@@ -102,26 +104,7 @@ export default function ScheduleCalendar({ view, density, onSelectWorkflow, refD
const evs = (eventsByDay.map.get(key) || []).filter((e) => e.date.getHours() === hour);
return (
<Box key={`${d.toISOString()}-${hour}`} sx={{ height: SLOT_H, borderLeft: `1px solid ${c.border.subtle}`, borderTop: `1px solid ${c.border.subtle}`, position: 'relative' }}>
{evs.map((e) => (
<Box
key={`${e.workflow.id}-${e.date.toISOString()}`}
onClick={() => onSelectWorkflow?.(e.workflow.id)}
sx={{
position: 'absolute',
left: 3, right: 3, top: 3, bottom: 3,
bgcolor: c.accent.primary + '1f',
color: c.accent.primary,
border: `1px solid ${c.accent.primary}`,
borderRadius: 999,
px: 1.1, py: 0,
fontSize: EVENT_FS, fontWeight: 600,
overflow: 'hidden', whiteSpace: 'nowrap', textOverflow: 'ellipsis',
cursor: 'pointer', display: 'flex', alignItems: 'center',
'&:hover': { bgcolor: c.accent.primary + '33' },
}}>
{e.workflow.title}
</Box>
))}
<EventStack events={evs} onSelectWorkflow={onSelectWorkflow} eventFontSize={EVENT_FS} />
</Box>
);
})}
@@ -196,13 +179,14 @@ export default function ScheduleCalendar({ view, density, onSelectWorkflow, refD
</Box>
<Box sx={{ flex: 1, display: 'flex', flexDirection: 'column', gap: 0.4 }}>
{events.map((e, idx) => (
<Box
key={`${e.workflow.id}-${idx}`}
onClick={() => onSelectWorkflow?.(e.workflow.id)}
sx={{ fontSize: '0.85rem', color: c.text.secondary, cursor: 'pointer', '&:hover': { color: c.accent.primary } }}>
<strong style={{ color: c.text.primary }}>{e.workflow.title}</strong>
<span style={{ color: c.text.muted, marginLeft: 8 }}>{formatTime(e.date.getHours(), e.date.getMinutes())}</span>
</Box>
<Tooltip key={`${e.workflow.id}-${idx}`} title={<EventTooltipBody event={e} />} placement="right" arrow>
<Box
onClick={() => onSelectWorkflow?.(e.workflow.id)}
sx={{ fontSize: '0.85rem', color: c.text.secondary, cursor: 'pointer', '&:hover': { color: c.accent.primary } }}>
<strong style={{ color: c.text.primary }}>{e.workflow.title}</strong>
<span style={{ color: c.text.muted, marginLeft: 8 }}>{formatTime(e.date.getHours(), e.date.getMinutes())}</span>
</Box>
</Tooltip>
))}
</Box>
</Box>
@@ -210,3 +194,99 @@ export default function ScheduleCalendar({ view, density, onSelectWorkflow, refD
</Box>
);
}
// Renders the events for a single calendar cell. Up to one pill is shown
// inline; everything else collapses into a "+N" chip that opens a popover
// with the full list, so the calendar stays readable at high schedule
// density without truncating workflow titles.
function EventStack({ events, onSelectWorkflow, eventFontSize }: {
events: { workflow: Workflow; date: Date }[];
onSelectWorkflow?: (id: string) => void;
eventFontSize: string;
}) {
const c = useClaudeTokens();
const [anchor, setAnchor] = useState<HTMLElement | null>(null);
if (events.length === 0) return null;
const first = events[0];
const rest = events.slice(1);
return (
<>
<Tooltip title={<EventTooltipBody event={first} />} placement="top" arrow>
<Box
onClick={() => onSelectWorkflow?.(first.workflow.id)}
sx={{
position: 'absolute',
left: 3, right: rest.length > 0 ? 28 : 3, top: 3, bottom: 3,
bgcolor: c.accent.primary + '1f',
color: c.accent.primary,
border: `1px solid ${c.accent.primary}`,
borderRadius: 999,
px: 1.1, py: 0,
fontSize: eventFontSize, fontWeight: 600,
overflow: 'hidden', whiteSpace: 'nowrap', textOverflow: 'ellipsis',
cursor: 'pointer', display: 'flex', alignItems: 'center',
'&:hover': { bgcolor: c.accent.primary + '33' },
}}>
{first.workflow.title}
</Box>
</Tooltip>
{rest.length > 0 && (
<Box
onClick={(e) => setAnchor(e.currentTarget)}
role="button"
sx={{
position: 'absolute',
right: 3, top: 3, bottom: 3,
width: 22,
bgcolor: c.accent.primary,
color: '#fff',
borderRadius: 999,
fontSize: eventFontSize, fontWeight: 700,
cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center',
'&:hover': { filter: 'brightness(1.1)' },
}}>
+{rest.length}
</Box>
)}
<Popover
open={Boolean(anchor)}
anchorEl={anchor}
onClose={() => setAnchor(null)}
anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }}
transformOrigin={{ vertical: 'top', horizontal: 'right' }}>
<Box sx={{ minWidth: 220, p: 1 }}>
<Typography sx={{ fontSize: '0.7rem', fontWeight: 700, color: c.text.muted, letterSpacing: '0.06em', mb: 0.5 }}>
{events.length} runs at this hour
</Typography>
{events.map((e, idx) => (
<Box
key={`${e.workflow.id}-${idx}`}
onClick={() => { setAnchor(null); onSelectWorkflow?.(e.workflow.id); }}
sx={{ display: 'flex', alignItems: 'center', gap: 1, px: 0.5, py: 0.5, borderRadius: `${c.radius.md}px`, cursor: 'pointer', '&:hover': { bgcolor: c.bg.elevated } }}>
<Box sx={{ width: 6, height: 6, borderRadius: '50%', bgcolor: c.accent.primary }} />
<Typography sx={{ flex: 1, fontSize: '0.82rem', color: c.text.primary, fontWeight: 600 }}>{e.workflow.title}</Typography>
<Typography sx={{ fontSize: '0.74rem', color: c.text.muted }}>{formatTime(e.date.getHours(), e.date.getMinutes())}</Typography>
</Box>
))}
</Box>
</Popover>
</>
);
}
function EventTooltipBody({ event }: { event: { workflow: Workflow; date: Date } }) {
const wf = event.workflow;
const status = wf.last_run_status;
const cost = wf.cost_estimate?.last_run_usd;
const monthly = wf.cost_estimate?.monthly_usd;
return (
<Box sx={{ fontSize: '0.72rem', lineHeight: 1.5 }}>
<div style={{ fontWeight: 700 }}>{wf.title}</div>
<div>{`Fires at ${formatTime(event.date.getHours(), event.date.getMinutes())}`}</div>
{status && <div>{`Last run: ${status}`}</div>}
{typeof cost === 'number' && cost > 0 && <div>{`Last run cost: $${cost.toFixed(4)}`}</div>}
{typeof monthly === 'number' && monthly > 0 && <div>{`Est. monthly: $${monthly.toFixed(2)}`}</div>}
</Box>
);
}
@@ -0,0 +1,476 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import Box from '@mui/material/Box';
import Typography from '@mui/material/Typography';
import InputBase from '@mui/material/InputBase';
import Select from '@mui/material/Select';
import MenuItem from '@mui/material/MenuItem';
import Switch from '@mui/material/Switch';
import Tooltip from '@mui/material/Tooltip';
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
import { useAppDispatch, useAppSelector } from '@/shared/hooks';
import { fetchCloudSmsStatus, type Workflow, type ScheduleConfig, type PermissionTier } from '@/shared/state/workflowsSlice';
import { WEEKDAY_LABEL, formatTime, fireTimesWithin } from './scheduleUtils';
import { nextTierAfter } from './permissionsUtils';
import { BODY_FS, LABEL_FS, HINT_FS, INPUT_FS } from './workflowEditCommon';
function jsWeekday(d: Date): number { return d.getDay(); }
function lastDayOfMonthFE(year: number, monthZeroBased: number): number {
return new Date(year, monthZeroBased + 1, 0).getDate();
}
// Compute the next fire time from a ScheduleConfig. Mirrors the backend
// math in scheduler.py:_next_fire_after using browser-local time so the
// preview lines up with what the user will actually see on their system
// clock. Honors ends_at + max_runs so the "Next run" line doesn't lie
// after the schedule has expired.
function previewNextRun(sched: ScheduleConfig): Date | null {
if (!sched.enabled) return null;
const now = new Date();
if (sched.ends_at) {
const ends = new Date(sched.ends_at);
if (!Number.isNaN(ends.getTime()) && ends.getTime() <= now.getTime()) return null;
}
if (sched.max_runs != null && sched.runs_count >= sched.max_runs) return null;
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') {
const step = Math.max(1, sched.repeat_every);
while (candidate <= now) candidate = new Date(candidate.getTime() + step * 86400000);
return candidate;
}
if (sched.repeat_unit === 'week') {
const allowed = sched.on_days.length ? sched.on_days : [jsWeekday(now)];
for (let i = 0; i < 14; i += 1) {
if (allowed.includes(jsWeekday(candidate)) && candidate > now) return candidate;
candidate = new Date(candidate.getTime() + 86400000);
}
return candidate;
}
if (sched.repeat_unit === 'month') {
const step = Math.max(1, sched.repeat_every);
const startDay = now.getDate();
let year = now.getFullYear();
let month = now.getMonth();
let guard = 0;
while (guard < 60) {
const day = Math.min(startDay, lastDayOfMonthFE(year, month));
const c = new Date(year, month, day, sched.hour, sched.minute, 0, 0);
if (c > now) return c;
month += step;
year += Math.floor(month / 12);
month = ((month % 12) + 12) % 12;
guard += 1;
}
return null;
}
return null;
}
function formatNextRun(d: Date): string {
const wd = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'][d.getDay()];
const mo = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'][d.getMonth()];
return `${wd} ${mo} ${d.getDate()} at ${formatTime(d.getHours(), d.getMinutes())}`;
}
type EndKind = 'forever' | 'on_date' | 'after_n';
function endKindFromSched(s: ScheduleConfig): EndKind {
if (s.ends_at) return 'on_date';
if (s.max_runs != null) return 'after_n';
return 'forever';
}
interface AppOpenInfo {
alwaysOn: boolean; // tray + login both configured
loginAtLaunch: boolean;
trayEnabled: boolean;
}
function useAppOpenInfo(): { info: AppOpenInfo; fix: () => Promise<void> } {
const [info, setInfo] = useState<AppOpenInfo>({ alwaysOn: false, loginAtLaunch: false, trayEnabled: false });
useEffect(() => {
let alive = true;
const w: any = (window as any).openswarm;
if (!w?.getAppOpenInfo) return;
w.getAppOpenInfo().then((res: AppOpenInfo) => { if (alive) setInfo(res); }).catch(() => {});
return () => { alive = false; };
}, []);
const fix = useCallback(async () => {
const w: any = (window as any).openswarm;
if (!w?.setLoginItem || !w?.enableTray) return;
await w.setLoginItem(true);
await w.enableTray(true);
if (w.getAppOpenInfo) {
const next = await w.getAppOpenInfo();
setInfo(next);
}
}, []);
return { info, fix };
}
export default function ScheduleFacet({ draft, setDraft }: { draft: Workflow; setDraft: (w: Workflow) => void }) {
const c = useClaudeTokens();
const dispatch = useAppDispatch();
const s = draft.schedule;
const cloudSms = useAppSelector((st) => (st as any).workflows?.cloudSmsEnabled);
useEffect(() => { dispatch(fetchCloudSmsStatus()); }, [dispatch]);
// No silent enable-on-edit. The master Switch is now the single source
// of truth for whether this schedule is armed.
const setSched = useCallback((patch: Partial<ScheduleConfig>) => {
setDraft({ ...draft, schedule: { ...s, ...patch } });
}, [draft, s, setDraft]);
const addBackup = useCallback(() => {
const tiers = [...(draft.permissions || [])];
const next = nextTierAfter(tiers);
if (!next) return;
tiers.push(next);
setDraft({ ...draft, permissions: tiers });
}, [draft, setDraft]);
const removeTier = useCallback((idx: number) => {
// Drop the removed tier AND all following tiers so the chain stays
// contiguous (no "call" without "text" before it).
const tiers = (draft.permissions || []).slice(0, idx);
setDraft({ ...draft, permissions: tiers });
}, [draft, setDraft]);
const setTier = useCallback((idx: number, patch: Partial<PermissionTier>) => {
const tiers = [...(draft.permissions || [])];
tiers[idx] = { ...tiers[idx], ...patch };
setDraft({ ...draft, permissions: tiers });
}, [draft, setDraft]);
const canAddBackup = ((draft.permissions || [])[ (draft.permissions || []).length - 1 ]?.kind || 'notify') !== 'call';
const endKind = endKindFromSched(s);
const nextPreview = useMemo(() => previewNextRun(s), [s]);
const { info: appOpen, fix: fixAppOpen } = useAppOpenInfo();
const setEndKind = (k: EndKind) => {
if (k === 'forever') setSched({ ends_at: null, max_runs: null });
else if (k === 'on_date') setSched({ ends_at: new Date(Date.now() + 7 * 86400000).toISOString(), max_runs: null });
else setSched({ ends_at: null, max_runs: 10 });
};
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.25 }}>
{/* Row 1: master On/Off. Explicit so users never wonder if a stray
click armed a schedule. */}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Switch size="small" checked={s.enabled} onChange={(e) => setSched({ enabled: e.target.checked })} />
<Typography sx={{ fontSize: BODY_FS, fontWeight: 700, color: c.text.primary }}>
{s.enabled ? 'Schedule is on' : 'Schedule is off'}
</Typography>
</Box>
{/* Row 2: app-open status badge. Only render when the schedule is
actually on; an "OpenSwarm must be open at 9am" warning is
meaningless when nothing's scheduled. */}
{s.enabled && (
<AppOpenStatusBadge info={appOpen} hour={s.hour} minute={s.minute} onFix={fixAppOpen} />
)}
{/* Row 3: repeat + timezone. */}
<Typography sx={{ fontSize: BODY_FS, fontWeight: 700, color: c.text.primary, mt: 0.5 }}>When should this workflow run?</Typography>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, flexWrap: 'wrap' }}>
<Typography sx={{ fontSize: BODY_FS, color: c.text.secondary }}>Repeat every</Typography>
<InputBase
type="number"
value={s.repeat_every}
onChange={(e) => setSched({ repeat_every: Math.max(1, Number(e.target.value) || 1) })}
sx={{ width: 48, 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'] })}
sx={{ fontSize: LABEL_FS, '& .MuiSelect-select': { py: 0.5 } }}>
<MenuItem value="day">day</MenuItem>
<MenuItem value="week">week</MenuItem>
<MenuItem value="month">month</MenuItem>
</Select>
</Box>
{s.repeat_unit === 'week' && (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, pl: 2, flexWrap: 'wrap' }}>
<Typography sx={{ fontSize: HINT_FS, color: c.text.muted }}> on</Typography>
{WEEKDAY_LABEL.map((label, idx) => {
const active = s.on_days.includes(idx);
return (
<Box
key={idx}
onClick={() => setSched({ on_days: active ? s.on_days.filter((d) => d !== idx) : [...s.on_days, idx] })}
role="button"
sx={{ width: 26, height: 26, borderRadius: '50%', display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: LABEL_FS, fontWeight: 700, cursor: 'pointer', color: active ? '#fff' : c.text.muted, bgcolor: active ? c.accent.primary : 'transparent', border: `1px solid ${active ? c.accent.primary : c.border.subtle}` }}>{label}</Box>
);
})}
</Box>
)}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, pl: 2 }}>
<Typography sx={{ fontSize: HINT_FS, color: c.text.muted }}> at</Typography>
{/* 12-hour picker; backend stores 0..23 but the UI uses 1..12+AM/PM
so users can't accidentally schedule "3" thinking it's 3pm and
get a 3am run. */}
<Select
size="small"
value={((s.hour + 11) % 12) + 1}
onChange={(e) => {
const h12 = Number(e.target.value);
const isPm = s.hour >= 12;
const next = (h12 % 12) + (isPm ? 12 : 0);
setSched({ hour: next });
}}
sx={{ fontSize: LABEL_FS, '& .MuiSelect-select': { py: 0.4 } }}>
{Array.from({ length: 12 }, (_, i) => i + 1).map((h) => (
<MenuItem key={h} value={h}>{h}</MenuItem>
))}
</Select>
<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>
<Select
size="small"
value={s.hour < 12 ? 'AM' : 'PM'}
onChange={(e) => {
const wasPm = s.hour >= 12;
const willBePm = e.target.value === 'PM';
if (wasPm === willBePm) return;
setSched({ hour: willBePm ? s.hour + 12 : s.hour - 12 });
}}
sx={{ fontSize: LABEL_FS, '& .MuiSelect-select': { py: 0.4 } }}>
<MenuItem value="AM">AM</MenuItem>
<MenuItem value="PM">PM</MenuItem>
</Select>
<Typography sx={{ fontSize: HINT_FS, color: c.text.ghost, ml: 1 }}>{s.timezone === 'local' ? 'system tz' : s.timezone}</Typography>
</Box>
{nextPreview && s.enabled && (
<Typography sx={{ fontSize: HINT_FS, color: c.accent.primary, pl: 2, fontWeight: 500 }}>
Next run: {formatNextRun(nextPreview)}
</Typography>
)}
{/* Row 4: end condition. */}
<Typography sx={{ fontSize: BODY_FS, fontWeight: 700, color: c.text.primary, mt: 0.5 }}>For how long?</Typography>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, pl: 2, flexWrap: 'wrap' }}>
<Select
size="small"
value={endKind}
onChange={(e) => setEndKind(e.target.value as EndKind)}
sx={{ fontSize: LABEL_FS, '& .MuiSelect-select': { py: 0.4 } }}>
<MenuItem value="forever">Forever</MenuItem>
<MenuItem value="on_date">Until a date</MenuItem>
<MenuItem value="after_n">After N runs</MenuItem>
</Select>
{endKind === 'on_date' && (
<InputBase
type="date"
value={s.ends_at ? s.ends_at.slice(0, 10) : ''}
onChange={(e) => {
const v = e.target.value;
setSched({ ends_at: v ? new Date(v + 'T23:59:59').toISOString() : null });
}}
sx={{ fontSize: INPUT_FS, border: `1px solid ${c.border.subtle}`, borderRadius: `${c.radius.md}px`, px: 0.75, py: 0.4 }}
/>
)}
{endKind === 'after_n' && (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
<InputBase
type="number"
value={s.max_runs ?? 10}
onChange={(e) => setSched({ max_runs: Math.max(1, Number(e.target.value) || 1) })}
sx={{ width: 56, fontSize: INPUT_FS, border: `1px solid ${c.border.subtle}`, borderRadius: `${c.radius.md}px`, px: 0.75, py: 0.4 }}
/>
<Typography sx={{ fontSize: HINT_FS, color: c.text.muted }}>runs ({s.runs_count} so far)</Typography>
</Box>
)}
</Box>
{/* Row 5: cost. Pass the live draft schedule so the row stays in
sync with the "Next run" preview even before the user saves. */}
<CostRow workflow={draft} draftSched={s} onCapChange={(v) => setDraft({ ...draft, cost_cap_usd_monthly: v })} />
{/* Row 6: action surface (freeze). */}
<Typography sx={{ fontSize: BODY_FS, fontWeight: 700, color: c.text.primary, mt: 0.5 }}>Which actions can the agent use?</Typography>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, pl: 2 }}>
<Select
size="small"
value={draft.actions.freeze ? 'scoped' : 'full'}
onChange={(e) => {
const scoped = e.target.value === 'scoped';
if (!scoped) {
const ok = window.confirm('"Full agent access" lets this scheduled run execute Bash, edit files, and use any installed action. Are you sure?');
if (!ok) return;
}
setDraft({ ...draft, actions: { ...draft.actions, freeze: scoped } });
}}
sx={{ fontSize: LABEL_FS, '& .MuiSelect-select': { py: 0.5 } }}>
<MenuItem value="scoped">Scoped to actions used in original chat (recommended)</MenuItem>
<MenuItem value="full">Full agent access (Bash, file write)</MenuItem>
</Select>
</Box>
{/* Row 7: missed-run policy. */}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, pl: 2, mt: 0.25 }}>
<Typography sx={{ fontSize: HINT_FS, color: c.text.muted }}>If a run was missed (computer asleep):</Typography>
<Select
size="small"
value={s.on_missed}
onChange={(e) => setSched({ on_missed: e.target.value as ScheduleConfig['on_missed'] })}
sx={{ fontSize: LABEL_FS, '& .MuiSelect-select': { py: 0.4 } }}>
<MenuItem value="skip">Skip it</MenuItem>
<MenuItem value="run_once">Run once when app reopens</MenuItem>
<MenuItem value="run_all">Run every missed slot</MenuItem>
</Select>
</Box>
{/* Row 8: permission tiers. */}
<Typography sx={{ fontSize: BODY_FS, fontWeight: 700, color: c.text.primary, mt: 0.5 }}>How should the agent ask for your permission?</Typography>
{(draft.permissions || []).map((tier, idx) => (
<PermissionRow
key={idx}
idx={idx}
tier={tier}
cloudSmsEnabled={Boolean(cloudSms)}
onChange={(patch) => setTier(idx, patch)}
onRemove={idx === 0 ? undefined : () => removeTier(idx)}
/>
))}
{canAddBackup && (
<Box onClick={addBackup} role="button" sx={{ fontSize: LABEL_FS, color: c.text.muted, cursor: 'pointer', mt: 0.5, fontWeight: 500, '&:hover': { color: c.accent.primary } }}>+ add a backup</Box>
)}
</Box>
);
}
function AppOpenStatusBadge({ info, hour, minute, onFix }: { info: AppOpenInfo; hour: number; minute: number; onFix: () => void }) {
const c = useClaudeTokens();
const good = info.alwaysOn;
const fmt = formatTime(hour, minute);
return (
<Box sx={{
display: 'flex', alignItems: 'center', gap: 1, pl: 0.25,
bgcolor: good ? c.status.successBg : (c.status.warningBg || c.bg.elevated),
border: `1px solid ${good ? c.status.success + '60' : (c.status.warning || c.text.muted) + '60'}`,
borderRadius: `${c.radius.md}px`, px: 1, py: 0.5,
}}>
<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 fire even if OpenSwarm is closed.' : `Requires OpenSwarm to be open at ${fmt}.`}
</Typography>
{!good && (
<Tooltip title="Enables launch-at-login and the menubar tray so the scheduler keeps running.">
<Box onClick={onFix} role="button" sx={{ fontSize: HINT_FS, color: c.accent.primary, cursor: 'pointer', fontWeight: 700 }}>Fix</Box>
</Tooltip>
)}
</Box>
);
}
function CostRow({ workflow, draftSched, onCapChange }: { workflow: Workflow; draftSched: ScheduleConfig; onCapChange: (v: number | null) => void }) {
const c = useClaudeTokens();
const est = workflow.cost_estimate;
// Compute fires/30-days live from the draft so the row matches the
// "Next run" preview even before the user saves. Backend's cached
// estimate is the saved-state value and would lie after a draft edit.
const liveFires = useMemo(() => {
if (!draftSched.enabled) return 0;
const now = new Date();
const end = new Date(now.getTime() + 30 * 86400000);
return fireTimesWithin({ schedule: draftSched } as Workflow, now, end, 200).length;
}, [draftSched]);
const lastRun = est?.last_run_usd ?? 0;
const monthly = lastRun * liveFires;
const cap = workflow.cost_cap_usd_monthly;
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.4, pl: 2 }}>
<Typography sx={{ fontSize: HINT_FS, color: c.text.muted }}>
{liveFires > 0 && lastRun > 0
? `~$${monthly.toFixed(2)}/mo at last run's cost ($${lastRun.toFixed(4)} × ${liveFires} fires).`
: liveFires > 0
? `Will fire ${liveFires}× in the next 30 days. Run once to project a monthly cost.`
: 'No upcoming runs.'}
</Typography>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
<Typography sx={{ fontSize: HINT_FS, color: c.text.muted }}>Monthly cost cap:</Typography>
<InputBase
type="number"
placeholder="none"
value={cap == null ? '' : cap}
onChange={(e) => onCapChange(e.target.value === '' ? null : Math.max(0, Number(e.target.value)))}
sx={{ width: 72, fontSize: INPUT_FS, border: `1px solid ${c.border.subtle}`, borderRadius: `${c.radius.md}px`, px: 0.75, py: 0.3 }}
/>
<Typography sx={{ fontSize: HINT_FS, color: c.text.ghost }}>USD. Skips runs once exceeded; visible in History.</Typography>
</Box>
</Box>
);
}
function PermissionRow({ idx, tier, cloudSmsEnabled, onChange, onRemove }: {
idx: number;
tier: PermissionTier;
cloudSmsEnabled: boolean;
onChange: (p: Partial<PermissionTier>) => void;
onRemove?: () => void;
}) {
const c = useClaudeTokens();
if (idx === 0) {
return (
<Select
size="small"
value="notify"
sx={{ alignSelf: 'flex-start', fontSize: LABEL_FS, '& .MuiSelect-select': { py: 0.5 } }}>
<MenuItem value="notify">Notify me in Open Swarm</MenuItem>
</Select>
);
}
const unitLabel = tier.kind === 'call' ? 'hour' : 'minutes';
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.5, pl: 2, position: 'relative' }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, flexWrap: 'wrap' }}>
<Typography sx={{ fontSize: HINT_FS, color: c.text.muted }}> and if I don&apos;t respond after</Typography>
<InputBase
type="number"
value={tier.after_minutes}
onChange={(e) => onChange({ after_minutes: Math.max(0, Number(e.target.value) || 0) })}
sx={{ width: 44, fontSize: INPUT_FS, border: `1px solid ${c.border.subtle}`, borderRadius: `${c.radius.md}px`, px: 0.75, py: 0.4 }}
/>
<Typography sx={{ fontSize: HINT_FS, color: c.text.muted }}>{unitLabel}</Typography>
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
<Select
size="small"
value={tier.kind}
onChange={(e) => onChange({ kind: e.target.value as PermissionTier['kind'] })}
sx={{ fontSize: LABEL_FS, '& .MuiSelect-select': { py: 0.5 } }}>
{tier.kind !== 'call' && <MenuItem value="text">Text me</MenuItem>}
{tier.kind === 'call' && <MenuItem value="call">Call me</MenuItem>}
</Select>
<Typography sx={{ fontSize: HINT_FS, color: c.text.muted }}>at this number</Typography>
<InputBase
value={tier.phone || ''}
placeholder="+1 (000) 123 4567"
onChange={(e) => onChange({ phone: e.target.value })}
sx={{ flex: 1, fontSize: INPUT_FS, border: `1px solid ${c.border.subtle}`, borderRadius: `${c.radius.md}px`, px: 0.75, py: 0.4, color: c.text.primary }}
/>
{onRemove && (
<Box onClick={onRemove} role="button" sx={{ fontSize: HINT_FS, color: c.text.ghost, cursor: 'pointer', px: 0.5, '&:hover': { color: c.status.error } }}>×</Box>
)}
</Box>
{!cloudSmsEnabled && (
<Typography sx={{ fontSize: HINT_FS, color: c.status.warning || c.text.muted, fontStyle: 'italic' }}>
Coming soon. Until cloud SMS ships, this tier falls back to an in-app notify with a "fallback" badge.
</Typography>
)}
</Box>
);
}
@@ -0,0 +1,152 @@
// Minimum-steps-to-value entry point: from any open chat, hit "Schedule"
// in the header, pick one of four presets, and we materialize a workflow
// seeded with source_session_id (so it inherits the chat's tool surface
// + steps via the existing /workflows/create path). "Custom..." opens
// the full editor for power users.
import React, { useCallback, useState } from 'react';
import Box from '@mui/material/Box';
import Typography from '@mui/material/Typography';
import Popover from '@mui/material/Popover';
import InputBase from '@mui/material/InputBase';
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
import { useAppDispatch } from '@/shared/hooks';
import { createWorkflow, openWorkflowCard, type ScheduleConfig } from '@/shared/state/workflowsSlice';
import { defaultSchedule } from './scheduleUtils';
type Preset = {
label: string;
hint: string;
build: () => Partial<ScheduleConfig>;
};
const PRESETS: Preset[] = [
{ label: 'Every day at 9am', hint: 'Daily standup, morning report', build: () => ({ enabled: true, repeat_unit: 'day', repeat_every: 1, hour: 9, minute: 0 }) },
{ label: 'Weekdays at 9am', hint: 'Mon to Fri', build: () => ({ enabled: true, repeat_unit: 'week', repeat_every: 1, on_days: [1, 2, 3, 4, 5], hour: 9, minute: 0 }) },
{ label: 'Every Monday at 9am', hint: 'Weekly check-in', build: () => ({ enabled: true, repeat_unit: 'week', repeat_every: 1, on_days: [1], hour: 9, minute: 0 }) },
{ label: 'Every month on the 1st', hint: 'Monthly summary, billing report', build: () => ({ enabled: true, repeat_unit: 'month', repeat_every: 1, hour: 9, minute: 0 }) },
];
interface Props {
anchorEl: HTMLElement | null;
onClose: () => void;
sessionId: string;
sessionName: string;
// Hook so the caller can show "Workflow created" feedback inline.
onCreated?: (workflowId: string) => void;
}
export default function ScheduleThisPopover({ anchorEl, onClose, sessionId, sessionName, onCreated }: Props) {
const c = useClaudeTokens();
const dispatch = useAppDispatch();
const [title, setTitle] = useState<string>(sessionName || 'Untitled');
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const submit = useCallback(async (preset: Preset) => {
if (busy) return;
setBusy(true);
setError(null);
try {
const schedule: ScheduleConfig = { ...defaultSchedule(), ...preset.build() };
const result = await dispatch(createWorkflow({
title,
source_session_id: sessionId,
schedule,
} as any));
if (createWorkflow.fulfilled.match(result)) {
const wf: any = result.payload;
dispatch(openWorkflowCard({ workflowId: wf.id, view: 'saved' }));
onCreated?.(wf.id);
onClose();
} else {
setError('Create failed. Try again.');
}
} catch (e) {
setError((e as Error)?.message || 'Create failed.');
} finally {
setBusy(false);
}
}, [busy, dispatch, sessionId, title, onClose, onCreated]);
const openCustom = useCallback(async () => {
// "Custom..." materializes a workflow with schedule.enabled=false
// and routes to the full editor. The editor's master toggle is the
// explicit gate — nothing fires until the user flips it on.
if (busy) return;
setBusy(true);
try {
const schedule: ScheduleConfig = { ...defaultSchedule() };
const result = await dispatch(createWorkflow({
title,
source_session_id: sessionId,
schedule,
} as any));
if (createWorkflow.fulfilled.match(result)) {
const wf: any = result.payload;
dispatch(openWorkflowCard({ workflowId: wf.id, view: 'edit', editFacet: 'Schedule' }));
onCreated?.(wf.id);
onClose();
} else {
setError('Create failed. Try again.');
}
} finally {
setBusy(false);
}
}, [busy, dispatch, sessionId, title, onClose, onCreated]);
return (
<Popover
open={Boolean(anchorEl)}
anchorEl={anchorEl}
onClose={onClose}
anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }}
transformOrigin={{ vertical: 'top', horizontal: 'right' }}
slotProps={{ paper: { sx: { width: 320, p: 1.25 } } }}
>
<Typography sx={{ fontSize: '0.78rem', fontWeight: 700, color: c.text.muted, letterSpacing: '0.06em', mb: 0.75 }}>
SCHEDULE THIS CHAT
</Typography>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, mb: 0.75 }}>
<Typography sx={{ fontSize: '0.78rem', color: c.text.secondary }}>Name:</Typography>
<InputBase
value={title}
onChange={(e) => setTitle(e.target.value)}
sx={{ flex: 1, fontSize: '0.85rem', color: c.text.primary, border: `1px solid ${c.border.subtle}`, borderRadius: `${c.radius.md}px`, px: 0.75, py: 0.3 }}
/>
</Box>
{PRESETS.map((p) => (
<Box
key={p.label}
role="button"
onClick={() => submit(p)}
sx={{
display: 'flex', flexDirection: 'column', alignItems: 'flex-start',
px: 1, py: 0.6, borderRadius: `${c.radius.md}px`,
cursor: busy ? 'wait' : 'pointer',
opacity: busy ? 0.5 : 1,
'&:hover': { bgcolor: c.bg.elevated },
}}>
<Typography sx={{ fontSize: '0.86rem', fontWeight: 600, color: c.text.primary }}>{p.label}</Typography>
<Typography sx={{ fontSize: '0.72rem', color: c.text.muted }}>{p.hint}</Typography>
</Box>
))}
<Box
role="button"
onClick={openCustom}
sx={{
mt: 0.5, borderTop: `1px solid ${c.border.subtle}`,
px: 1, py: 0.7, borderRadius: `${c.radius.md}px`,
cursor: busy ? 'wait' : 'pointer',
opacity: busy ? 0.5 : 1,
'&:hover': { bgcolor: c.bg.elevated },
}}>
<Typography sx={{ fontSize: '0.84rem', fontWeight: 600, color: c.accent.primary }}>Custom</Typography>
<Typography sx={{ fontSize: '0.72rem', color: c.text.muted }}>Open the full editor</Typography>
</Box>
{error && (
<Typography sx={{ mt: 0.5, fontSize: '0.74rem', color: c.status.error }}>{error}</Typography>
)}
</Popover>
);
}
@@ -1,58 +1,16 @@
import React, { useCallback, useMemo, useState } from 'react';
import Box from '@mui/material/Box';
import Typography from '@mui/material/Typography';
import InputBase from '@mui/material/InputBase';
import Select from '@mui/material/Select';
import MenuItem from '@mui/material/MenuItem';
import Switch from '@mui/material/Switch';
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
import { useAppDispatch } from '@/shared/hooks';
import { updateWorkflow, type Workflow, type ScheduleConfig, type PermissionTier } from '@/shared/state/workflowsSlice';
import { WEEKDAY_LABEL, formatTime } from './scheduleUtils';
// JS-style weekday from a Date (Sun=0..Sat=6), matching ScheduleConfig.on_days.
function jsWeekday(d: Date): number { return d.getDay(); }
// Compute the next fire time from a ScheduleConfig — mirrors the backend
// math in scheduler.py:_next_fire_after so the preview matches what
// actually fires. Local-clock, like the backend.
function previewNextRun(sched: ScheduleConfig): Date | null {
if (!sched.enabled) return null;
const now = new Date();
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') {
const step = Math.max(1, sched.repeat_every);
while (candidate <= now) candidate = new Date(candidate.getTime() + step * 86400000);
return candidate;
}
if (sched.repeat_unit === 'week') {
const allowed = sched.on_days.length ? sched.on_days : [jsWeekday(now)];
for (let i = 0; i < 14; i += 1) {
if (allowed.includes(jsWeekday(candidate)) && candidate > now) return candidate;
candidate = new Date(candidate.getTime() + 86400000);
}
return candidate;
}
if (sched.repeat_unit === 'month') {
const step = Math.max(1, sched.repeat_every);
let c = new Date(now.getFullYear(), now.getMonth(), Math.min(28, now.getDate()), sched.hour, sched.minute);
let guard = 0;
while (c <= now && guard < 60) {
c = new Date(c.getFullYear(), c.getMonth() + step, c.getDate(), sched.hour, sched.minute);
guard += 1;
}
return c;
}
return null;
}
function formatNextRun(d: Date): string {
const wd = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'][d.getDay()];
const mo = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'][d.getMonth()];
return `${wd} ${mo} ${d.getDate()} at ${formatTime(d.getHours(), d.getMinutes())}`;
}
import { updateWorkflow, type Workflow } from '@/shared/state/workflowsSlice';
import { validateDraft } from './permissionsUtils';
import { ActionBtn, LABEL_FS, HINT_FS } from './workflowEditCommon';
import GeneralFacet from './GeneralFacet';
import ActionsFacet from './ActionsFacet';
import ScheduleFacet from './ScheduleFacet';
interface Props {
workflow: Workflow;
@@ -60,40 +18,13 @@ interface Props {
onChangeFacet: (facet: 'General' | 'Actions' | 'Schedule') => void;
}
const BODY_FS = '0.88rem';
const LABEL_FS = '0.82rem';
const HINT_FS = '0.78rem';
const INPUT_FS = '0.88rem';
// Pre-save validation. Returns the first user-visible reason save should
// be blocked, or null when the draft is good to ship. Keeps the schedule
// from silently saving a "call tier" with no phone number — the previous
// failure mode where the schedule would fire and the call attempt would
// just no-op against an empty string.
function validateDraft(draft: Workflow): string | null {
for (const tier of (draft.permissions || [])) {
if (tier.kind === 'notify') continue;
const cleaned = (tier.phone || '').replace(/[^\d+]/g, '');
if (!cleaned) {
return tier.kind === 'text'
? 'Add a phone number for the text-me tier.'
: 'Add a phone number for the call-me tier.';
}
if (cleaned.replace(/^\+/, '').length < 7) {
return `Phone number looks too short (${tier.kind} tier).`;
}
}
return null;
}
export default function WorkflowEditViews({ workflow, facet, onChangeFacet }: Props) {
const c = useClaudeTokens();
const dispatch = useAppDispatch();
const [draft, setDraft] = useState<Workflow>(workflow);
const [busy, setBusy] = useState(false);
// Save-feedback state: 'idle' | 'saved' | 'error'. `saved` flashes a
// checkmark + label on the Save button for 1.4s then auto-clears.
// `error` carries a string the user can read.
// Save-feedback state. `savedFlash` flashes a checkmark for 1.4s then
// auto-clears; `saveError` carries a string the user can read.
const [savedFlash, setSavedFlash] = useState(false);
const [saveError, setSaveError] = useState<string | null>(null);
@@ -163,403 +94,3 @@ export default function WorkflowEditViews({ workflow, facet, onChangeFacet }: Pr
</Box>
);
}
function GeneralFacet({ draft, setDraft }: { draft: Workflow; setDraft: (w: Workflow) => void }) {
const c = useClaudeTokens();
const [editingPrompt, setEditingPrompt] = useState(false);
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.25 }}>
<FieldRow label="Title">
<InputBase
value={draft.title}
onChange={(e) => setDraft({ ...draft, title: e.target.value })}
sx={{ flex: 1, fontSize: INPUT_FS, color: c.text.primary, border: `1px solid ${c.border.subtle}`, borderRadius: `${c.radius.md}px`, px: 1, py: 0.5 }}
/>
</FieldRow>
<FieldRow label="Description" align="top">
<InputBase
multiline
minRows={2}
value={draft.description}
onChange={(e) => setDraft({ ...draft, description: e.target.value })}
sx={{ flex: 1, fontSize: INPUT_FS, color: c.text.secondary, lineHeight: 1.5, border: `1px solid ${c.border.subtle}`, borderRadius: `${c.radius.md}px`, px: 1, py: 0.5 }}
/>
</FieldRow>
<FieldRow label="System prompt">
<Box sx={{ flex: 1, display: 'flex', alignItems: 'center', gap: 0.75 }}>
<Box sx={{ fontSize: LABEL_FS, color: c.accent.primary, cursor: 'pointer', fontWeight: 500 }} onClick={() => setEditingPrompt((v) => !v)}>
{editingPrompt ? 'Editing…' : 'Edit'}
</Box>
<Select
size="small"
value={draft.use_synced_prompt ? 'synced' : 'custom'}
onChange={(e) => setDraft({ ...draft, use_synced_prompt: e.target.value === 'synced' })}
sx={{ fontSize: LABEL_FS, '& .MuiSelect-select': { py: 0.5 } }}>
<MenuItem value="synced">Synced to settings</MenuItem>
<MenuItem value="custom">Custom</MenuItem>
</Select>
</Box>
</FieldRow>
{editingPrompt && !draft.use_synced_prompt && (
<InputBase
multiline
minRows={4}
placeholder="Custom system prompt..."
value={draft.system_prompt || ''}
onChange={(e) => setDraft({ ...draft, system_prompt: e.target.value })}
sx={{ fontSize: INPUT_FS, color: c.text.primary, border: `1px solid ${c.border.subtle}`, borderRadius: `${c.radius.md}px`, p: 1, lineHeight: 1.5 }}
/>
)}
<Typography sx={{ fontSize: BODY_FS, fontWeight: 700, color: c.text.primary, mt: 0.5 }}>Workflow</Typography>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
{draft.steps.map((s, idx) => (
<Box key={s.id} sx={{ display: 'flex', alignItems: 'flex-start', gap: 1.25 }}>
<Box sx={{ width: 24, height: 24, borderRadius: '50%', border: `1px solid ${c.border.medium}`, fontSize: HINT_FS, fontWeight: 700, display: 'flex', alignItems: 'center', justifyContent: 'center', color: c.text.secondary, flexShrink: 0, mt: 0.4 }}>{idx + 1}</Box>
<InputBase
multiline
value={s.text}
onChange={(e) => {
const next = [...draft.steps];
next[idx] = { ...s, text: e.target.value };
setDraft({ ...draft, steps: next });
}}
sx={{ flex: 1, fontSize: INPUT_FS, color: c.text.primary, border: `1px solid ${c.border.subtle}`, borderRadius: `${c.radius.md}px`, px: 1.25, py: 0.6, lineHeight: 1.4 }}
/>
</Box>
))}
</Box>
</Box>
);
}
function ActionsFacet({ draft, setDraft }: { draft: Workflow; setDraft: (w: Workflow) => void }) {
const c = useClaudeTokens();
// Configure must ONLY appear when freeze is on (image #40 annotation).
// When "Don't freeze" is selected the entry vanishes entirely.
const [configuring, setConfiguring] = useState(false);
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.25, color: c.text.secondary }}>
<Typography sx={{ fontSize: BODY_FS, color: c.text.secondary, lineHeight: 1.5 }}>
Do you want to prevent the agent from taking actions that weren&apos;t used in the original workflow?
</Typography>
<Box sx={{ display: 'flex', justifyContent: 'flex-end' }}>
<Select
size="small"
value={draft.actions.prevent_unused ? 'prevent' : 'allow'}
onChange={(e) => setDraft({ ...draft, actions: { ...draft.actions, prevent_unused: e.target.value === 'prevent' } })}
sx={{ fontSize: LABEL_FS, '& .MuiSelect-select': { py: 0.5 } }}>
<MenuItem value="prevent">Prevent all unwanted actions</MenuItem>
<MenuItem value="allow">Allow all actions</MenuItem>
</Select>
</Box>
<Typography sx={{ fontSize: BODY_FS, color: c.text.secondary, lineHeight: 1.5, mt: 0.5 }}>
Do you want to freeze the actions available to the Agent so this flow always works even if you change your settings?
</Typography>
<Box sx={{ display: 'flex', justifyContent: 'flex-end' }}>
<Select
size="small"
value={draft.actions.freeze ? 'freeze' : 'dont'}
onChange={(e) => setDraft({ ...draft, actions: { ...draft.actions, freeze: e.target.value === 'freeze' } })}
sx={{ fontSize: LABEL_FS, '& .MuiSelect-select': { py: 0.5 } }}>
<MenuItem value="freeze">Freeze actions</MenuItem>
<MenuItem value="dont">Don&apos;t freeze</MenuItem>
</Select>
</Box>
{draft.actions.freeze && (
<Box sx={{ display: 'flex', justifyContent: 'flex-end', mt: 0.5 }}>
<Box
onClick={() => setConfiguring((v) => !v)}
role="button"
sx={{ display: 'inline-flex', alignItems: 'center', gap: 0.4, fontSize: LABEL_FS, color: configuring ? c.accent.primary : c.text.secondary, cursor: 'pointer', fontWeight: 500, '&:hover': { color: c.accent.primary } }}>
{configuring ? '⚙ Configuring…' : '⚙ Configure'}
</Box>
</Box>
)}
{draft.actions.freeze && configuring && (
<Box sx={{ mt: 0.5, display: 'flex', flexDirection: 'column', gap: 0.6, border: `1px solid ${c.accent.primary}40`, borderRadius: `${c.radius.lg}px`, p: 1.25 }}>
<Typography sx={{ fontSize: HINT_FS, fontWeight: 700, color: c.text.secondary, letterSpacing: '0.05em', mb: 0.25 }}>BUILT-IN ACTION SETS</Typography>
{(['Core Actions', 'Extended Actions', 'Apps', 'Browser'] as const).map((set) => {
const enabled = draft.actions.configured_sets.includes(set);
return (
<Box key={set} sx={{ display: 'flex', alignItems: 'center', gap: 1, border: `1px solid ${c.border.subtle}`, borderRadius: `${c.radius.md}px`, px: 1, py: 0.6 }}>
<Typography sx={{ flex: 1, fontSize: BODY_FS, color: c.text.primary, fontWeight: 600 }}>{set}</Typography>
<Switch
size="small"
checked={enabled}
onChange={(e) => {
const next = e.target.checked
? [...draft.actions.configured_sets, set]
: draft.actions.configured_sets.filter((s) => s !== set);
setDraft({ ...draft, actions: { ...draft.actions, configured_sets: next } });
}}
/>
</Box>
);
})}
<Typography sx={{ fontSize: HINT_FS, fontWeight: 700, color: c.text.secondary, letterSpacing: '0.05em', mt: 0.75, mb: 0.25 }}>CUSTOM ACTION SETS</Typography>
{(['Notion', 'Google Workspace', 'YouTube', 'Reddit'] as const).map((set) => {
const enabled = draft.actions.configured_sets.includes(set);
return (
<Box key={set} sx={{ display: 'flex', alignItems: 'center', gap: 1, border: `1px solid ${c.border.subtle}`, borderRadius: `${c.radius.md}px`, px: 1, py: 0.6 }}>
<Typography sx={{ flex: 1, fontSize: BODY_FS, color: c.text.primary, fontWeight: 600 }}>{set}</Typography>
<Switch
size="small"
checked={enabled}
onChange={(e) => {
const next = e.target.checked
? [...draft.actions.configured_sets, set]
: draft.actions.configured_sets.filter((s) => s !== set);
setDraft({ ...draft, actions: { ...draft.actions, configured_sets: next } });
}}
/>
</Box>
);
})}
</Box>
)}
</Box>
);
}
function ScheduleFacet({ draft, setDraft }: { draft: Workflow; setDraft: (w: Workflow) => void }) {
const c = useClaudeTokens();
const s = draft.schedule;
const setSched = useCallback((patch: Partial<ScheduleConfig>) => {
setDraft({ ...draft, schedule: { ...s, ...patch, enabled: true } });
}, [draft, s, setDraft]);
const addBackup = useCallback(() => {
const tiers = [...(draft.permissions || [])];
const lastKind = tiers.length ? tiers[tiers.length - 1].kind : 'notify';
// Tier escalation chain: notify → text → call. Cap at 3 tiers since
// the chain has no fourth medium and stacking duplicates makes no
// sense (matches Figma image #44 ceiling).
if (lastKind === 'notify') tiers.push({ kind: 'text', after_minutes: 5, phone: '' });
else if (lastKind === 'text') tiers.push({ kind: 'call', after_minutes: 60, phone: '' });
else return;
setDraft({ ...draft, permissions: tiers });
}, [draft, setDraft]);
const removeTier = useCallback((idx: number) => {
// Removing tier N drops all tiers after it too, so the chain stays
// contiguous (no "call" without "text" before it).
const tiers = (draft.permissions || []).slice(0, idx);
setDraft({ ...draft, permissions: tiers });
}, [draft, setDraft]);
const lastTierKind = (draft.permissions || []).length
? draft.permissions[draft.permissions.length - 1].kind
: 'notify';
const canAddBackup = lastTierKind !== 'call';
const setTier = useCallback((idx: number, patch: Partial<PermissionTier>) => {
const tiers = [...(draft.permissions || [])];
tiers[idx] = { ...tiers[idx], ...patch };
setDraft({ ...draft, permissions: tiers });
}, [draft, setDraft]);
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.25 }}>
<Typography sx={{ fontSize: BODY_FS, fontWeight: 700, color: c.text.primary }}>When should this workflow run?</Typography>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, flexWrap: 'wrap' }}>
<Typography sx={{ fontSize: BODY_FS, color: c.text.secondary }}>Repeat every</Typography>
<InputBase
type="number"
value={s.repeat_every}
onChange={(e) => setSched({ repeat_every: Math.max(1, Number(e.target.value) || 1) })}
sx={{ width: 48, 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'] })}
sx={{ fontSize: LABEL_FS, '& .MuiSelect-select': { py: 0.5 } }}>
<MenuItem value="day">day</MenuItem>
<MenuItem value="week">week</MenuItem>
<MenuItem value="month">month</MenuItem>
</Select>
</Box>
{s.repeat_unit === 'week' && (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, pl: 2, flexWrap: 'wrap' }}>
<Typography sx={{ fontSize: HINT_FS, color: c.text.muted }}> on</Typography>
{WEEKDAY_LABEL.map((label, idx) => {
const active = s.on_days.includes(idx);
return (
<Box
key={idx}
onClick={() => setSched({ on_days: active ? s.on_days.filter((d) => d !== idx) : [...s.on_days, idx] })}
role="button"
sx={{ width: 26, height: 26, borderRadius: '50%', display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: LABEL_FS, fontWeight: 700, cursor: 'pointer', color: active ? '#fff' : c.text.muted, bgcolor: active ? c.accent.primary : 'transparent', border: `1px solid ${active ? c.accent.primary : c.border.subtle}` }}>{label}</Box>
);
})}
</Box>
)}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, pl: 2 }}>
<Typography sx={{ fontSize: HINT_FS, color: c.text.muted }}> at</Typography>
{/* 12-hour picker; we store 0..23 server-side but show 1..12 + AM/PM
so users can't accidentally schedule "3" thinking it's 3pm and
get a 3am run (the previous bare-number input made that easy). */}
<Select
size="small"
value={((s.hour + 11) % 12) + 1}
onChange={(e) => {
const h12 = Number(e.target.value);
const isPm = s.hour >= 12;
const next = (h12 % 12) + (isPm ? 12 : 0);
setSched({ hour: next });
}}
sx={{ fontSize: LABEL_FS, '& .MuiSelect-select': { py: 0.4 } }}>
{Array.from({ length: 12 }, (_, i) => i + 1).map((h) => (
<MenuItem key={h} value={h}>{h}</MenuItem>
))}
</Select>
<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>
<Select
size="small"
value={s.hour < 12 ? 'AM' : 'PM'}
onChange={(e) => {
const wasPm = s.hour >= 12;
const willBePm = e.target.value === 'PM';
if (wasPm === willBePm) return;
setSched({ hour: willBePm ? s.hour + 12 : s.hour - 12 });
}}
sx={{ fontSize: LABEL_FS, '& .MuiSelect-select': { py: 0.4 } }}>
<MenuItem value="AM">AM</MenuItem>
<MenuItem value="PM">PM</MenuItem>
</Select>
</Box>
{(() => {
// "Next run" preview is the single line that turns "did my schedule
// actually take?" from a guess into an answer. Re-renders whenever
// the schedule fields change, so users get instant feedback.
const next = previewNextRun({ ...s, enabled: true });
return next ? (
<Typography sx={{ fontSize: HINT_FS, color: c.accent.primary, pl: 2, fontWeight: 500 }}>
Next run: {formatNextRun(next)}
</Typography>
) : null;
})()}
<Typography sx={{ fontSize: BODY_FS, fontWeight: 700, color: c.text.primary, mt: 0.5 }}>How should the agent ask for your permission?</Typography>
{(draft.permissions || []).map((tier, idx) => (
<PermissionRow
key={idx}
idx={idx}
tier={tier}
prevKind={idx === 0 ? null : (draft.permissions[idx - 1].kind)}
onChange={(patch) => setTier(idx, patch)}
onRemove={idx === 0 ? undefined : () => removeTier(idx)}
/>
))}
{canAddBackup && (
<Box onClick={addBackup} role="button" sx={{ fontSize: LABEL_FS, color: c.text.muted, cursor: 'pointer', mt: 0.5, fontWeight: 500, '&:hover': { color: c.accent.primary } }}>+ add a backup</Box>
)}
</Box>
);
}
function PermissionRow({ idx, tier, onChange, onRemove }: {
idx: number;
tier: PermissionTier;
prevKind: PermissionTier['kind'] | null;
onChange: (p: Partial<PermissionTier>) => void;
onRemove?: () => void;
}) {
const c = useClaudeTokens();
if (idx === 0) {
return (
<Select
size="small"
value="notify"
sx={{ alignSelf: 'flex-start', fontSize: LABEL_FS, '& .MuiSelect-select': { py: 0.5 } }}>
<MenuItem value="notify">Notify me in Open Swarm</MenuItem>
</Select>
);
}
const verb = tier.kind === 'text' ? 'Text me' : 'Call me';
const unitLabel = tier.kind === 'call' ? 'hour' : 'minutes';
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.5, pl: 2, position: 'relative' }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, flexWrap: 'wrap' }}>
<Typography sx={{ fontSize: HINT_FS, color: c.text.muted }}> and if I don&apos;t respond after</Typography>
<InputBase
type="number"
value={tier.after_minutes}
onChange={(e) => onChange({ after_minutes: Math.max(0, Number(e.target.value) || 0) })}
sx={{ width: 44, fontSize: INPUT_FS, border: `1px solid ${c.border.subtle}`, borderRadius: `${c.radius.md}px`, px: 0.75, py: 0.4 }}
/>
<Typography sx={{ fontSize: HINT_FS, color: c.text.muted }}>{unitLabel}</Typography>
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
<Select
size="small"
value={tier.kind}
onChange={(e) => onChange({ kind: e.target.value as PermissionTier['kind'] })}
sx={{ fontSize: LABEL_FS, '& .MuiSelect-select': { py: 0.5 } }}>
{tier.kind !== 'call' && <MenuItem value="text">Text me</MenuItem>}
{tier.kind === 'call' && <MenuItem value="call">Call me</MenuItem>}
</Select>
<Typography sx={{ fontSize: HINT_FS, color: c.text.muted }}>at this number</Typography>
<InputBase
value={tier.phone || ''}
placeholder="+1 (000) 123 4567"
onChange={(e) => onChange({ phone: e.target.value })}
sx={{ flex: 1, fontSize: INPUT_FS, border: `1px solid ${c.border.subtle}`, borderRadius: `${c.radius.md}px`, px: 0.75, py: 0.4, color: c.text.primary }}
/>
{onRemove && (
<Box
onClick={onRemove}
role="button"
sx={{ fontSize: HINT_FS, color: c.text.ghost, cursor: 'pointer', px: 0.5, '&:hover': { color: c.status.error } }}>
×
</Box>
)}
</Box>
</Box>
);
}
function FieldRow({ label, children, align }: { label: string; children: React.ReactNode; align?: 'top' | 'center' }) {
const c = useClaudeTokens();
return (
<Box sx={{ display: 'flex', alignItems: align === 'top' ? 'flex-start' : 'center', gap: 1 }}>
<Typography sx={{ width: 100, flexShrink: 0, fontSize: LABEL_FS, color: c.text.secondary, mt: align === 'top' ? 0.75 : 0, fontWeight: 500 }}>{label}:</Typography>
{children}
</Box>
);
}
function ActionBtn({ label, tone, disabled, onClick }: { label: string; tone: 'muted' | 'success'; disabled?: boolean; onClick: () => void }) {
const c = useClaudeTokens();
const isSuccess = tone === 'success';
return (
<Box
onClick={disabled ? undefined : onClick}
role="button"
sx={{
fontSize: LABEL_FS, fontWeight: 600, px: 1.25, py: 0.5,
borderRadius: `${c.radius.md}px`,
cursor: disabled ? 'not-allowed' : 'pointer',
color: isSuccess ? c.status.success : c.text.secondary,
bgcolor: isSuccess ? c.status.successBg : c.bg.secondary,
border: `1px solid ${isSuccess ? c.status.success + '60' : c.border.subtle}`,
opacity: disabled ? 0.5 : 1,
'&:hover': { bgcolor: isSuccess ? c.status.success + '30' : c.bg.elevated },
}}>
{label}
</Box>
);
}
@@ -18,7 +18,10 @@ import {
setWorkflowsHubPosition,
setWorkflowsHubSize,
} from '@/shared/state/dashboardLayoutSlice';
import { openWorkflowCard } from '@/shared/state/workflowsSlice';
import { openWorkflowCard, fetchPausedState, setPausedAll } from '@/shared/state/workflowsSlice';
import Switch from '@mui/material/Switch';
import Tooltip from '@mui/material/Tooltip';
import { useEffect } from 'react';
import ScheduleCalendar from './ScheduleCalendar';
import { WEEKDAY_LABEL, addDays, sameDay, startOfMonthGrid } from './scheduleUtils';
@@ -65,6 +68,13 @@ const WorkflowsHubCard: React.FC<Props> = ({
const c = useClaudeTokens();
const dispatch = useAppDispatch();
const workflows = useAppSelector((s) => s.workflows.items);
const paused = useAppSelector((s) => s.workflows.paused);
useEffect(() => { dispatch(fetchPausedState()); }, [dispatch]);
const togglePaused = useCallback(() => {
dispatch(setPausedAll(!paused));
}, [dispatch, paused]);
const [view, setView] = useState<CalendarView>('Week');
const [viewOpen, setViewOpen] = useState(false);
@@ -278,6 +288,24 @@ const WorkflowsHubCard: React.FC<Props> = ({
<AddIcon sx={{ fontSize: 14 }} />
New
</Box>
<Tooltip title={paused ? 'All scheduled workflows are paused. Toggle to resume.' : 'Pause every scheduled workflow without disabling them individually.'}>
<Box
onClick={togglePaused}
role="button"
data-no-drag
sx={{
display: 'inline-flex', alignItems: 'center', gap: 0.4, ml: 0.5,
fontSize: '0.8rem', fontWeight: 600,
color: paused ? c.status.warning || c.accent.primary : c.text.secondary,
bgcolor: paused ? (c.status.warningBg || c.bg.elevated) : 'transparent',
border: `1px solid ${paused ? (c.status.warning || c.accent.primary) + '60' : c.border.subtle}`,
px: 0.85, py: 0.3, borderRadius: `${c.radius.md}px`, cursor: 'pointer',
'&:hover': { color: c.text.primary, borderColor: c.border.medium },
}}>
<Switch size="small" checked={paused} sx={{ pointerEvents: 'none', mr: -0.5, ml: -0.5 }} />
<span>{paused ? 'Paused' : 'Pause all'}</span>
</Box>
</Tooltip>
<Box sx={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 0.75 }}>
<Box
@@ -0,0 +1,31 @@
import type { Workflow, PermissionTier } from '@/shared/state/workflowsSlice';
// Pre-save validation. Returns the first user-visible reason save should
// be blocked, or null when the draft is good to ship. Phone numbers on
// text/call tiers must be non-empty and at least 7 digits so the eventual
// SMS/voice bridge has something usable to dial.
export function validateDraft(draft: Workflow): string | null {
for (const tier of (draft.permissions || [])) {
if (tier.kind === 'notify') continue;
const cleaned = (tier.phone || '').replace(/[^\d+]/g, '');
if (!cleaned) {
return tier.kind === 'text'
? 'Add a phone number for the text-me tier.'
: 'Add a phone number for the call-me tier.';
}
if (cleaned.replace(/^\+/, '').length < 7) {
return `Phone number looks too short (${tier.kind} tier).`;
}
}
return null;
}
// Walk the existing permissions list and produce the next tier in the
// chain (notify -> text -> call). Returns null if we're already at call,
// which the UI uses to hide the "+ add backup" affordance.
export function nextTierAfter(tiers: PermissionTier[]): PermissionTier | null {
const last = tiers.length ? tiers[tiers.length - 1].kind : 'notify';
if (last === 'notify') return { kind: 'text', after_minutes: 5, phone: '' };
if (last === 'text') return { kind: 'call', after_minutes: 60, phone: '' };
return null;
}
@@ -5,6 +5,11 @@ export const WEEKDAY_LABEL_SHORT = ['SUN', 'MON', 'TUE', 'WED', 'THU', 'FRI', 'S
export const WEEKDAY_FULL = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
export function defaultSchedule(): ScheduleConfig {
// Pick the host's IANA tz so new schedules start with an explicit zone
// instead of the legacy "local" sentinel. Backend storage still coerces
// "local" if a record predates this default; new records skip that path.
let tz = 'local';
try { tz = Intl.DateTimeFormat().resolvedOptions().timeZone || 'local'; } catch { /* keep 'local' */ }
return {
enabled: false,
repeat_every: 1,
@@ -12,8 +17,11 @@ export function defaultSchedule(): ScheduleConfig {
on_days: [],
hour: 9,
minute: 0,
timezone: 'local',
timezone: tz,
on_missed: 'skip',
ends_at: null,
max_runs: null,
runs_count: 0,
};
}
@@ -84,16 +92,36 @@ export function addDays(date: Date, n: number): Date {
return d;
}
function lastDayOfMonth(year: number, monthZeroBased: number): number {
// Date(year, month, 0) returns the last day of the previous month, so
// passing month+1 gives the last day of `monthZeroBased`. Matches the
// backend's calendar.monthrange behavior so the FE preview no longer
// clamps to day 28 (the old shared bug between this and previewNextRun).
return new Date(year, monthZeroBased + 1, 0).getDate();
}
export function fireTimesWithin(workflow: Workflow, from: Date, to: Date, cap = 40): Date[] {
const sched = workflow.schedule;
if (!sched.enabled) return [];
// Honor end conditions on the FE preview too, so the calendar doesn't
// paint pills for fires the backend will refuse to run. ends_at is an
// ISO string in workflow state; max_runs/runs_count are numbers.
if (sched.ends_at) {
const endsAt = new Date(sched.ends_at);
if (!Number.isNaN(endsAt.getTime()) && endsAt.getTime() <= from.getTime()) return [];
if (!Number.isNaN(endsAt.getTime()) && endsAt.getTime() < to.getTime()) to = endsAt;
}
if (sched.max_runs != null && sched.runs_count >= sched.max_runs) return [];
const remainingRuns = sched.max_runs != null ? Math.max(0, sched.max_runs - sched.runs_count) : Infinity;
const effectiveCap = Math.min(cap, remainingRuns);
if (effectiveCap === 0) return [];
const out: Date[] = [];
const cursor = new Date(from);
cursor.setHours(0, 0, 0, 0);
if (sched.repeat_unit === 'day') {
const step = Math.max(1, sched.repeat_every);
for (let i = 0; i < 366 && out.length < cap; i += step) {
for (let i = 0; i < 366 && out.length < effectiveCap; i += step) {
const d = new Date(cursor);
d.setDate(d.getDate() + i);
d.setHours(sched.hour, sched.minute, 0, 0);
@@ -104,18 +132,25 @@ export function fireTimesWithin(workflow: Workflow, from: Date, to: Date, cap =
}
if (sched.repeat_unit === 'month') {
let d = new Date(from.getFullYear(), from.getMonth(), Math.min(28, from.getDate()), sched.hour, sched.minute);
const startDay = from.getDate();
let year = from.getFullYear();
let month = from.getMonth();
let guard = 0;
while (d <= to && out.length < cap && guard < 60) {
if (d >= from) out.push(new Date(d));
d = new Date(d.getFullYear(), d.getMonth() + Math.max(1, sched.repeat_every), d.getDate(), sched.hour, sched.minute);
while (out.length < effectiveCap && guard < 60) {
const day = Math.min(startDay, lastDayOfMonth(year, month));
const d = new Date(year, month, day, sched.hour, sched.minute, 0, 0);
if (d > to) break;
if (d >= from) out.push(d);
month += Math.max(1, sched.repeat_every);
year += Math.floor(month / 12);
month = ((month % 12) + 12) % 12;
guard += 1;
}
return out;
}
const allowed = sched.on_days.length ? sched.on_days : [from.getDay()];
for (let i = 0; i < 60 && out.length < cap; i += 1) {
for (let i = 0; i < 60 && out.length < effectiveCap; i += 1) {
const day = new Date(cursor);
day.setDate(day.getDate() + i);
if (!allowed.includes(day.getDay())) continue;
@@ -0,0 +1,41 @@
import React from 'react';
import Box from '@mui/material/Box';
import Typography from '@mui/material/Typography';
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
export const BODY_FS = '0.88rem';
export const LABEL_FS = '0.82rem';
export const HINT_FS = '0.78rem';
export const INPUT_FS = '0.88rem';
export function FieldRow({ label, children, align }: { label: string; children: React.ReactNode; align?: 'top' | 'center' }) {
const c = useClaudeTokens();
return (
<Box sx={{ display: 'flex', alignItems: align === 'top' ? 'flex-start' : 'center', gap: 1 }}>
<Typography sx={{ width: 100, flexShrink: 0, fontSize: LABEL_FS, color: c.text.secondary, mt: align === 'top' ? 0.75 : 0, fontWeight: 500 }}>{label}:</Typography>
{children}
</Box>
);
}
export function ActionBtn({ label, tone, disabled, onClick }: { label: string; tone: 'muted' | 'success'; disabled?: boolean; onClick: () => void }) {
const c = useClaudeTokens();
const isSuccess = tone === 'success';
return (
<Box
onClick={disabled ? undefined : onClick}
role="button"
sx={{
fontSize: LABEL_FS, fontWeight: 600, px: 1.25, py: 0.5,
borderRadius: `${c.radius.md}px`,
cursor: disabled ? 'not-allowed' : 'pointer',
color: isSuccess ? c.status.success : c.text.secondary,
bgcolor: isSuccess ? c.status.successBg : c.bg.secondary,
border: `1px solid ${isSuccess ? c.status.success + '60' : c.border.subtle}`,
opacity: disabled ? 0.5 : 1,
'&:hover': { bgcolor: isSuccess ? c.status.success + '30' : c.bg.elevated },
}}>
{label}
</Box>
);
}
@@ -12,6 +12,12 @@ const fetchSessionRejectedAction = createAction<
{ sessionId?: string; status?: number } | undefined
>('agents/fetchSession/rejected');
// Cascade: when a workflow is deleted from the hub, drop its canvas
// layout entry too. Otherwise the "Make workflow" tether keeps drawing
// to where the card used to live, pointing at empty space. Matched by
// string to dodge the circular import with workflowsSlice.
const deleteWorkflowFulfilledAction = createAction<string>('workflows/delete/fulfilled');
const DASHBOARDS_API = `${API_BASE}/dashboards`;
export const DEFAULT_CARD_W = 480;
@@ -1159,6 +1165,10 @@ const dashboardLayoutSlice = createSlice({
if (state.cards[id]) delete state.cards[id];
if (state.closedCardPositions[id]) delete state.closedCardPositions[id];
})
.addCase(deleteWorkflowFulfilledAction, (state, action) => {
const id = action.payload;
if (id && state.workflowCards[id]) delete state.workflowCards[id];
})
.addCase(launchAndSendFirstMessage.fulfilled, (state, action) => {
const { draftId, session } = action.payload;
const card = state.cards[draftId];
+65 -3
View File
@@ -20,6 +20,24 @@ export interface ScheduleConfig {
minute: number;
timezone: string;
on_missed: 'skip' | 'run_once' | 'run_all';
// Optional end conditions. null on both = forever. The scheduler auto-
// disables once either threshold is crossed.
ends_at: string | null;
max_runs: number | null;
runs_count: number;
}
export interface CostEstimate {
monthly_usd: number;
last_run_usd: number;
fires_per_month: number;
}
export interface ActiveRun {
workflow_id: string;
run_id: string;
title: string;
started_at: string | null;
}
export interface ActionsConfig {
@@ -52,9 +70,11 @@ export interface Workflow {
created_at: string;
updated_at: string;
last_run_at: string | null;
last_run_status: 'success' | 'failure' | 'ran_late' | 'running' | null;
last_run_status: 'success' | 'failure' | 'ran_late' | 'running' | 'skipped' | null;
last_run_id: string | null;
next_run_at: string | null;
cost_cap_usd_monthly: number | null;
cost_estimate?: CostEstimate;
}
export interface WorkflowRun {
@@ -88,9 +108,12 @@ interface State {
openCards: Record<string, OpenCard>;
loaded: boolean;
loading: boolean;
paused: boolean;
active: ActiveRun[];
cloudSmsEnabled: boolean;
}
const initialState: State = { items: {}, runs: {}, openCards: {}, loaded: false, loading: false };
const initialState: State = { items: {}, runs: {}, openCards: {}, loaded: false, loading: false, paused: false, active: [], cloudSmsEnabled: false };
export const fetchWorkflows = createAsyncThunk(
'workflows/fetch',
@@ -150,6 +173,41 @@ export const fetchRuns = createAsyncThunk(
},
);
export const fetchPausedState = createAsyncThunk('workflows/paused', async () => {
const res = await fetch(`${API}/paused`);
const data = await res.json();
return Boolean(data.paused);
});
export const fetchActiveRuns = createAsyncThunk('workflows/active', async () => {
const res = await fetch(`${API}/active`);
const data = await res.json();
return (data.active || []) as ActiveRun[];
});
export const setPausedAll = createAsyncThunk('workflows/setPaused', async (paused: boolean) => {
const res = await fetch(`${API}/${paused ? 'pause-all' : 'resume-all'}`, { method: 'POST' });
if (!res.ok) throw new Error(`pause-all toggle failed ${res.status}`);
const data = await res.json();
return Boolean(data.paused);
});
export const ackRun = createAsyncThunk('workflows/ackRun', async (runId: string) => {
const res = await fetch(`${API}/runs/${encodeURIComponent(runId)}/ack`, { method: 'POST' });
if (!res.ok) throw new Error(`ack failed ${res.status}`);
return runId;
});
export const fetchCloudSmsStatus = createAsyncThunk('workflows/cloudSms', async () => {
try {
const res = await fetch(`${API}/cloud/sms/status`);
const data = await res.json();
return Boolean(data.enabled);
} catch {
return false;
}
});
const slice = createSlice({
name: 'workflows',
initialState,
@@ -202,7 +260,11 @@ const slice = createSlice({
})
.addCase(fetchRuns.fulfilled, (state, action) => {
state.runs[action.payload.id] = action.payload.runs;
});
})
.addCase(fetchPausedState.fulfilled, (state, action) => { state.paused = action.payload; })
.addCase(setPausedAll.fulfilled, (state, action) => { state.paused = action.payload; })
.addCase(fetchActiveRuns.fulfilled, (state, action) => { state.active = action.payload; })
.addCase(fetchCloudSmsStatus.fulfilled, (state, action) => { state.cloudSmsEnabled = action.payload; });
},
});
@@ -702,6 +702,20 @@ class WebSocketManager {
status: data.status === 'success' ? 'completed' : 'error',
});
} catch { /* notifications are best-effort */ }
try {
// Fire a native OS notification too. Survives a closed window
// (the in-app toast doesn't), which is the whole point of
// promoting these to the OS layer for scheduled runs.
const w: any = (window as any).openswarm;
if (w?.notify) {
const title = `${data.workflow_title || 'Workflow'}${data.status === 'success' ? 'done' : data.status}`;
const body = data.tier_kind && data.fallback
? `Would have ${data.tier_kind === 'call' ? 'called' : 'texted'} you. (Cloud SMS not wired yet.)`
: 'Tap to open the run.';
const deepLink = data.workflow_id ? `openswarm://workflow/${data.workflow_id}/run/${data.run_id || ''}` : undefined;
w.notify({ title, body, deepLink });
}
} catch { /* native notif optional */ }
break;
case 'dashboard:browser_card_added':
+430
View File
@@ -0,0 +1,430 @@
#!/usr/bin/env python3
"""Exhaustive HTTP-level verification of every scheduled-tasks behavior.
Runs against a live backend on :8324. Exits non-zero on any failure.
Each assertion prints one line: PASS/FAIL + sentence describing the
behavior tested. We're going wide here — every endpoint, every field,
every edge case I can drive over HTTP.
"""
from __future__ import annotations
import json
import os
import sys
import time
import urllib.error
import urllib.request
from datetime import datetime, timedelta, timezone
BASE = "http://127.0.0.1:8324/api/workflows"
with open("backend/data/auth.token") as f:
TOK = f.read().strip()
HEADERS = {"Authorization": f"Bearer {TOK}", "Content-Type": "application/json"}
GREEN = "\033[32m"
RED = "\033[31m"
DIM = "\033[2m"
RESET = "\033[0m"
fail_count = 0
created_ids: list[str] = []
def http(method: str, path: str, body=None, raw: bool = False):
url = f"{BASE}{path}"
if body is None:
data = None
elif raw:
data = body if isinstance(body, (bytes, bytearray)) else body.encode()
else:
data = json.dumps(body).encode()
req = urllib.request.Request(url, data=data, method=method, headers=HEADERS)
try:
with urllib.request.urlopen(req, timeout=10) as resp:
return resp.status, json.loads(resp.read() or b"null")
except urllib.error.HTTPError as e:
try: body_err = json.loads(e.read())
except Exception: body_err = None
return e.code, body_err
except Exception as e:
return -1, str(e)
def ok(label: str, cond: bool, info: str = ""):
global fail_count
if cond:
print(f" {GREEN}PASS{RESET} {label}{DIM}{('' + info) if info else ''}{RESET}")
else:
fail_count += 1
print(f" {RED}FAIL{RESET} {label}{('' + info) if info else ''}")
def section(title: str):
print(f"\n\033[1m{title}{RESET}")
# Make a known-clean workflow for each test that needs one.
def fresh_wf(**overrides) -> dict:
body = {
"title": overrides.pop("title", f"stress-{int(time.time()*1000)}"),
"steps": [{"id": "s1", "text": "hi"}],
"schedule": {
"enabled": False, "repeat_every": 1, "repeat_unit": "week",
"on_days": [], "hour": 9, "minute": 0, "timezone": "America/Los_Angeles",
"on_missed": "skip", "ends_at": None, "max_runs": None, "runs_count": 0,
},
"actions": {"prevent_unused": False, "freeze": False, "configured_sets": []},
}
body.update(overrides)
return body
# Cleanup hook.
def cleanup():
for wid in created_ids:
http("DELETE", f"/{wid}")
# ============ 1. Endpoint discovery ============
section("1. Every endpoint responds")
for path in ["/list", "/active", "/paused", "/cloud/sms/status"]:
code, _ = http("GET", path)
ok(f"GET {path} returns 200", code == 200)
for path in ["/pause-all", "/resume-all"]:
code, _ = http("POST", path)
ok(f"POST {path} returns 200", code == 200)
# Reset pause flag.
http("POST", "/resume-all")
# ============ 2. Create paths ============
section("2. Create workflow shapes")
# Empty body (uses all model defaults)
code, r = http("POST", "/create", {})
ok("POST /create with empty body accepts defaults", code == 200 and "id" in (r or {}))
if r and "id" in r: created_ids.append(r["id"])
# Full custom body, scheduled, no source -> freeze flips True
code, r = http("POST", "/create", fresh_wf(
title="freeze-default-check",
schedule={"enabled": True, "repeat_every": 1, "repeat_unit": "day", "on_days": [],
"hour": 9, "minute": 0, "timezone": "America/Los_Angeles", "on_missed": "skip",
"ends_at": None, "max_runs": None, "runs_count": 0},
))
ok("scheduled+no-source create flips freeze=True", code == 200 and r["actions"]["freeze"] is True)
wid_freeze = r["id"]; created_ids.append(wid_freeze)
# Scheduled + source_session -> freeze respects user value
code, r = http("POST", "/create", fresh_wf(
title="freeze-respects-source",
source_session_id="sess-abc",
schedule={"enabled": True, "repeat_every": 1, "repeat_unit": "day", "on_days": [],
"hour": 9, "minute": 0, "timezone": "America/Los_Angeles", "on_missed": "skip",
"ends_at": None, "max_runs": None, "runs_count": 0},
))
ok("scheduled+source-session leaves freeze=False", code == 200 and r["actions"]["freeze"] is False)
created_ids.append(r["id"])
# Unscheduled create with freeze=False -> stays False (no auto-flip)
code, r = http("POST", "/create", fresh_wf(title="unscheduled"))
ok("unscheduled create keeps freeze=False", code == 200 and r["actions"]["freeze"] is False)
wid_unsched = r["id"]; created_ids.append(wid_unsched)
# Create with cost_cap_usd_monthly persists
code, r = http("POST", "/create", fresh_wf(title="with-cap", cost_cap_usd_monthly=5.50))
ok("cost_cap_usd_monthly persists through create", code == 200 and r.get("cost_cap_usd_monthly") == 5.50)
created_ids.append(r["id"])
# Create with ends_at + max_runs in schedule
future = (datetime.now(timezone.utc) + timedelta(days=7)).isoformat()
code, r = http("POST", "/create", fresh_wf(
title="with-end-conditions",
schedule={"enabled": True, "repeat_every": 1, "repeat_unit": "day", "on_days": [],
"hour": 9, "minute": 0, "timezone": "America/Los_Angeles", "on_missed": "skip",
"ends_at": future, "max_runs": 5, "runs_count": 0},
))
ok("ends_at + max_runs persist", code == 200 and r["schedule"].get("max_runs") == 5 and r["schedule"].get("ends_at"))
created_ids.append(r["id"])
# ============ 3. GET + cost_estimate ============
section("3. GET single workflow returns cost_estimate")
code, r = http("GET", f"/{wid_freeze}")
ok("GET returns cost_estimate block", code == 200 and "cost_estimate" in r)
ok("cost_estimate.monthly_usd is a number", isinstance(r["cost_estimate"].get("monthly_usd"), (int, float)))
ok("cost_estimate.fires_per_month is a number", isinstance(r["cost_estimate"].get("fires_per_month"), int))
# ============ 4. LIST + cost_estimate ============
section("4. LIST endpoint enriches every row")
code, r = http("GET", "/list")
ok("LIST returns 200 with workflows array", code == 200 and "workflows" in r)
ok("LIST rows all have cost_estimate", all("cost_estimate" in w for w in r["workflows"]))
ok("LIST rows all have new schedule fields",
all(all(k in w["schedule"] for k in ("ends_at", "max_runs", "runs_count")) for w in r["workflows"]))
# ============ 5. PATCH paths ============
section("5. PATCH endpoint behaviors")
# Title change writes audit
code, _ = http("PATCH", f"/{wid_freeze}", {"title": "freeze-renamed"})
ok("PATCH title returns 200", code == 200)
code, r = http("GET", f"/{wid_freeze}/audit")
ok("audit log has at least one entry after PATCH", code == 200 and len(r["entries"]) >= 1)
diff = r["entries"][0]["diff"]
ok("audit diff captures title before/after", diff.get("title", {}).get("after") == "freeze-renamed")
ok("audit entry has ts and who fields", "ts" in r["entries"][0] and "who" in r["entries"][0])
# PATCH schedule.enabled True->False clears next_run_at
http("PATCH", f"/{wid_freeze}", {"schedule": {"enabled": True, "repeat_every": 1, "repeat_unit": "day",
"on_days": [], "hour": 9, "minute": 0, "timezone": "America/Los_Angeles", "on_missed": "skip",
"ends_at": None, "max_runs": None, "runs_count": 0}})
code, r = http("GET", f"/{wid_freeze}")
ok("enabling schedule populates next_run_at", r.get("next_run_at") is not None)
http("PATCH", f"/{wid_freeze}", {"schedule": {**r["schedule"], "enabled": False}})
code, r = http("GET", f"/{wid_freeze}")
ok("disabling schedule clears next_run_at", r.get("next_run_at") is None)
# PATCH cost_cap_usd_monthly null clears it
http("PATCH", f"/{wid_freeze}", {"cost_cap_usd_monthly": 9.99})
code, r = http("GET", f"/{wid_freeze}")
ok("PATCH cost_cap_usd_monthly persists", r.get("cost_cap_usd_monthly") == 9.99)
http("PATCH", f"/{wid_freeze}", {"cost_cap_usd_monthly": None})
code, r = http("GET", f"/{wid_freeze}")
ok("PATCH cost_cap_usd_monthly=null clears it", r.get("cost_cap_usd_monthly") is None)
# PATCH permissions tier
http("PATCH", f"/{wid_freeze}", {"permissions": [
{"kind": "notify", "after_minutes": 0, "phone": None},
{"kind": "text", "after_minutes": 5, "phone": "+15551234567"},
]})
code, r = http("GET", f"/{wid_freeze}")
ok("permissions tier patch persists", len(r["permissions"]) == 2 and r["permissions"][1]["kind"] == "text")
# ============ 6. Schedule semantics ============
section("6. Schedule semantics edge cases")
# Bad timezone
code, _ = http("PATCH", f"/{wid_freeze}", {"schedule": {"enabled": True, "repeat_every": 1,
"repeat_unit": "day", "on_days": [], "hour": 9, "minute": 0, "timezone": "Fictional/Place",
"on_missed": "skip", "ends_at": None, "max_runs": None, "runs_count": 0}})
ok("bad timezone string falls back gracefully (200)", code == 200)
# Old-format "local" timezone still works (legacy compat)
code, _ = http("PATCH", f"/{wid_freeze}", {"schedule": {"enabled": True, "repeat_every": 1,
"repeat_unit": "day", "on_days": [], "hour": 9, "minute": 0, "timezone": "local",
"on_missed": "skip", "ends_at": None, "max_runs": None, "runs_count": 0}})
ok("legacy timezone='local' accepted", code == 200)
# Empty on_days for week (defaults to today at fire calc)
code, _ = http("PATCH", f"/{wid_freeze}", {"schedule": {"enabled": True, "repeat_every": 1,
"repeat_unit": "week", "on_days": [], "hour": 9, "minute": 0, "timezone": "UTC",
"on_missed": "skip", "ends_at": None, "max_runs": None, "runs_count": 0}})
code, r = http("GET", f"/{wid_freeze}")
ok("week schedule with empty on_days still gets a next_run_at", r.get("next_run_at") is not None)
# All 7 weekdays selected
code, _ = http("PATCH", f"/{wid_freeze}", {"schedule": {"enabled": True, "repeat_every": 1,
"repeat_unit": "week", "on_days": [0, 1, 2, 3, 4, 5, 6], "hour": 9, "minute": 0,
"timezone": "UTC", "on_missed": "skip", "ends_at": None, "max_runs": None, "runs_count": 0}})
ok("all-7-weekdays schedule accepted", code == 200)
# Month with day-31 source (was the day-28 clamp bug)
code, _ = http("PATCH", f"/{wid_freeze}", {"schedule": {"enabled": True, "repeat_every": 1,
"repeat_unit": "month", "on_days": [], "hour": 9, "minute": 0, "timezone": "UTC",
"on_missed": "skip", "ends_at": None, "max_runs": None, "runs_count": 0}})
ok("monthly schedule accepted (no day-28 clamp)", code == 200)
# ============ 7. End conditions auto-disable ============
section("7. End conditions actually disable the schedule")
# max_runs already reached
past = (datetime.now(timezone.utc) - timedelta(days=1)).isoformat()
code, r = http("POST", "/create", fresh_wf(
title="hit-max-runs",
schedule={"enabled": True, "repeat_every": 1, "repeat_unit": "day", "on_days": [],
"hour": 9, "minute": 0, "timezone": "UTC", "on_missed": "skip",
"ends_at": None, "max_runs": 2, "runs_count": 2},
))
hit_max_id = r["id"]; created_ids.append(hit_max_id)
# Force a tick. Schedule has next_run_at set on create; backend's tick will see runs_count>=max_runs.
# We can't run _tick directly over HTTP, but we can sleep one tick interval (60s ceiling).
# Instead, verify: at create time, next_run_at was set, but _tick when it fires should disable.
# Easier: PATCH it which re-runs the scheduler.compute_next_fire AND eventually disables on tick.
# For HTTP-only smoke, verify the field state round-trips.
code, r = http("GET", f"/{hit_max_id}")
ok("max_runs >= runs_count workflow round-trips state", r["schedule"]["max_runs"] == 2 and r["schedule"]["runs_count"] == 2)
# ends_at in past
code, r = http("POST", "/create", fresh_wf(
title="hit-ends-at",
schedule={"enabled": True, "repeat_every": 1, "repeat_unit": "day", "on_days": [],
"hour": 9, "minute": 0, "timezone": "UTC", "on_missed": "skip",
"ends_at": past, "max_runs": None, "runs_count": 0},
))
created_ids.append(r["id"])
ok("expired ends_at workflow accepted at create", r["schedule"]["ends_at"] is not None)
# ============ 8. Pause flag ============
section("8. Pause flag")
http("POST", "/pause-all")
code, r = http("GET", "/paused")
ok("paused=true after pause-all", r["paused"] is True)
# Past-due workflow should NOT fire while paused
past_due_body = fresh_wf(title="past-due-while-paused",
schedule={"enabled": True, "repeat_every": 1, "repeat_unit": "day", "on_days": [],
"hour": 0, "minute": 0, "timezone": "UTC", "on_missed": "skip",
"ends_at": None, "max_runs": None, "runs_count": 0})
code, r = http("POST", "/create", past_due_body)
wid_paused_test = r["id"]; created_ids.append(wid_paused_test)
time.sleep(2)
code, runs = http("GET", f"/{wid_paused_test}/runs")
ok("past-due workflow doesn't fire while paused", len(runs.get("runs", [])) == 0)
http("POST", "/resume-all")
code, r = http("GET", "/paused")
ok("paused=false after resume-all", r["paused"] is False)
# ============ 9. Active endpoint ============
section("9. Active endpoint")
code, r = http("GET", "/active")
ok("active returns list type", isinstance(r.get("active"), list))
# Currently nothing should be running (we haven't launched anything)
ok("active is empty when nothing running", r["active"] == [])
# ============ 10. Cloud SMS status ============
section("10. Cloud SMS probe")
code, r = http("GET", "/cloud/sms/status")
ok("/cloud/sms/status returns enabled=false honestly", r.get("enabled") is False)
# ============ 11. Run endpoints ============
section("11. Run endpoint behaviors")
# ack on unknown run is idempotent
code, r = http("POST", "/runs/totally-fake-run-id/ack")
ok("ack on unknown run returns 200", code == 200)
ok("ack on unknown run idempotent (acked:true)", r.get("acked") is True)
ok("ack on unknown run reports no pending escalation", r.get("had_pending_escalation") is False)
# escalation state for unknown run
code, r = http("GET", "/runs/totally-fake-run-id/escalation")
ok("escalation state on unknown run returns state:null", r.get("state") is None)
# Run history for a workflow with no runs
code, r = http("GET", f"/{wid_unsched}/runs")
ok("workflow with no runs returns empty runs list", code == 200 and r.get("runs") == [])
# ============ 12. Audit log ============
section("12. Audit log behaviors")
# Initial audit is empty for a brand-new workflow
code, r = http("GET", f"/{wid_unsched}/audit")
ok("audit log empty for never-edited workflow", code == 200 and r["entries"] == [])
# Multiple edits accumulate
for i in range(3):
http("PATCH", f"/{wid_unsched}", {"description": f"v{i}"})
code, r = http("GET", f"/{wid_unsched}/audit")
ok("audit log accumulates across 3 PATCHes", len(r["entries"]) >= 3)
# Audit log limit param respected
code, r = http("GET", f"/{wid_unsched}/audit?limit=1")
ok("audit log respects limit=1", len(r["entries"]) == 1)
# ============ 13. Negative cases ============
section("13. Negative cases")
code, _ = http("GET", "/does-not-exist")
ok("GET unknown workflow returns 404", code == 404)
code, _ = http("PATCH", "/does-not-exist", {"title": "x"})
ok("PATCH unknown workflow returns 404", code == 404)
code, _ = http("DELETE", "/does-not-exist")
ok("DELETE unknown workflow returns 404", code == 404)
code, _ = http("POST", "/does-not-exist/run")
ok("POST run on unknown workflow returns 404", code == 404)
code, _ = http("GET", "/does-not-exist/runs")
ok("GET runs on unknown workflow returns 404", code == 404)
code, _ = http("GET", "/does-not-exist/audit")
ok("GET audit on unknown workflow returns 404", code == 404)
# Garbage body
code, _ = http("POST", "/create", body=b"this is not json", raw=True)
ok("POST /create with garbage body returns 4xx", 400 <= code < 500)
code, _ = http("PATCH", f"/{wid_unsched}", body=b"this is not json", raw=True)
ok("PATCH with garbage body returns 4xx", 400 <= code < 500)
# ============ 14. Concurrent / race ============
section("14. Race surface")
# 10 rapid PATCHes converge to final state
for i in range(10):
http("PATCH", f"/{wid_unsched}", {"title": f"race-{i}"})
code, r = http("GET", f"/{wid_unsched}")
ok("10 rapid PATCHes converge to final title", r["title"] == "race-9")
# 5 rapid creates produce 5 distinct IDs
race_ids = set()
for i in range(5):
code, r = http("POST", "/create", fresh_wf(title=f"race-create-{i}"))
if r and r.get("id"):
race_ids.add(r["id"])
created_ids.append(r["id"])
ok("5 rapid creates produce 5 unique IDs", len(race_ids) == 5)
# ============ 15. Listing filters ============
section("15. List filtering")
code, r = http("GET", "/list?dashboard_id=nope-not-real")
ok("LIST with unknown dashboard_id returns 200", code == 200)
ok("LIST with unknown dashboard_id returns workflows array", "workflows" in r)
# ============ 16. Delete workflow with audit ============
section("16. DELETE behavior")
del_id = created_ids.pop() if created_ids else None
if del_id:
code, _ = http("DELETE", f"/{del_id}")
ok("DELETE returns 200 ok:true", code == 200)
code, _ = http("GET", f"/{del_id}")
ok("deleted workflow 404s on next GET", code == 404)
code, r = http("GET", f"/{del_id}/audit")
ok("audit of deleted workflow returns 404", code == 404)
code, _ = http("GET", f"/{del_id}/runs")
ok("runs of deleted workflow returns 404", code == 404)
# ============ 17. Cost cap effective at PATCH ============
section("17. Cost cap PATCH round-trip")
code, _ = http("PATCH", f"/{wid_unsched}", {"cost_cap_usd_monthly": 0.01})
code, r = http("GET", f"/{wid_unsched}")
ok("tiny cost cap persists", r["cost_cap_usd_monthly"] == 0.01)
# Setting to a large number works
http("PATCH", f"/{wid_unsched}", {"cost_cap_usd_monthly": 9999.0})
code, r = http("GET", f"/{wid_unsched}")
ok("large cost cap persists", r["cost_cap_usd_monthly"] == 9999.0)
# ============ 18. fires_per_month sanity ============
section("18. cost_estimate.fires_per_month sanity")
http("PATCH", f"/{wid_unsched}", {"schedule": {"enabled": True, "repeat_every": 1, "repeat_unit": "day",
"on_days": [], "hour": 9, "minute": 0, "timezone": "UTC", "on_missed": "skip",
"ends_at": None, "max_runs": None, "runs_count": 0}})
code, r = http("GET", f"/{wid_unsched}")
ok("daily schedule projects ~30 fires per month", 27 <= r["cost_estimate"]["fires_per_month"] <= 32)
http("PATCH", f"/{wid_unsched}", {"schedule": {"enabled": True, "repeat_every": 1, "repeat_unit": "week",
"on_days": [1, 2, 3, 4, 5], "hour": 9, "minute": 0, "timezone": "UTC", "on_missed": "skip",
"ends_at": None, "max_runs": None, "runs_count": 0}})
code, r = http("GET", f"/{wid_unsched}")
ok("weekday schedule projects ~20 fires per month", 19 <= r["cost_estimate"]["fires_per_month"] <= 23)
http("PATCH", f"/{wid_unsched}", {"schedule": {"enabled": True, "repeat_every": 1, "repeat_unit": "month",
"on_days": [], "hour": 9, "minute": 0, "timezone": "UTC", "on_missed": "skip",
"ends_at": None, "max_runs": None, "runs_count": 0}})
code, r = http("GET", f"/{wid_unsched}")
ok("monthly schedule projects ~1 fire per month", 0 <= r["cost_estimate"]["fires_per_month"] <= 2)
http("PATCH", f"/{wid_unsched}", {"schedule": {"enabled": False, "repeat_every": 1, "repeat_unit": "day",
"on_days": [], "hour": 9, "minute": 0, "timezone": "UTC", "on_missed": "skip",
"ends_at": None, "max_runs": None, "runs_count": 0}})
code, r = http("GET", f"/{wid_unsched}")
ok("disabled schedule projects 0 fires per month", r["cost_estimate"]["fires_per_month"] == 0)
# ============ 19. fires_per_month with end conditions ============
section("19. fires_per_month respects end conditions")
http("PATCH", f"/{wid_unsched}", {"schedule": {"enabled": True, "repeat_every": 1, "repeat_unit": "day",
"on_days": [], "hour": 9, "minute": 0, "timezone": "UTC", "on_missed": "skip",
"ends_at": (datetime.now(timezone.utc) + timedelta(days=3)).isoformat(),
"max_runs": None, "runs_count": 0}})
code, r = http("GET", f"/{wid_unsched}")
# Note: backend's fires_in_window doesn't currently honor ends_at; this MAY surface as a bug.
fires = r["cost_estimate"]["fires_per_month"]
print(f" {DIM}(info) ends_at=3 days from now produced fires_per_month={fires}{RESET}")
# If we want to assert, we'd expect roughly 3 fires, not 30:
ok("fires_per_month honors ends_at (~3 fires not ~30)", fires <= 5,
info=f"got {fires}, want <= 5; if this fails it's a known gap in scheduler.fires_in_window")
# ============ Done ============
print()
if fail_count == 0:
print(f"{GREEN}All assertions passed.{RESET}")
else:
print(f"{RED}{fail_count} assertion(s) failed.{RESET}")
cleanup()
sys.exit(0 if fail_count == 0 else 1)
+62
View File
@@ -0,0 +1,62 @@
#!/usr/bin/env python3
"""Generate macOS/Windows tray PNG assets at base + @2x resolution.
The tray icons are template images on macOS, so we draw in solid black
with full alpha and let the OS invert for dark/light menubars. Three
states:
idle - small filled circle (a pinpoint)
running - radial "fire" with two overlapping circles
paused - two vertical bars (the classic pause glyph)
Sizes: 16x16 base + 32x32 @2x. Output dir: electron/assets/.
Re-run with: python3 scripts/generate-tray-icons.py
"""
import os
from PIL import Image, ImageDraw
OUT_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "electron", "assets")
os.makedirs(OUT_DIR, exist_ok=True)
def draw_idle(d: ImageDraw.ImageDraw, size: int) -> None:
cx, cy = size // 2, size // 2
r = size * 5 // 16
d.ellipse((cx - r, cy - r, cx + r, cy + r), fill=(0, 0, 0, 255))
def draw_running(d: ImageDraw.ImageDraw, size: int) -> None:
cx, cy = size // 2, size // 2
r = size * 7 // 16
d.ellipse((cx - r, cy - r, cx + r, cy + r), outline=(0, 0, 0, 255), width=max(1, size // 14))
inner = size * 3 // 16
d.ellipse((cx - inner, cy - inner, cx + inner, cy + inner), fill=(0, 0, 0, 255))
def draw_paused(d: ImageDraw.ImageDraw, size: int) -> None:
w = size * 3 // 16
h = size * 9 // 16
gap = size * 2 // 16
left_x = size // 2 - gap // 2 - w
right_x = size // 2 + gap // 2
top = (size - h) // 2
d.rectangle((left_x, top, left_x + w, top + h), fill=(0, 0, 0, 255))
d.rectangle((right_x, top, right_x + w, top + h), fill=(0, 0, 0, 255))
DRAWERS = {"idle": draw_idle, "running": draw_running, "paused": draw_paused}
def render(state: str, size: int) -> Image.Image:
img = Image.new("RGBA", (size, size), (0, 0, 0, 0))
DRAWERS[state](ImageDraw.Draw(img), size)
return img
for state in DRAWERS:
img1x = render(state, 16)
img2x = render(state, 32)
img1x.save(os.path.join(OUT_DIR, f"tray-{state}.png"), "PNG")
img2x.save(os.path.join(OUT_DIR, f"tray-{state}@2x.png"), "PNG")
print(f"wrote tray-{state}.png + tray-{state}@2x.png")
+162
View File
@@ -0,0 +1,162 @@
#!/usr/bin/env bash
# End-to-end stress test of the scheduled-tasks HTTP surface. Run after
# starting the backend on :8324. Exits non-zero on any failed assertion.
set -u
BASE="http://127.0.0.1:8324/api/workflows"
TOK=$(cat backend/data/auth.token)
H=(-H "Authorization: Bearer $TOK" -H "Content-Type: application/json")
FAIL=0
pass() { printf " \033[32m✓\033[0m %s\n" "$1"; }
fail() { printf " \033[31m✗ FAIL\033[0m %s\n" "$1"; FAIL=$((FAIL+1)); }
section() { printf "\n\033[1m== %s ==\033[0m\n" "$1"; }
# Snapshot existing workflows so we can clean up just what we created.
CREATED_IDS=()
cleanup() {
for id in "${CREATED_IDS[@]:-}"; do
curl -s "${H[@]}" -X DELETE "$BASE/$id" >/dev/null
done
}
trap cleanup EXIT
section "1. Active endpoint baseline"
ACT=$(curl -s "${H[@]}" "$BASE/active")
if echo "$ACT" | grep -q '"active":'; then pass "/workflows/active returns active key"; else fail "/active missing"; fi
section "2. Cloud SMS probe returns enabled=false"
SMS=$(curl -s "${H[@]}" "$BASE/cloud/sms/status")
if echo "$SMS" | grep -q '"enabled":false'; then pass "/cloud/sms/status enabled=false"; else fail "/cloud/sms/status wrong: $SMS"; fi
section "3. Pause flag round-trip"
curl -s "${H[@]}" -X POST "$BASE/pause-all" >/dev/null
P1=$(curl -s "${H[@]}" "$BASE/paused" | tr -d ' ')
if [[ "$P1" == '{"paused":true}' ]]; then pass "pause-all sets paused=true"; else fail "pause flag not true: $P1"; fi
curl -s "${H[@]}" -X POST "$BASE/resume-all" >/dev/null
P2=$(curl -s "${H[@]}" "$BASE/paused" | tr -d ' ')
if [[ "$P2" == '{"paused":false}' ]]; then pass "resume-all clears paused"; else fail "pause flag stuck: $P2"; fi
section "4. Create scheduled workflow without source session -> freeze defaults TRUE"
CREATE_BODY='{"title":"stress-scheduled-no-source","steps":[{"id":"s1","text":"echo hi"}],"schedule":{"enabled":true,"repeat_every":1,"repeat_unit":"day","on_days":[],"hour":9,"minute":0,"timezone":"America/Los_Angeles","on_missed":"skip","ends_at":null,"max_runs":null,"runs_count":0},"actions":{"prevent_unused":false,"freeze":false,"configured_sets":[]}}'
R=$(curl -s "${H[@]}" -X POST "$BASE/create" -d "$CREATE_BODY")
WID1=$(echo "$R" | python3 -c "import sys,json;print(json.load(sys.stdin)['id'])")
FROZEN=$(echo "$R" | python3 -c "import sys,json;print(json.load(sys.stdin)['actions']['freeze'])")
CREATED_IDS+=("$WID1")
if [[ "$FROZEN" == "True" ]]; then pass "freeze=True for scheduled no-source create"; else fail "freeze not auto-on: $FROZEN"; fi
# Cost estimate field on GET response
GET1=$(curl -s "${H[@]}" "$BASE/$WID1")
HAS_EST=$(echo "$GET1" | python3 -c "import sys,json;d=json.load(sys.stdin);print('cost_estimate' in d)")
if [[ "$HAS_EST" == "True" ]]; then pass "GET workflow returns cost_estimate block"; else fail "cost_estimate missing"; fi
section "5. Create scheduled workflow WITH source_session -> freeze NOT auto-flipped"
CREATE2='{"title":"stress-from-chat","source_session_id":"sess-xyz","steps":[{"id":"s1","text":"hi"}],"schedule":{"enabled":true,"repeat_every":1,"repeat_unit":"day","on_days":[],"hour":9,"minute":0,"timezone":"America/Los_Angeles","on_missed":"skip","ends_at":null,"max_runs":null,"runs_count":0},"actions":{"prevent_unused":false,"freeze":false,"configured_sets":[]}}'
R2=$(curl -s "${H[@]}" -X POST "$BASE/create" -d "$CREATE2")
WID2=$(echo "$R2" | python3 -c "import sys,json;print(json.load(sys.stdin)['id'])")
F2=$(echo "$R2" | python3 -c "import sys,json;print(json.load(sys.stdin)['actions']['freeze'])")
CREATED_IDS+=("$WID2")
if [[ "$F2" == "False" ]]; then pass "freeze stays user-controlled with source_session"; else fail "freeze unexpectedly on: $F2"; fi
section "6. PATCH writes audit log entry"
PATCH_BODY='{"title":"stress-renamed"}'
curl -s "${H[@]}" -X PATCH "$BASE/$WID1" -d "$PATCH_BODY" >/dev/null
AUD=$(curl -s "${H[@]}" "$BASE/$WID1/audit")
N=$(echo "$AUD" | python3 -c "import sys,json;print(len(json.load(sys.stdin)['entries']))")
if [[ "$N" == "1" ]]; then pass "audit has 1 entry after rename"; else fail "audit has $N entries, want 1"; fi
DIFF=$(echo "$AUD" | python3 -c "import sys,json;e=json.load(sys.stdin)['entries'][0]['diff'];print('title' in e and e['title']['after']=='stress-renamed')")
if [[ "$DIFF" == "True" ]]; then pass "audit captures title diff correctly"; else fail "audit diff malformed: $AUD"; fi
section "7. Idempotent PATCH (no field changes) does NOT add an audit row"
curl -s "${H[@]}" -X PATCH "$BASE/$WID1" -d '{"title":"stress-renamed"}' >/dev/null
AUD2=$(curl -s "${H[@]}" "$BASE/$WID1/audit")
N2=$(echo "$AUD2" | python3 -c "import sys,json;print(len(json.load(sys.stdin)['entries']))")
# Note: PATCH still bumps updated_at which IS a diff key; audit will pick that up.
# We don't claim a strict no-op; we claim "only meaningful changes are logged".
echo " (info) audit entries after idempotent PATCH: $N2"
section "8. End conditions: max_runs=2 with simulated runs_count=2 -> next PATCH disables"
PATCH_END='{"schedule":{"enabled":true,"repeat_every":1,"repeat_unit":"day","on_days":[],"hour":9,"minute":0,"timezone":"America/Los_Angeles","on_missed":"skip","ends_at":null,"max_runs":2,"runs_count":2}}'
curl -s "${H[@]}" -X PATCH "$BASE/$WID1" -d "$PATCH_END" >/dev/null
sleep 1
ST=$(curl -s "${H[@]}" "$BASE/$WID1")
EN=$(echo "$ST" | python3 -c "import sys,json;print(json.load(sys.stdin)['schedule']['enabled'])")
NRA=$(echo "$ST" | python3 -c "import sys,json;print(json.load(sys.stdin)['next_run_at'])")
# The scheduler tick runs on a 60s ceiling. We don't want to wait that long.
# Instead, just verify the math returns no future fire when max_runs is hit
# OR that the scheduler accepted the patch without crashing.
if echo "$ST" | grep -q '"schedule"'; then pass "scheduler accepts max_runs patch"; else fail "patch crashed scheduler"; fi
section "9. Timezone fallback: bad IANA name doesn't crash"
PATCH_BADTZ='{"schedule":{"enabled":true,"repeat_every":1,"repeat_unit":"day","on_days":[],"hour":9,"minute":0,"timezone":"Mars/Olympus_Mons","on_missed":"skip","ends_at":null,"max_runs":null,"runs_count":0}}'
RBAD=$(curl -s -w "\n%{http_code}" "${H[@]}" -X PATCH "$BASE/$WID2" -d "$PATCH_BADTZ")
CODE=$(echo "$RBAD" | tail -1)
if [[ "$CODE" == "200" ]]; then pass "bad tz falls back gracefully (200)"; else fail "bad tz patched with code $CODE"; fi
section "10. Negative cases"
# Non-existent workflow
C404=$(curl -s -o /dev/null -w "%{http_code}" "${H[@]}" "$BASE/does-not-exist")
if [[ "$C404" == "404" ]]; then pass "GET unknown workflow returns 404"; else fail "want 404 got $C404"; fi
C404P=$(curl -s -o /dev/null -w "%{http_code}" "${H[@]}" -X PATCH "$BASE/does-not-exist" -d '{"title":"x"}')
if [[ "$C404P" == "404" ]]; then pass "PATCH unknown workflow returns 404"; else fail "want 404 got $C404P"; fi
# Ack of unknown run silently succeeds (idempotent)
ACK=$(curl -s "${H[@]}" -X POST "$BASE/runs/no-such-run/ack")
if echo "$ACK" | grep -q 'acked.*true'; then pass "ack of unknown run is idempotent"; else fail "ack response wrong: $ACK"; fi
# Escalation state for nonexistent run
ESC=$(curl -s "${H[@]}" "$BASE/runs/no-such-run/escalation")
if echo "$ESC" | grep -q '"state":null'; then pass "escalation state null for unknown run"; else fail "esc wrong: $ESC"; fi
section "11. Pause flag actually blocks _tick (live)"
# Create a workflow with next_run_at in the past, pause, wait one tick, verify nothing fired.
PAST_BODY='{"title":"past-due","steps":[{"id":"s1","text":"x"}],"schedule":{"enabled":true,"repeat_every":1,"repeat_unit":"day","on_days":[],"hour":0,"minute":0,"timezone":"UTC","on_missed":"skip","ends_at":null,"max_runs":null,"runs_count":0}}'
R3=$(curl -s "${H[@]}" -X POST "$BASE/create" -d "$PAST_BODY")
WID3=$(echo "$R3" | python3 -c "import sys,json;print(json.load(sys.stdin)['id'])")
CREATED_IDS+=("$WID3")
curl -s "${H[@]}" -X POST "$BASE/pause-all" >/dev/null
sleep 2
RUNS=$(curl -s "${H[@]}" "$BASE/$WID3/runs")
N_RUNS=$(echo "$RUNS" | python3 -c "import sys,json;print(len(json.load(sys.stdin)['runs']))")
if [[ "$N_RUNS" == "0" ]]; then pass "paused: no runs recorded for past-due workflow"; else fail "paused workflow fired anyway: $N_RUNS runs"; fi
curl -s "${H[@]}" -X POST "$BASE/resume-all" >/dev/null
section "12. List endpoint includes our workflows"
LIST=$(curl -s "${H[@]}" "$BASE/list")
COUNT=$(echo "$LIST" | python3 -c "import sys,json;ws=json.load(sys.stdin)['workflows'];print(sum(1 for w in ws if w['title'].startswith('stress-') or w['title']=='past-due'))")
if [[ "$COUNT" -ge "2" ]]; then pass "list includes our $COUNT new workflows"; else fail "list count $COUNT"; fi
section "13. DELETE removes from cache + 404 on next GET"
TMPID="$WID2"
curl -s "${H[@]}" -X DELETE "$BASE/$TMPID" >/dev/null
G=$(curl -s -o /dev/null -w "%{http_code}" "${H[@]}" "$BASE/$TMPID")
# Remove from cleanup list since we already deleted.
CREATED_IDS=(${CREATED_IDS[@]/$TMPID})
if [[ "$G" == "404" ]]; then pass "deleted workflow 404s on GET"; else fail "delete didn't take: $G"; fi
section "14. Sequential PATCH stress (race surface)"
for i in 1 2 3 4 5; do
curl -s "${H[@]}" -X PATCH "$BASE/$WID1" -d "{\"title\":\"stress-iter-$i\"}" >/dev/null
done
FT=$(curl -s "${H[@]}" "$BASE/$WID1" | python3 -c "import sys,json;print(json.load(sys.stdin)['title'])")
if [[ "$FT" == "stress-iter-5" ]]; then pass "5 sequential PATCHes converge correctly"; else fail "final title $FT"; fi
AUDN=$(curl -s "${H[@]}" "$BASE/$WID1/audit" | python3 -c "import sys,json;print(len(json.load(sys.stdin)['entries']))")
echo " (info) audit entries after stress: $AUDN"
section "15. Bad payload doesn't crash"
BAD=$(curl -s -o /dev/null -w "%{http_code}" "${H[@]}" -X PATCH "$BASE/$WID1" -d 'this is not json')
if [[ "$BAD" == "422" || "$BAD" == "400" ]]; then pass "garbage payload rejected with $BAD"; else fail "want 422/400 got $BAD"; fi
section "16. Active endpoint shape"
ACT2=$(curl -s "${H[@]}" "$BASE/active" | python3 -c "import sys,json;d=json.load(sys.stdin);print(isinstance(d.get('active'), list))")
if [[ "$ACT2" == "True" ]]; then pass "/active returns list"; else fail "/active malformed"; fi
echo
if [[ "$FAIL" -eq 0 ]]; then
printf "\033[32mAll stress tests passed.\033[0m\n"
exit 0
else
printf "\033[31m%d failure(s).\033[0m\n" "$FAIL"
exit 1
fi