mirror of
https://github.com/0xWheatyz/handler.git
synced 2026-08-30 16:06:24 +00:00
44632ea771
schedules.model_id (migration 0013) pins every fired run of a recurring spawn to a registered model backend, exactly like a hand-spawned agent: the worker copies it into each firing's spawn payload, the launched agent records the pin, and resumes stay on the same backend. The Schedules form gets the same Model dropdown as the spawn form (Claude subscription by default), with a badge in the schedules table. Create/update routes fail fast on a missing or disabled backend so a stale selection bounces immediately instead of every firing failing asynchronously in Activity; a backend deleted later still fails each firing visibly rather than silently falling back to the subscription. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DzDofD7gP63WpeLG8vEdZu
225 lines
8.3 KiB
TypeScript
225 lines
8.3 KiB
TypeScript
/* Schedules — recurring agent spawns. Every interval the worker starts a fresh,
|
|
* stateless agent named <prefix>-<timestamp> with the stored prompt. The canonical
|
|
* pattern: keep state in a file in the repo and have the prompt continue from it. */
|
|
"use client";
|
|
|
|
import { useMemo, useState } from "react";
|
|
import { useDashboard } from "@/components/store";
|
|
import { Badge, Button, Card, Input, Select, Textarea, Toggle } from "@/components/ui";
|
|
import { fmtFull } from "@/lib/format";
|
|
|
|
const ROLE_OPTS = [
|
|
{ value: "", label: "Role — none" },
|
|
{ value: "junior", label: "junior" },
|
|
{ value: "senior", label: "senior" },
|
|
{ value: "deploy", label: "deploy" },
|
|
];
|
|
|
|
const INTERVAL_OPTS = [
|
|
{ value: "900", label: "every 15 minutes" },
|
|
{ value: "1800", label: "every 30 minutes" },
|
|
{ value: "3600", label: "every hour" },
|
|
{ value: "21600", label: "every 6 hours" },
|
|
{ value: "86400", label: "every day" },
|
|
{ value: "604800", label: "every week" },
|
|
];
|
|
|
|
const TASK_PLACEHOLDER =
|
|
"Read @notes.md and continue from where it left off. Before finishing, overwrite " +
|
|
"@notes.md with the current state so the next run can pick up from there.";
|
|
|
|
function intervalLabel(seconds: number): string {
|
|
const opt = INTERVAL_OPTS.find((o) => Number(o.value) === seconds);
|
|
if (opt) return opt.label;
|
|
if (seconds % 3600 === 0) return `every ${seconds / 3600}h`;
|
|
if (seconds % 60 === 0) return `every ${seconds / 60}m`;
|
|
return `every ${seconds}s`;
|
|
}
|
|
|
|
const emptyForm = { name_prefix: "", task: "", interval: "3600", role: "", model_id: "" };
|
|
|
|
export function SchedulesSection() {
|
|
const s = useDashboard();
|
|
const [form, setForm] = useState(emptyForm);
|
|
|
|
const projectOpts = useMemo(
|
|
() => s.projects.map((p) => ({ value: p.id, label: p.id })),
|
|
[s.projects],
|
|
);
|
|
/* Same choice the spawn form offers: Claude subscription by default, plus every
|
|
* enabled backend from the Claude page's Models tab. Every fired run spawns on it. */
|
|
const modelOpts = useMemo(
|
|
() => [
|
|
{ value: "", label: "Claude (subscription)" },
|
|
...s.claudeModels
|
|
.filter((m) => m.enabled)
|
|
.map((m) => ({ value: String(m.id), label: `${m.name} (${m.model})` })),
|
|
],
|
|
[s.claudeModels],
|
|
);
|
|
const modelName = useMemo(() => {
|
|
const byId = new Map(s.claudeModels.map((m) => [m.id, m.name]));
|
|
return (id: number | null | undefined) => (id == null ? null : byId.get(id) ?? `#${id}`);
|
|
}, [s.claudeModels]);
|
|
|
|
const create = async () => {
|
|
const ok = await s.createSchedule(s.selectedProjectId, {
|
|
name_prefix: form.name_prefix,
|
|
task: form.task,
|
|
interval_seconds: Number(form.interval),
|
|
role: form.role,
|
|
model_id: form.model_id,
|
|
});
|
|
if (ok) setForm(emptyForm);
|
|
};
|
|
|
|
return (
|
|
<>
|
|
<div className="section-head">
|
|
<div className="section-title">Schedules</div>
|
|
<div className="section-desc">
|
|
Spawn a fresh agent on an interval. Each run is stateless — keep continuity in a
|
|
file the prompt reads and overwrites.
|
|
</div>
|
|
</div>
|
|
<div className="section-body">
|
|
{s.projects.length === 0 ? (
|
|
<div className="empty">Register a repository first.</div>
|
|
) : (
|
|
<>
|
|
<div className="row">
|
|
<div style={{ width: 260 }}>
|
|
<Select
|
|
label="Repository"
|
|
value={s.selectedProjectId}
|
|
onChange={s.selectProject}
|
|
options={projectOpts}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<Card>
|
|
<div className="card-head" style={{ marginBottom: 14 }}>
|
|
<span className="card-title" style={{ fontSize: "var(--text-md)", color: "var(--text-heading)" }}>
|
|
New schedule
|
|
</span>
|
|
</div>
|
|
<div className="form-grid">
|
|
<Input
|
|
label="Name prefix"
|
|
value={form.name_prefix}
|
|
onChange={(v) => setForm({ ...form, name_prefix: v })}
|
|
placeholder="nightly"
|
|
/>
|
|
<Select
|
|
label="Interval"
|
|
value={form.interval}
|
|
onChange={(v) => setForm({ ...form, interval: v })}
|
|
options={INTERVAL_OPTS}
|
|
/>
|
|
<Select
|
|
label="Role"
|
|
value={form.role}
|
|
onChange={(v) => setForm({ ...form, role: v })}
|
|
options={ROLE_OPTS}
|
|
/>
|
|
<Select
|
|
label="Model"
|
|
value={form.model_id}
|
|
onChange={(v) => setForm({ ...form, model_id: v })}
|
|
options={modelOpts}
|
|
/>
|
|
</div>
|
|
<div className="mt14">
|
|
<Textarea
|
|
label="Prompt (the task every run starts with)"
|
|
value={form.task}
|
|
onChange={(v) => setForm({ ...form, task: v })}
|
|
rows={3}
|
|
placeholder={TASK_PLACEHOLDER}
|
|
/>
|
|
</div>
|
|
<p className="faint" style={{ fontSize: "var(--text-xs)", margin: "10px 0 0" }}>
|
|
Runs are named <span className="mono">{form.name_prefix.trim() || "prefix"}-YYYYMMDD-HHMMSS</span>.
|
|
The repo is pulled before every run; the first run fires on the worker's next
|
|
pass.
|
|
</p>
|
|
<div className="hstack mt14">
|
|
<Button
|
|
variant="primary"
|
|
disabled={s.cmd.busy || !form.name_prefix.trim() || !form.task.trim()}
|
|
onClick={create}
|
|
>
|
|
Create schedule
|
|
</Button>
|
|
</div>
|
|
</Card>
|
|
|
|
{s.schedules.length === 0 ? (
|
|
<div className="empty">No schedules yet.</div>
|
|
) : (
|
|
<div className="table-wrap">
|
|
<table className="tbl">
|
|
<thead>
|
|
<tr>
|
|
<th>On</th>
|
|
<th>Name</th>
|
|
<th>Repository</th>
|
|
<th>Interval</th>
|
|
<th>Prompt</th>
|
|
<th>Next run</th>
|
|
<th>Last run</th>
|
|
<th />
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{s.schedules.map((sc) => (
|
|
<tr key={sc.id}>
|
|
<td>
|
|
<Toggle
|
|
on={sc.enabled}
|
|
onClick={() => s.updateSchedule(sc.id, { enabled: !sc.enabled })}
|
|
/>
|
|
</td>
|
|
<td className="mono">
|
|
{sc.name_prefix}
|
|
{sc.role ? (
|
|
<>
|
|
{" "}
|
|
<Badge tone="info">{sc.role}</Badge>
|
|
</>
|
|
) : null}
|
|
{sc.model_id != null ? (
|
|
<>
|
|
{" "}
|
|
<Badge tone="warning">{modelName(sc.model_id)}</Badge>
|
|
</>
|
|
) : null}
|
|
</td>
|
|
<td className="mono faint">{sc.project_id}</td>
|
|
<td className="nowrap">{intervalLabel(sc.interval_seconds)}</td>
|
|
<td className="faint" style={{ maxWidth: 340 }}>
|
|
<span className="truncate" style={{ display: "block" }} title={sc.task}>
|
|
{sc.task}
|
|
</span>
|
|
</td>
|
|
<td className="faint nowrap">{sc.enabled ? fmtFull(sc.next_run_at) : "paused"}</td>
|
|
<td className="faint nowrap">{fmtFull(sc.last_run_at)}</td>
|
|
<td className="nowrap">
|
|
<Button size="sm" variant="danger" onClick={() => s.deleteSchedule(sc.id)}>
|
|
Delete
|
|
</Button>
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
)}
|
|
</>
|
|
)}
|
|
</div>
|
|
</>
|
|
);
|
|
}
|