diff --git a/CHANGELOG.md b/CHANGELOG.md index ab92706..aaa72f9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,38 @@ All notable changes to Code Context Control (C3) are documented here. The format is loosely based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [2.156.0] - 2026-10-13 + +### Added — Scheduled tasks + +A schedule runs a Claude Code skill (`/daily_report weekly`), a free prompt or +a plain command in a project at a time or cadence, with the model, effort and +permission mode you choose, either autonomously or only after a person +approves each run. Everything is in `docs/scheduled-tasks.md`. + +- The Oracle hosts the loop (`oracle/services/scheduler.py`). Each tick + claims due slots in one locked transaction (`ScheduleStore.claim_due` + advances `next_run_at` as it writes the run row, so a slot is never claimed + twice by any process), routes each run through the misfire window, the + overlap rule, an optional `check_command` gate (exit 0 = nothing to do, no + LLM cost) and the autonomy gate, and fires it as a detached shell job + (`services/schedule_runner.py`): tree-kill on timeout, no console window, + output kept, cost and tokens read back from `claude -p --output-format json`. + A kernel-held `scheduler.lock` keeps a second Oracle in standby. +- Approval mode: a pending row in `services/schedule_approvals.py` lands in + the project's feed (`kind=schedule`) for the Hub, Desk and phone; decide is + human-only and a lapsed approval answers 409. Expire-and-skip after the TTL. +- Cadences: 5-field cron with names, steps and `@daily` aliases, presets + (hourly / daily / weekdays / weekly / monthly at HH:MM), `every` seconds, or + a one-shot `at`; IANA zones or `local` via `zoneinfo` (`tzdata` on Windows). +- Surfaces: `/api/schedules/*` on the Oracle, `/api/mobile/schedules*` + (capabilities `schedules` / `schedules_write`, API version 6), + `/api/hub/schedules*` and a Hub **Schedules** tab (create, edit, pause, run + now, approve, recent runs with cost), `c3_schedule` for agents (no decide), + `c3 schedule list|add|show|rm|enable|disable|run|runs|approvals|approve|deny`. +- Shell jobs: the supervisor accepts `argv`, `stdin_text` and `env_drop` + payload keys. + ## [2.155.0] - 2026-10-12 ### Fixed — the hub stays up, and a `/clear` keeps its C3 diff --git a/cli/c3.py b/cli/c3.py index 4d0df99..a40fb22 100644 --- a/cli/c3.py +++ b/cli/c3.py @@ -91,7 +91,7 @@ # Config CONFIG_DIR = ".c3" CONFIG_FILE = ".c3/config.json" -__version__ = "2.155.0" +__version__ = "2.156.0" # The PreToolUse matcher for native subagent calls (installer and hub migration). AGENT_MATCHER = "Agent|Task" @@ -264,6 +264,7 @@ def print_savings(savings: dict): "mcp__c3__c3_jira", "mcp__c3__c3_credentials", "mcp__c3__c3_project", "mcp__c3__c3_task", "mcp__c3__c3_artifacts", "mcp__c3__c3_override", "mcp__c3__c3_ci", "mcp__c3__c3_shell_job", + "mcp__c3__c3_schedule", ] # Obsolete MCP tool names from earlier C3 versions. `c3 permissions clean` @@ -7235,6 +7236,95 @@ def _enforce_audit(result: dict, project_path: str) -> None: _OVERRIDE_DEFAULT_TOOL = {"read": "Read", "write": "Edit"} +def cmd_schedule(args): + """`c3 schedule` — scheduled tasks: list, add, run, approve or deny (docs/scheduled-tasks.md).""" + import json as _json + + from cli.tools.schedule import build_target, build_when + from services.schedule_approvals import ApprovalError, ApprovalStore + from services.schedule_store import ScheduleError, ScheduleStore + + sub = getattr(args, "schedule_cmd", None) + if not sub: + print("Usage: c3 schedule ...") + return + store = ScheduleStore() + approvals = ApprovalStore() + try: + if sub == "list": + rows = store.list() + if not rows: + print("No schedules. Add one with `c3 schedule add`.") + for row in rows: + when = row.get("when") or {} + cadence = when.get("expr") or (f"every {when.get('seconds')}s" if when.get("type") == "every" + else when.get("when", "")) + flag = "on " if row.get("enabled") else "off" + print(f" {row['id']} [{flag}] {row['name']!r} {row['kind']} @ {cadence} {row['autonomy']} " + f"next={row.get('next_run_at') or '-'} last={row.get('last_status') or '-'} " + f"{row['project_path']}") + elif sub == "show": + row = store.get(args.schedule_id) + if row is None: + raise ScheduleError(f"no schedule {args.schedule_id}") + print(_json.dumps(row, indent=2, ensure_ascii=False)) + elif sub == "add": + from oracle.config import load_config + from oracle.services.scheduler import defaults_from_config + when = build_when(cron=args.cron, every=args.every, at=args.at, preset=args.preset, tz=args.tz, + preset_at=args.preset_at, days=args.days) + if when is None: + raise ScheduleError("give one of --cron, --every, --at or --preset") + fields = { + "name": args.name, "kind": args.kind, "target": build_target(args.kind, args.target), + "provider": args.provider, "model": args.model, "effort": args.effort, + "autonomy": args.autonomy, "project_path": str(Path(args.project_path).resolve()), + "when": when, "overlap": args.overlap, "check_command": args.check_command, + } + if args.timeout_s: + fields["timeout_s"] = args.timeout_s + if args.max_budget_usd: + fields["max_budget_usd"] = args.max_budget_usd + if args.permission_mode: + fields["permission_mode"] = args.permission_mode + row = store.create(fields, defaults=defaults_from_config(load_config()), created_by="cli") + print(f"Created {row['id']} {row['name']!r}; next run {row['next_run_at']}") + elif sub in ("enable", "disable"): + row = store.set_enabled(args.schedule_id, sub == "enable", by="cli") + print(f"{row['id']} {sub}d; next run {row.get('next_run_at') or '-'}") + elif sub == "rm": + if not store.delete(args.schedule_id, by="cli"): + raise ScheduleError(f"no schedule {args.schedule_id}") + print(f"Deleted {args.schedule_id}") + elif sub == "run": + if store.get(args.schedule_id) is None: + raise ScheduleError(f"no schedule {args.schedule_id}") + run = store.request_run(args.schedule_id, trigger="manual", by="cli", approved=args.bypass_approval) + print(f"Queued run {run['run_id']} ({run['status']}); the Oracle dispatches it on its next tick") + elif sub == "runs": + rows = store.runs(schedule_id=args.schedule_id or None, status=args.status or None, limit=args.limit) + if not rows: + print("No runs.") + for run in rows: + cost = f" ${run['cost_usd']:.3f}" if run.get("cost_usd") is not None else "" + err = f" — {str(run['error'])[:80]}" if run.get("error") else "" + print(f" {run['run_id']} {run['status']:<17} {run['trigger']:<8} {run['schedule_name']!r} " + f"{(run.get('claimed_at') or '')[:19]}{cost}{err}") + elif sub == "approvals": + rows = approvals.list(status=args.status or "pending") + if not rows: + print(f"No {args.status or 'pending'} approvals.") + for row in rows: + print(f" {row['id']} {row['status']:<8} {row['schedule_name']!r} {row['summary']} " + f"expires={row['expires_at']}") + elif sub in ("approve", "deny"): + row = approvals.decide(args.approval_id, sub, decided_by="cli", note=args.note or "") + print(f"{row['id']} {row['status']}; the Oracle acts on it on its next tick") + except (ScheduleError, ApprovalError) as exc: + print(f"Error: {exc}") + sys.exit(1) + + def cmd_override(args): """`c3 override` — Override Requests: allow one blocked call, once. @@ -9663,6 +9753,7 @@ def main(): "access": cmd_access, "enforce": cmd_enforce, "override": cmd_override, + "schedule": cmd_schedule, "locks": cmd_locks, "prune-quarantine": cmd_prune_quarantine, "ci": cmd_ci, diff --git a/cli/commands/parser.py b/cli/commands/parser.py index 281638f..9993aad 100644 --- a/cli/commands/parser.py +++ b/cli/commands/parser.py @@ -840,6 +840,56 @@ def _ci_common(sub): ov_costs.add_argument("--path", dest="project_path", default=".", help="Project directory (default: current)") + # ── Scheduled tasks (docs/scheduled-tasks.md) ───────────────────────── + # The human CLI over the same stores the Oracle fires from. approve/deny + # live here and on the Hub/Desk/phone, never on the c3_schedule agent tool. + p_schedule = subparsers.add_parser( + "schedule", help="Scheduled tasks — skills, prompts or commands the Oracle fires on a cadence") + sched_subs = p_schedule.add_subparsers(dest="schedule_cmd") + sched_subs.add_parser("list", help="Every schedule on this machine") + sc_show = sched_subs.add_parser("show", help="One schedule as JSON") + sc_show.add_argument("schedule_id") + sc_add = sched_subs.add_parser("add", help="Create a schedule") + sc_add.add_argument("name") + sc_add.add_argument("target", help="'/skill args', a prompt, or a command (see --kind)") + sc_add.add_argument("--kind", choices=["skill", "prompt", "command"], default="skill") + sc_add.add_argument("--cron", default="", help="5-field cron, e.g. '0 9 * * 1-5'") + sc_add.add_argument("--every", type=int, default=0, help="Seconds between runs") + sc_add.add_argument("--at", default="", help="One-shot ISO datetime") + sc_add.add_argument("--preset", choices=["hourly", "daily", "weekdays", "weekly", "monthly"], default="") + sc_add.add_argument("--preset-at", dest="preset_at", default="09:00", help="HH:MM for --preset") + sc_add.add_argument("--days", default="", help="Weekly preset days, e.g. mon,fri") + sc_add.add_argument("--tz", default="local", help="IANA zone or 'local'") + sc_add.add_argument("--provider", choices=["claude", "codex", "gemini", "grok"], default="claude") + sc_add.add_argument("--model", default="") + sc_add.add_argument("--effort", choices=["", "low", "medium", "high", "xhigh", "max"], default="") + sc_add.add_argument("--autonomy", choices=["autonomous", "approval"], default="approval") + sc_add.add_argument("--permission-mode", dest="permission_mode", default="") + sc_add.add_argument("--timeout", dest="timeout_s", type=int, default=0) + sc_add.add_argument("--budget", dest="max_budget_usd", type=float, default=0.0, help="--max-budget-usd cap") + sc_add.add_argument("--overlap", choices=["skip", "allow"], default="skip") + sc_add.add_argument("--check", dest="check_command", default="", + help="Command whose exit 1 arms the run; exit 0 skips it") + sc_add.add_argument("--path", dest="project_path", default=".", help="Project directory (default: current)") + for verb, help_text in (("enable", "Resume a schedule"), ("disable", "Pause a schedule"), + ("rm", "Delete a schedule")): + sp = sched_subs.add_parser(verb, help=help_text) + sp.add_argument("schedule_id") + sc_run = sched_subs.add_parser("run", help="Queue a run now") + sc_run.add_argument("schedule_id") + sc_run.add_argument("--bypass-approval", dest="bypass_approval", action="store_true", + help="Skip the approval gate for this one run") + sc_runs = sched_subs.add_parser("runs", help="Recent runs") + sc_runs.add_argument("--schedule", dest="schedule_id", default="") + sc_runs.add_argument("--status", default="") + sc_runs.add_argument("--limit", type=int, default=30) + sc_appr = sched_subs.add_parser("approvals", help="Pending (or --status) approvals") + sc_appr.add_argument("--status", default="") + for verb in ("approve", "deny"): + sp = sched_subs.add_parser(verb, help=f"{verb.capitalize()} one pending scheduled run") + sp.add_argument("approval_id") + sp.add_argument("--note", default="") + # ── Tool discipline / enforcement mode (v2.66.0) ──────────────────── # LAYER C: how hard C3 pushes the agent toward c3_* tools. Distinct from # `c3 access` (path policy — a security boundary) and from diff --git a/cli/guide/tools.html b/cli/guide/tools.html index d90a8fa..9a76dd7 100644 --- a/cli/guide/tools.html +++ b/cli/guide/tools.html @@ -107,6 +107,7 @@ 💥 c3_impact ⚡ c3_shell 🕐 c3_shell_job + 📅 c3_schedule 🧪 c3_ci + +
+
+ c3_schedule +
+ execute + v2.156.0 +
+ Scheduled tasks — a skill, prompt or command the Oracle fires on a cadence, autonomously or after a human approves each run +
+
+

