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>
204 lines
9.1 KiB
TypeScript
204 lines
9.1 KiB
TypeScript
import { useEffect, useState } from "react";
|
||
import { del, get, patch, post } from "../api";
|
||
import { fmtRelative, useT } from "../i18n";
|
||
import { navigate } from "../router";
|
||
import { confirmDialog, toast } from "../dialogs";
|
||
import { Icon } from "./Icon";
|
||
import { Spinner } from "./Activity";
|
||
import { NewJobDialog } from "./NewJobDialog";
|
||
|
||
export interface JobInfo {
|
||
id: string;
|
||
path: string;
|
||
enabled: boolean;
|
||
last_run?: number | null;
|
||
last_session_id?: string | null;
|
||
last_status?: string | null;
|
||
next_run?: number | null;
|
||
error?: string;
|
||
webhook?: boolean;
|
||
spec?: { name: string; schedule: string; agent: string; model?: string | null; permission_mode: string; timeout_minutes: number; prompt: string; assistant_session?: string | null };
|
||
}
|
||
|
||
const fmtDate = (ms?: number | null) => (ms ? new Date(ms).toLocaleString(undefined, { dateStyle: "medium", timeStyle: "short" }) : "–");
|
||
|
||
/** Automations: agents started on a schedule with the instructions from a Markdown file. */
|
||
export function Jobs({ onMenu }: { onMenu: () => void }) {
|
||
const t = useT();
|
||
const [jobs, setJobs] = useState<JobInfo[]>();
|
||
const [path, setPath] = useState("");
|
||
const [busy, setBusy] = useState<string>();
|
||
const [hook, setHook] = useState<{ id: string; url: string; secret: string }>();
|
||
const [templates, setTemplates] = useState(false);
|
||
|
||
const reload = () =>
|
||
get<JobInfo[]>("/jobs")
|
||
.then(setJobs)
|
||
.catch((e) => toast(e.message));
|
||
useEffect(() => {
|
||
void reload();
|
||
const h = setInterval(reload, 15000);
|
||
return () => clearInterval(h);
|
||
}, []);
|
||
|
||
const act = async (id: string, f: () => Promise<unknown>) => {
|
||
setBusy(id);
|
||
try {
|
||
await f();
|
||
await reload();
|
||
} catch (e: any) {
|
||
toast(e.message);
|
||
} finally {
|
||
setBusy(undefined);
|
||
}
|
||
};
|
||
|
||
return (
|
||
<>
|
||
<div className="topbar">
|
||
<button className="ghost menu-btn" onClick={onMenu} aria-label={t("nav.menu")}>
|
||
<Icon name="menu" />
|
||
</button>
|
||
<div className="grow">
|
||
<h1>{t("nav.jobs")}</h1>
|
||
</div>
|
||
</div>
|
||
<div className="page">
|
||
<div className="page-inner" style={{ maxWidth: 980 }}>
|
||
<div className="notice">{t("jobs.intro")}</div>
|
||
<div className="row" style={{ margin: "12px 0" }}>
|
||
<button className="primary" onClick={() => setTemplates(true)}>
|
||
<Icon name="plus" /> {t("jobs.new")}
|
||
</button>
|
||
</div>
|
||
{templates && <NewJobDialog onClose={() => setTemplates(false)} />}
|
||
{!jobs && <div className="empty"><Spinner /></div>}
|
||
{jobs?.length === 0 && <div className="empty">{t("jobs.none")}</div>}
|
||
{jobs?.map((j) => (
|
||
<div key={j.id} className={`panel job ${j.enabled ? "" : "off"}`}>
|
||
<div className="job-head">
|
||
<Icon name="clock" className="job-icon" />
|
||
<div style={{ flex: 1, minWidth: 0 }}>
|
||
<h3>
|
||
<button className="link-btn job-title" onClick={() => navigate(`/jobs/${j.id}`)}>
|
||
{j.spec?.name ?? j.path.split("/").pop()}
|
||
</button>
|
||
</h3>
|
||
<div className="hint mono">{j.path}</div>
|
||
</div>
|
||
<label className="switch" title={t(j.enabled ? "jobs.disable" : "jobs.enable")}>
|
||
<input type="checkbox" checked={j.enabled} onChange={(e) => act(j.id, () => patch(`/jobs/${j.id}`, { enabled: e.target.checked }))} />
|
||
<span>{t(j.enabled ? "jobs.on" : "jobs.off")}</span>
|
||
</label>
|
||
</div>
|
||
{j.error ? (
|
||
<div className="notice err">{j.error}</div>
|
||
) : (
|
||
<div className="job-grid">
|
||
<span>{t("jobs.schedule")}</span>
|
||
<code>{j.spec?.schedule}</code>
|
||
<span>{t("jobs.next")}</span>
|
||
<span>{j.enabled ? fmtDate(j.next_run) : "–"}</span>
|
||
<span>{t("jobs.last")}</span>
|
||
<span>
|
||
{j.last_run ? `${fmtRelative(j.last_run)} · ` : "–"}
|
||
{j.last_status && <span className={`chip ${j.last_status === "done" ? "ok" : j.last_status === "running" ? "" : "warn"}`}>{t(`jobs.status.${j.last_status.startsWith("error") ? "error" : j.last_status}` as any)}</span>}
|
||
{j.last_session_id && (
|
||
<button className="link-btn" style={{ marginLeft: 8 }} onClick={() => navigate(`/s/${j.last_session_id}`)}>
|
||
{t("jobs.openRun")}
|
||
</button>
|
||
)}
|
||
</span>
|
||
<span>{t("jobs.mode")}</span>
|
||
<span>
|
||
{j.spec?.agent} · {j.spec?.model || t("agents.sessionModel")} · {t(`permissionMode.${j.spec?.permission_mode ?? "auto"}` as any)} · max {j.spec?.timeout_minutes} min
|
||
</span>
|
||
</div>
|
||
)}
|
||
{j.last_status?.startsWith("error") && <div className="notice err">{j.last_status}</div>}
|
||
{j.spec && (
|
||
<details className="job-prompt">
|
||
<summary>{t("jobs.prompt")}</summary>
|
||
<pre>{j.spec.prompt}</pre>
|
||
</details>
|
||
)}
|
||
<div className="job-hook">
|
||
<span>
|
||
<Icon name="external" /> {t("jobs.webhook")}: {j.webhook ? t("jobs.on") : t("jobs.off")}
|
||
</span>
|
||
<button
|
||
className="small ghost"
|
||
onClick={() =>
|
||
act(j.id, async () => {
|
||
const r = await post<{ url?: string; secret?: string }>(`/jobs/${j.id}/webhook`, { enable: !j.webhook });
|
||
if (r.url && r.secret) setHook({ id: j.id, url: r.url, secret: r.secret });
|
||
})
|
||
}
|
||
>
|
||
{j.webhook ? t("jobs.webhookOff") : t("jobs.webhookOn")}
|
||
</button>
|
||
{j.webhook && (
|
||
<button
|
||
className="small ghost"
|
||
onClick={() =>
|
||
act(j.id, async () => {
|
||
const r = await post<{ url: string; secret: string }>(`/jobs/${j.id}/webhook`, { enable: true });
|
||
setHook({ id: j.id, url: r.url, secret: r.secret });
|
||
})
|
||
}
|
||
>
|
||
{t("jobs.webhookRenew")}
|
||
</button>
|
||
)}
|
||
</div>
|
||
{hook?.id === j.id && (
|
||
<div className="notice ok">
|
||
<div>{t("jobs.webhookShown")}</div>
|
||
<div className="mono" style={{ marginTop: 6, wordBreak: "break-all" }}>URL: {hook.url}</div>
|
||
<div className="mono" style={{ wordBreak: "break-all" }}>Secret: {hook.secret}</div>
|
||
<div className="hint" style={{ marginTop: 6 }}>{t("jobs.webhookHelp")}</div>
|
||
<pre className="job-example">{`curl -X POST ${hook.url} \\\n -H "X-CodeCraft-Secret: ${hook.secret}" \\\n -H "Content-Type: application/json" -d '{"event":"test"}'`}</pre>
|
||
</div>
|
||
)}
|
||
<div className="row" style={{ gap: 8, marginTop: 10 }}>
|
||
<button className="small" disabled={busy === j.id || !!j.error} onClick={() => act(j.id, async () => {
|
||
const r = await post<{ session_id: string }>(`/jobs/${j.id}/run`, {});
|
||
navigate(`/s/${r.session_id}`);
|
||
})}>
|
||
{busy === j.id ? <Spinner small /> : <Icon name="play" />} {t("jobs.runNow")}
|
||
</button>
|
||
<button className="small" onClick={() => navigate(`/jobs/${j.id}`)}>
|
||
<Icon name="edit" /> {t("jobs.edit")}
|
||
</button>
|
||
<span className="grow" />
|
||
<button
|
||
className="small ghost danger"
|
||
onClick={async () => {
|
||
if (await confirmDialog({ message: t("jobs.deleteConfirm", { name: j.spec?.name ?? j.path }), danger: true })) void act(j.id, () => del(`/jobs/${j.id}`));
|
||
}}
|
||
>
|
||
<Icon name="trash" /> {t("jobs.remove")}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
))}
|
||
<div className="panel">
|
||
<h3>{t("jobs.register")}</h3>
|
||
<div className="hint" style={{ marginBottom: 8 }}>{t("jobs.registerHint")}</div>
|
||
<div className="row" style={{ gap: 8 }}>
|
||
<input className="mono" style={{ flex: 1, minWidth: 0 }} value={path} placeholder="/workspace/project/.codecraft/jobs/nightly.md" onChange={(e) => setPath(e.target.value)} />
|
||
<button disabled={!path.trim()} onClick={() => act("new", async () => {
|
||
const j = await post<JobInfo>("/jobs", { path: path.trim() });
|
||
setPath("");
|
||
navigate(`/jobs/${j.id}`);
|
||
})}>
|
||
{t("jobs.registerBtn")}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</>
|
||
);
|
||
}
|