Automations
- Job files hold only the instructions; name, schedule, mode, model, approval
mode and time limit are settings in the database (jobs.settings). Existing
files are adopted on start-up (frontmatter into the database and removed from
the file); plain Markdown files registered later get default settings and stay
switched off until scheduled.
- New automation: name, project and a starting point (blank example structure or
a template) create .codecraft/jobs/<name>.md and open the editor: settings with
a graphical cron builder (next runs in server time via /jobs/schedule), the
instructions in the Markdown editor and an assistant chat
(POST /jobs/{id}/assistant) whose model edits the file; the editor reloads it
after each turn and warns about conflicts with unsaved edits.
- The schedule tool writes prompt-only files with settings.
Chat list
- Folders and projects keep a user-defined order and can be pinned (drag and
drop, or the group's menu), stored per user via /account/sidebar. A new
message moves the chat to the top of its group, no longer the project itself.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
218 lines
8.2 KiB
TypeScript
218 lines
8.2 KiB
TypeScript
import { useEffect, useState } from "react";
|
||
import { get } from "../api";
|
||
import { getLang, useT } from "../i18n";
|
||
|
||
/**
|
||
* Graphical editor for a 5-field cron expression (minute hour day month weekday,
|
||
* server time zone): every N minutes, hourly, daily, on weekdays, monthly – or a
|
||
* custom expression. Shows the next runs as computed by the server.
|
||
*/
|
||
type Mode = "minutes" | "hourly" | "daily" | "weekly" | "monthly" | "custom";
|
||
|
||
interface State {
|
||
mode: Mode;
|
||
every: number;
|
||
minute: number;
|
||
time: string; // HH:MM
|
||
days: number[]; // cron weekdays, 0 = Sunday
|
||
dom: number;
|
||
custom: string;
|
||
}
|
||
|
||
const MINUTE_STEPS = [5, 10, 15, 20, 30];
|
||
/** Monday first; cron numbers (0 = Sunday). */
|
||
const WEEK = [1, 2, 3, 4, 5, 6, 0];
|
||
|
||
const pad = (n: number) => String(n).padStart(2, "0");
|
||
const hhmm = (h: string, m: string) => `${pad(Number(h))}:${pad(Number(m))}`;
|
||
|
||
function expandDays(field: string): number[] | null {
|
||
const out = new Set<number>();
|
||
for (const part of field.split(",")) {
|
||
const m = part.match(/^(\d)(?:-(\d))?$/);
|
||
if (!m) return null;
|
||
const a = Number(m[1]);
|
||
const b = m[2] ? Number(m[2]) : a;
|
||
if (a > 7 || b > 7 || b < a) return null;
|
||
for (let d = a; d <= b; d++) out.add(d % 7);
|
||
}
|
||
return [...out];
|
||
}
|
||
|
||
export function parseCron(expr: string): State {
|
||
const base: State = { mode: "custom", every: 15, minute: 0, time: "03:00", days: [1, 2, 3, 4, 5], dom: 1, custom: expr.trim() };
|
||
const f = expr.trim().split(/\s+/);
|
||
if (f.length !== 5) return base;
|
||
const [mi, h, dom, mon, dow] = f;
|
||
const num = (s: string, max: number) => /^\d+$/.test(s) && Number(s) <= max;
|
||
if (mon !== "*") return base;
|
||
let m;
|
||
if ((m = mi.match(/^\*\/(\d+)$/)) && h === "*" && dom === "*" && dow === "*" && MINUTE_STEPS.includes(Number(m[1]))) return { ...base, mode: "minutes", every: Number(m[1]) };
|
||
if (num(mi, 59) && h === "*" && dom === "*" && dow === "*") return { ...base, mode: "hourly", minute: Number(mi) };
|
||
if (!num(mi, 59) || !num(h, 23)) return base;
|
||
const time = hhmm(h, mi);
|
||
if (dom === "*" && dow === "*") return { ...base, mode: "daily", time };
|
||
if (dom === "*") {
|
||
const days = expandDays(dow);
|
||
return days?.length ? { ...base, mode: "weekly", time, days } : base;
|
||
}
|
||
if (num(dom, 31) && Number(dom) >= 1 && dow === "*") return { ...base, mode: "monthly", time, dom: Number(dom) };
|
||
return base;
|
||
}
|
||
|
||
export function buildCron(s: State): string {
|
||
const [h, m] = s.time.split(":").map(Number);
|
||
switch (s.mode) {
|
||
case "minutes":
|
||
return `*/${s.every} * * * *`;
|
||
case "hourly":
|
||
return `${s.minute} * * * *`;
|
||
case "daily":
|
||
return `${m} ${h} * * *`;
|
||
case "weekly": {
|
||
const days = [...s.days].sort((a, b) => a - b);
|
||
return `${m} ${h} * * ${days.length ? days.join(",") : "*"}`;
|
||
}
|
||
case "monthly":
|
||
return `${m} ${h} ${s.dom} * *`;
|
||
default:
|
||
return s.custom.trim();
|
||
}
|
||
}
|
||
|
||
const dayName = (d: number, style: "short" | "long") => {
|
||
// 2023-01-01 was a Sunday.
|
||
return new Date(2023, 0, 1 + d).toLocaleDateString(getLang(), { weekday: style });
|
||
};
|
||
|
||
/** "2026-11-11T06:30" (server wall time) for display, without time zone conversion. */
|
||
const fmtWall = (s: string) => {
|
||
const [d, hm] = s.split("T");
|
||
const [y, mo, da] = d.split("-").map(Number);
|
||
const date = new Date(y, mo - 1, da).toLocaleDateString(getLang(), { weekday: "short", day: "numeric", month: "short" });
|
||
return `${date}, ${hm}`;
|
||
};
|
||
|
||
export function CronBuilder({ value, onChange }: { value: string; onChange: (cron: string) => void }) {
|
||
const t = useT();
|
||
const [s, setS] = useState<State>(() => parseCron(value));
|
||
const [preview, setPreview] = useState<{ ok: boolean; next_local?: string[]; error?: string; tz?: string }>();
|
||
|
||
// A value from outside (another automation, reset) that the builder did not produce.
|
||
useEffect(() => {
|
||
if (value.trim() !== buildCron(s)) setS(parseCron(value));
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, [value]);
|
||
|
||
useEffect(() => {
|
||
const expr = value.trim();
|
||
if (!expr) return setPreview(undefined);
|
||
let alive = true;
|
||
const h = setTimeout(() => {
|
||
get<typeof preview>(`/jobs/schedule?expr=${encodeURIComponent(expr)}`)
|
||
.then((p) => alive && setPreview(p))
|
||
.catch(() => alive && setPreview(undefined));
|
||
}, 250);
|
||
return () => {
|
||
alive = false;
|
||
clearTimeout(h);
|
||
};
|
||
}, [value]);
|
||
|
||
const set = (patch: Partial<State>) => {
|
||
const next = { ...s, ...patch };
|
||
// Switching to "custom" starts from the current expression.
|
||
if (patch.mode === "custom" && s.mode !== "custom") next.custom = buildCron(s);
|
||
setS(next);
|
||
onChange(buildCron(next));
|
||
};
|
||
|
||
const modes: Mode[] = ["minutes", "hourly", "daily", "weekly", "monthly", "custom"];
|
||
return (
|
||
<div className="cron-builder">
|
||
<div className="cron-modes" role="radiogroup" aria-label={t("cron.label")}>
|
||
{modes.map((m) => (
|
||
<button key={m} type="button" role="radio" aria-checked={s.mode === m} className={`chip-btn ${s.mode === m ? "active" : ""}`} onClick={() => set({ mode: m })}>
|
||
{t(`cron.mode.${m}` as const)}
|
||
</button>
|
||
))}
|
||
</div>
|
||
<div className="cron-fields">
|
||
{s.mode === "minutes" && (
|
||
<label>
|
||
{t("cron.every")}
|
||
<select value={s.every} onChange={(e) => set({ every: Number(e.target.value) })}>
|
||
{MINUTE_STEPS.map((n) => (
|
||
<option key={n} value={n}>
|
||
{n}
|
||
</option>
|
||
))}
|
||
</select>
|
||
{t("cron.minutes")}
|
||
</label>
|
||
)}
|
||
{s.mode === "hourly" && (
|
||
<label>
|
||
{t("cron.atMinute")}
|
||
<input type="number" min={0} max={59} value={s.minute} onChange={(e) => set({ minute: Math.min(59, Math.max(0, Number(e.target.value) || 0)) })} />
|
||
</label>
|
||
)}
|
||
{s.mode === "weekly" && (
|
||
<div className="cron-days" role="group" aria-label={t("cron.days")}>
|
||
{WEEK.map((d) => (
|
||
<button
|
||
key={d}
|
||
type="button"
|
||
aria-pressed={s.days.includes(d)}
|
||
className={`chip-btn ${s.days.includes(d) ? "active" : ""}`}
|
||
title={dayName(d, "long")}
|
||
onClick={() => set({ days: s.days.includes(d) ? s.days.filter((x) => x !== d) : [...s.days, d] })}
|
||
>
|
||
{dayName(d, "short")}
|
||
</button>
|
||
))}
|
||
<button type="button" className="link-btn" onClick={() => set({ days: [1, 2, 3, 4, 5] })}>
|
||
{t("cron.workdays")}
|
||
</button>
|
||
</div>
|
||
)}
|
||
{s.mode === "monthly" && (
|
||
<label>
|
||
{t("cron.onDay")}
|
||
<select value={s.dom} onChange={(e) => set({ dom: Number(e.target.value) })}>
|
||
{Array.from({ length: 31 }, (_, i) => i + 1).map((d) => (
|
||
<option key={d} value={d}>
|
||
{d}.
|
||
</option>
|
||
))}
|
||
</select>
|
||
</label>
|
||
)}
|
||
{(s.mode === "daily" || s.mode === "weekly" || s.mode === "monthly") && (
|
||
<label>
|
||
{t("cron.at")}
|
||
<input type="time" value={s.time} onChange={(e) => e.target.value && set({ time: e.target.value })} />
|
||
</label>
|
||
)}
|
||
{s.mode === "custom" && (
|
||
<label className="cron-custom">
|
||
<input className="mono" value={s.custom} placeholder="0 3 * * *" spellCheck={false} onChange={(e) => set({ custom: e.target.value })} />
|
||
<small className="hint">{t("cron.customHint")}</small>
|
||
</label>
|
||
)}
|
||
</div>
|
||
<div className="cron-preview">
|
||
<code>{value || "–"}</code>
|
||
{preview?.ok === false && <span className="err">{preview.error}</span>}
|
||
{preview?.ok && preview.next_local && preview.next_local.length > 0 && (
|
||
<span>
|
||
{t("cron.nextRuns")} {preview.next_local.slice(0, 3).map(fmtWall).join(" · ")}
|
||
</span>
|
||
)}
|
||
{preview?.tz && s.mode !== "minutes" && <span className="hint">{t("cron.serverTz", { tz: preview.tz })}</span>}
|
||
{s.mode === "monthly" && s.dom > 28 && <span className="hint">{t("cron.shortMonths")}</span>}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|