A schedule names what to run (kind='skill' with target='/daily_report weekly', a free prompt, or a plain command with no LLM), where (a registered project), how (provider claude / codex / gemini / grok, model, effort, permission_mode, max_budget_usd) and when (cron, preset, every seconds, or a one-shot at). The Oracle's scheduler claims each due slot once, runs it as a detached shell job in the project, records exit, duration, cost and tokens, and writes a kind=schedule notification the Hub, Desk and phone show. autonomy='approval' (the default from an agent) creates a pending approval instead of firing; nobody approving before the TTL means the slot is logged expired and skipped. Deciding is human-only — the Hub Schedules tab, the Desk, the phone or c3 schedule approve. See docs/scheduled-tasks.md.

+ +

Actions

+ + + + + + + + + +
ActionGroupDescription
createWritename, kind, target, one cadence (cron + tz | preset + preset_at [+ days] | every | at), then provider, model, effort, autonomy, project, cwd, timeout, max_budget_usd, permission_mode, allowed_tools, check_command (exit 1 arms the run, exit 0 skips it for free), overlap, misfire_grace_s
update, enable, disable, deleteWriteschedule_id + changed fields; a disabled schedule keeps its history
run_nowWriteQueue one run outside the cadence; approval-mode schedules still ask
list, get, runs, approvals, statusReadSchedules, one row, recent runs ([schedule_id] [status] [limit]), pending approvals, the loop's state
previewReadA cadence → its next fire times, before you commit to it
+ +

Examples

+
# A weekday report, sonnet at medium effort, asking first
+c3_schedule(action='create', name='daily report', kind='skill',
+            target='/daily_report', preset='weekdays', preset_at='09:00',
+            model='sonnet', effort='medium', max_budget_usd=1.0)
+
+# A free command every hour, no LLM, fires on its own
+c3_schedule(action='create', name='fetch', kind='command',
+            target='git fetch --all --prune', every=3600, autonomy='autonomous')
+
+# See what a cron would do, then what ran
+c3_schedule(action='preview', cron='0 18 * * 1-5', tz='Europe/Berlin')
+c3_schedule(action='runs', limit=10)
+ +

Surfaces: the Hub's Schedules tab (create, edit, pause, run now, approve or deny), /api/mobile/schedules for the Desk and the phone, and c3 schedule on the command line.

+
+
+
diff --git a/cli/hub_server.py b/cli/hub_server.py index 3f0bd17..7f744d0 100644 --- a/cli/hub_server.py +++ b/cli/hub_server.py @@ -83,7 +83,7 @@ def _hub_allowed_hosts(): # Top-bar tabs a persisted ``main_view`` may name (cli/hub_ui/components/topbar.js). _MAIN_VIEWS = {"projects", "board", "ci", "creds", "tokens", "locks", "access", - "enforce", "sessions"} + "enforce", "sessions", "schedules"} # Stock command per IDE id, and whether it takes the project path as an # argument (GUI) instead of being run inside a terminal at that path. @@ -400,6 +400,7 @@ def _win_find_ide(cmd: str) -> str: "hub_ui/components/hub_enforcement.js", "hub_ui/components/hub_sessions.js", "hub_ui/components/hub_ci.js", + "hub_ui/components/hub_schedules.js", "hub_ui/components/drill_subprojects.js", "hub_ui/components/drill_health.js", "hub_ui/components/drill_tasks.js", diff --git a/cli/hub_ui/app.js b/cli/hub_ui/app.js index 5a869ad..d41369d 100644 --- a/cli/hub_ui/app.js +++ b/cli/hub_ui/app.js @@ -44,7 +44,7 @@ function App() { if (cfg.projects_view === 'grid') setView('grid'); if (cfg.sidebar_collapsed != null) setSidebarCollapsed(!!cfg.sidebar_collapsed); if (cfg.sidebar_group) setFilter(cfg.sidebar_group); - if (['board', 'ci', 'creds', 'tokens', 'locks', 'access', 'enforce', 'sessions'].includes(cfg.main_view)) setMainView(cfg.main_view); + if (['board', 'ci', 'creds', 'tokens', 'locks', 'access', 'enforce', 'sessions', 'schedules'].includes(cfg.main_view)) setMainView(cfg.main_view); } catch { } try { const v = await api.get('/api/version'); setVersion(v.c3_version || ''); } catch { } }, []); @@ -129,6 +129,8 @@ function App() { ) : mainView === 'sessions' ? ( + ) : mainView === 'schedules' ? ( + ) : ( = 3600 ? `${Math.round(when.seconds / 360) / 10}h` : `${Math.round(when.seconds / 60)}m`}`; + if (when.type === 'at') return `once at ${String(when.when || '').slice(0, 16)}`; + if (when.type === 'preset') return `${when.preset} ${when.at || ''}${when.days && when.days.length ? ' ' + when.days.join(',') : ''}`; + return when.expr || '—'; +} + +function schedRelative(iso) { + if (!iso) return '—'; + const diff = (new Date(iso).getTime() - Date.now()) / 1000; + const abs = Math.abs(diff); + const text = abs < 90 ? `${Math.round(abs)}s` : abs < 5400 ? `${Math.round(abs / 60)}m` + : abs < 172800 ? `${Math.round(abs / 360) / 10}h` : `${Math.round(abs / 86400)}d`; + return diff >= 0 ? `in ${text}` : `${text} ago`; +} + +function schedTarget(row) { + const t = row.target || {}; + if (row.kind === 'skill') return `${t.skill || ''} ${t.args || ''}`.trim(); + if (row.kind === 'prompt') return t.prompt || ''; + return t.cmd || ''; +} + +const SCHED_STATUS_COLOR = { + done: 'accent', running: 'blue', dispatched: 'blue', check_running: 'blue', claimed: 'blue', + awaiting_approval: 'warn', approved: 'blue', failed: 'error', timeout: 'error', lost: 'error', + denied: 'warn', expired: 'warn', missed: 'warn', skipped_overlap: 'textDim', check_skipped: 'textDim', + cancelled: 'textDim', +}; + +function SchedStatusPill({ status }) { + const key = SCHED_STATUS_COLOR[status] || 'textMuted'; + return ( + {status || '—'} + ); +} + +function ScheduleModal({ row, projects, onClose, onSaved }) { + const { useState, useEffect } = React; + const editing = !!(row && row.id); + const w = (row && row.when) || {}; + const [f, setF] = useState({ + name: row ? row.name : '', + project_path: row ? row.project_path : ((projects[0] || {}).path || ''), + kind: row ? row.kind : 'skill', + target: row ? schedTarget(row) : '', + cadence: w.type || 'preset', + preset: w.preset || 'weekdays', preset_at: w.at || '09:00', days: (w.days || []).join(','), + cron: w.type === 'cron' ? w.expr : '', every: w.seconds ? Math.round(w.seconds / 60) : 60, + at: w.type === 'at' ? w.when : '', tz: w.tz || 'local', + provider: row ? row.provider : 'claude', model: row ? row.model : '', effort: row ? row.effort : '', + permission_mode: row ? row.permission_mode : 'acceptEdits', + autonomy: row ? row.autonomy : 'approval', + max_budget_usd: row ? row.max_budget_usd : 0, timeout_s: row ? row.timeout_s : 1800, + overlap: row ? row.overlap : 'skip', + grace_s: row && row.misfire ? row.misfire.grace_s : 900, + check_command: row ? row.check_command : '', + allowed_tools: row ? row.allowed_tools : '', + }); + const [preview, setPreview] = useState([]); + const [err, setErr] = useState(''); + const [saving, setSaving] = useState(false); + const set = (k) => (e) => setF({ ...f, [k]: e && e.target ? (e.target.type === 'checkbox' ? e.target.checked : e.target.value) : e }); + + const when = () => { + if (f.cadence === 'cron') return { type: 'cron', expr: f.cron, tz: f.tz }; + if (f.cadence === 'every') return { type: 'every', seconds: Math.max(1, Number(f.every) || 0) * 60 }; + if (f.cadence === 'at') return { type: 'at', when: f.at }; + return { type: 'preset', preset: f.preset, at: f.preset_at, days: f.days.split(',').map(s => s.trim()).filter(Boolean), tz: f.tz }; + }; + + useEffect(() => { + let alive = true; + api.post('/api/hub/schedules/preview', { when: when(), count: 3 }) + .then(d => { if (alive) { setPreview(d.next || []); setErr(''); } }) + .catch(e => { if (alive) { setPreview([]); setErr(String(e && e.message ? e.message : e)); } }); + return () => { alive = false; }; + }, [f.cadence, f.preset, f.preset_at, f.days, f.cron, f.every, f.at, f.tz]); + + const body = () => { + const target = f.kind === 'command' ? { cmd: f.target } : f.kind === 'prompt' ? { prompt: f.target } + : { skill: f.target.split(' ')[0], args: f.target.split(' ').slice(1).join(' ') }; + return { + name: f.name, project: f.project_path, kind: f.kind, target, when: when(), + provider: f.provider, model: f.model, effort: f.effort, permission_mode: f.permission_mode, + autonomy: f.autonomy, max_budget_usd: Number(f.max_budget_usd) || 0, timeout_s: Number(f.timeout_s) || 1800, + overlap: f.overlap, misfire: { policy: 'grace', grace_s: Number(f.grace_s) || 0 }, + check_command: f.check_command, allowed_tools: f.allowed_tools, + }; + }; + + const save = async () => { + setSaving(true); + try { + if (editing) await api.patch(`/api/hub/schedules/${row.id}`, body()); + else await api.post('/api/hub/schedules', body()); + onSaved(); + } catch (e) { + setErr(String(e && e.message ? e.message : e)); + setSaving(false); + } + }; + + const llm = f.kind !== 'command'; + const sel = (k, opts, labels) => ( + + ); + const two = { display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 10 }; + + return ( + +
+
Name
+
Project + +
+
+
+
Kind{sel('kind', ['skill', 'prompt', 'command'])}
+
{f.kind === 'skill' ? 'Skill and args' : f.kind === 'prompt' ? 'Prompt' : 'Command'} + +
+
+ + When +
+ {sel('cadence', ['preset', 'cron', 'every', 'at'], ['Preset', 'Cron', 'Every N min', 'Once at'])} + {f.cadence === 'preset' && sel('preset', SCHED_PRESETS)} + {f.cadence === 'preset' && } + {f.cadence === 'cron' && } + {f.cadence === 'every' && } + {f.cadence === 'every' && minutes} + {f.cadence === 'at' && } +
+ {f.cadence === 'preset' && f.preset === 'weekly' && ( + + )} + {(f.cadence === 'preset' || f.cadence === 'cron') && ( + + )} +
+ {preview.length ? 'next: ' + preview.map(p => p.replace('T', ' ').slice(0, 16)).join(' · ') : (err || 'no upcoming fire')} +
+ + {llm && ( +
+
Provider{sel('provider', SCHED_PROVIDERS)}
+
Model
+
Effort{sel('effort', SCHED_EFFORTS)}
+
+ )} +
+
Autonomy{sel('autonomy', ['approval', 'autonomous'], ['Ask me first', 'Autonomous'])}
+ {llm &&
Permission mode{sel('permission_mode', SCHED_MODES)}
} + {llm &&
Budget (USD, 0 = none)
} +
+
+
Timeout (s)
+
Overlap{sel('overlap', ['skip', 'allow'], ['Skip if still running', 'Allow'])}
+
Misfire grace (s)
+
+ Check command (optional — exit 1 arms the run, exit 0 skips it) + + {llm && f.provider === 'claude' && ( +
+ Allowed tools (optional) + +
+ )} + {err && preview.length > 0 &&
{err}
} + + + + +
+ ); +} + +function HubSchedules({ projects, onOpenDrill }) { + const { useState, useCallback } = React; + const [rows, setRows] = useState(null); + const [runs, setRuns] = useState([]); + const [approvals, setApprovals] = useState([]); + const [status, setStatus] = useState(null); + const [err, setErr] = useState(''); + const [modal, setModal] = useState(null); + const [confirm, setConfirm] = useState(null); + + const load = useCallback(async () => { + try { + const [s, r, a, st] = await Promise.all([ + api.get('/api/hub/schedules'), api.get('/api/hub/schedules/runs?limit=30'), + api.get('/api/hub/schedules/approvals?status=pending'), api.get('/api/hub/schedules/status'), + ]); + setRows(s.schedules || []); setRuns(r.runs || []); setApprovals(a.approvals || []); setStatus(st); + setErr(''); + } catch (e) { + setErr(String(e && e.message ? e.message : e)); + } + }, []); + React.useEffect(() => { load(); }, [load]); + usePoll(load, 5000); + + const act = async (fn) => { + try { await fn(); } catch (e) { setErr(String(e && e.message ? e.message : e)); } + load(); + }; + const decide = (id, decision) => act(() => api.post(`/api/hub/schedules/approvals/${id}`, { decision })); + const toggle = (row) => act(() => api.post(`/api/hub/schedules/${row.id}/${row.enabled ? 'disable' : 'enable'}`, {})); + const runNow = (row) => act(() => api.post(`/api/hub/schedules/${row.id}/run`, {})); + const remove = (row) => act(() => api.del(`/api/hub/schedules/${row.id}`)); + + const sched = status || {}; + const schedState = !status ? 'loading' : status.reachable === false ? 'oracle unreachable' + : sched.standby ? 'standby' : sched.running ? 'running' : 'stopped'; + const schedColor = schedState === 'running' ? T.accent : schedState === 'loading' ? T.textMuted : T.warn; + const grid = 'minmax(0,1.6fr) minmax(0,1fr) minmax(0,1.8fr) minmax(0,1.3fr) minmax(0,1fr) 90px 150px'; + const btn = (label, onClick, color, title) => ( + {label} + ); + + return ( +
+
+ + Scheduled tasks + scheduler {schedState}{sched.next_due ? ` · next ${schedRelative(sched.next_due)}` : ''} + + {rows ? `${rows.length} schedule(s)` : 'loading…'} + +
+ +
+ +
+
+ + {err && ( +
+ {err} +
+ )} + + {approvals.length > 0 && ( +
+
Waiting for your approval
+ {approvals.map(a => ( +
+ {a.schedule_name} + {a.summary} + expires {schedRelative(a.expires_at)} + + +
+ ))} +
+ )} + + {rows && rows.length === 0 && ( +
+ No scheduled tasks yet. Create one here, or from a session with c3_schedule(action='create', …). +
+ )} + + {rows && rows.length > 0 && ( +
+
+ NAMEPROJECTRUNSWHEN · NEXTMODELLAST +
+ {rows.map(row => ( +
+ + {row.name} + + {row.autonomy === 'autonomous' ? 'auto' : 'ask'} + + + onOpenDrill && onOpenDrill(projects.find(p => p.path === row.project_path) || { path: row.project_path, name: row.project_name })} + style={{ color: T.textMuted, cursor: onOpenDrill ? 'pointer' : 'default', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}> + {row.project_name || row.project_path} + + + {row.kind === 'command' ? '$ ' : ''}{schedTarget(row)} + + + {schedWhenLabel(row.when)} · {row.enabled ? schedRelative(row.next_run_at) : 'paused'} + + + {row.kind === 'command' ? '—' : `${row.provider}${row.model ? '/' + row.model : ''}${row.effort ? ' ' + row.effort : ''}`} + + + + {btn('Run', () => runNow(row), T.accent, 'Queue a run now')} + {btn(row.enabled ? 'Pause' : 'Resume', () => toggle(row))} + {btn('Edit', () => setModal(row))} + {btn('Delete', () => setConfirm(row), T.error)} + +
+ ))} +
+ )} + + {runs.length > 0 && ( +
+
Recent runs
+ {runs.map(run => ( +
+ + {run.schedule_name} + {schedRelative(run.claimed_at)} · {run.trigger} + {run.duration_ms != null ? `${(run.duration_ms / 1000).toFixed(1)}s` : ''} + {run.cost_usd != null ? `$${Number(run.cost_usd).toFixed(3)}` : ''} + + {run.error || run.result_excerpt || ''} + +
+ ))} +
+ )} + + {modal && ( + setModal(null)} + onSaved={() => { setModal(null); load(); }} /> + )} + {confirm && ( + { remove(confirm); setConfirm(null); }} onCancel={() => setConfirm(null)} /> + )} +
+ ); +} diff --git a/cli/hub_ui/components/topbar.js b/cli/hub_ui/components/topbar.js index 606d894..c37f81c 100644 --- a/cli/hub_ui/components/topbar.js +++ b/cli/hub_ui/components/topbar.js @@ -38,7 +38,7 @@ function TopBar({ version, activeCount, darkMode, hubConfig, mainView, setMainVi display: 'inline-flex', marginLeft: 10, border: `1px solid ${T.border}`, borderRadius: 6, overflow: 'hidden', }}> - {[['projects', 'Projects', 'layers'], ['board', 'Tasks', 'check'], ['ci', 'CI', 'play'], ['creds', 'Credentials', 'lock'], ['tokens', 'Tokens', 'gauge'], ['locks', 'Locks', 'gitBranch'], ['access', 'Access', 'eye'], ['enforce', 'Discipline', 'shield'], ['sessions', 'Sessions', 'messageSquare']].map(([id, label, icon]) => ( + {[['projects', 'Projects', 'layers'], ['board', 'Tasks', 'check'], ['ci', 'CI', 'play'], ['creds', 'Credentials', 'lock'], ['tokens', 'Tokens', 'gauge'], ['locks', 'Locks', 'gitBranch'], ['access', 'Access', 'eye'], ['enforce', 'Discipline', 'shield'], ['sessions', 'Sessions', 'messageSquare'], ['schedules', 'Schedules', 'clock']].map(([id, label, icon]) => (