Build App / compute-version (pull_request) Successful in 4s
Build Container / build-container (pull_request) Successful in 9m35s
Build App / build-linux (pull_request) Successful in 5m35s
Build App / build-windows (pull_request) Failing after 2m26s
Build App / build-macos (pull_request) Successful in 2m49s
Build App / create-tag (pull_request) Skipped
Build App / sync-to-github (pull_request) Skipped
Completes the Automation tab: it could list, toggle, run, log and remove tasks but not create them, so task creation still meant dropping to the CLI. Adds add_scheduled_task and update_scheduled_task, plus a task editor with cron presets and a plain-English reading of the expression. Every field is free user text, so all of it goes to the scheduler as a bare argv vector through bollard — no shell, no quoting. Validation is shape-only rather than metacharacter scrubbing: length caps, no control characters in single-line fields, no leading-dash name, absolute working_dir. Verified by round-tripping a prompt containing `; rm -rf /`, `$(id)`, backticks and newlines: it landed byte-for-byte in the task JSON with nothing executed. The scheduler CLI has no `edit`, so update is add-then-remove with the add first — a rejected edit leaves the original intact. The new id is surfaced in the editor rather than hidden. Root-cause fix, and the more serious half of this commit: triple-c-scheduler never validated --schedule, and rebuild_crontab regenerates the entire crontab and pipes it to `crontab`, which rejects the whole file if any line is malformed — with the error thrown away by `2>/dev/null || true`. A single bad schedule therefore silently unscheduled every other task in the container while reporting success. Reproduced directly. It matters because the global CLAUDE.md tells Claude to drive this CLI, so Claude could trigger it unprompted. `add` now validates the expression and exits non-zero, and rebuild_crontab reports a rejected crontab instead of swallowing it, keeping the offending file for inspection. Verified against the real CLI in this container: a bad schedule is refused without disturbing an existing task's crontab entry, and `0 9 * * 1-5`, `*/30 * * * *`, `0,30 8-17 * * *` and `0 0 1 1 *` are all still accepted. The Rust layer validates independently, agreeing with vixie cron on 23 probed expressions including `1/2` and `*/0` being invalid. 121 frontend tests, 44 Rust tests, both builds clean. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
288 lines
10 KiB
TypeScript
288 lines
10 KiB
TypeScript
import { useCallback, useEffect, useState } from "react";
|
|
import type { Project, ScheduledTask, SchedulerNotification } from "../../../lib/types";
|
|
import {
|
|
clearSchedulerNotifications,
|
|
getScheduledTaskLog,
|
|
getSchedulerNotifications,
|
|
listScheduledTasks,
|
|
removeScheduledTask,
|
|
runScheduledTaskNow,
|
|
setScheduledTaskEnabled,
|
|
} from "../../../lib/tauri-commands";
|
|
import { useAppState } from "../../../store/appState";
|
|
import Button from "../../ui/Button";
|
|
import Toggle from "../../ui/Toggle";
|
|
import Modal from "../../ui/Modal";
|
|
import StatusIndicator from "../../ui/StatusIndicator";
|
|
import TaskEditorModal from "./TaskEditorModal";
|
|
import { formatAge } from "./format";
|
|
|
|
interface Props {
|
|
project: Project;
|
|
}
|
|
|
|
/**
|
|
* UI for `triple-c-scheduler`, which ships in every container and until now
|
|
* had no interface beyond a CLAUDE.md paragraph.
|
|
*/
|
|
export default function AutomationTab({ project }: Props) {
|
|
const [tasks, setTasks] = useState<ScheduledTask[]>([]);
|
|
const [notifications, setNotifications] = useState<SchedulerNotification[]>([]);
|
|
const [loading, setLoading] = useState(false);
|
|
const [busyTaskId, setBusyTaskId] = useState<string | null>(null);
|
|
const [log, setLog] = useState<{ task: ScheduledTask; text: string } | null>(null);
|
|
const [confirmRemoveId, setConfirmRemoveId] = useState<string | null>(null);
|
|
/** `undefined` = closed, `null` = creating, a task = editing it. */
|
|
const [editing, setEditing] = useState<ScheduledTask | null | undefined>(undefined);
|
|
const pushToast = useAppState((s) => s.pushToast);
|
|
const running = project.status === "running";
|
|
|
|
const load = useCallback(() => {
|
|
if (!running) {
|
|
setTasks([]);
|
|
setNotifications([]);
|
|
return;
|
|
}
|
|
setLoading(true);
|
|
Promise.all([
|
|
listScheduledTasks(project.id).catch(() => [] as ScheduledTask[]),
|
|
getSchedulerNotifications(project.id).catch(
|
|
() => [] as SchedulerNotification[],
|
|
),
|
|
])
|
|
.then(([t, n]) => {
|
|
setTasks(t);
|
|
setNotifications(n);
|
|
})
|
|
.finally(() => setLoading(false));
|
|
}, [project.id, running]);
|
|
|
|
useEffect(load, [load]);
|
|
|
|
const withTask = async (taskId: string, label: string, fn: () => Promise<unknown>) => {
|
|
setBusyTaskId(taskId);
|
|
try {
|
|
await fn();
|
|
load();
|
|
} catch (e) {
|
|
pushToast({ kind: "error", message: `${label} failed`, detail: String(e) });
|
|
} finally {
|
|
setBusyTaskId(null);
|
|
}
|
|
};
|
|
|
|
const openLog = async (task: ScheduledTask) => {
|
|
setBusyTaskId(task.id);
|
|
try {
|
|
const text = await getScheduledTaskLog(project.id, task.id, 200);
|
|
setLog({ task, text });
|
|
} catch (e) {
|
|
pushToast({
|
|
kind: "error",
|
|
message: `Could not read the log for “${task.name}”`,
|
|
detail: String(e),
|
|
});
|
|
} finally {
|
|
setBusyTaskId(null);
|
|
}
|
|
};
|
|
|
|
const removing = tasks.find((t) => t.id === confirmRemoveId) ?? null;
|
|
|
|
return (
|
|
<div className="p-4 space-y-6 max-w-4xl">
|
|
{/* Notifications */}
|
|
{notifications.length > 0 && (
|
|
<section className="border border-[var(--accent)]/40 bg-[var(--accent-muted)] rounded-[var(--radius-panel)]">
|
|
<header className="flex items-center justify-between px-3 py-2 border-b border-[var(--border-color)]">
|
|
<h2 className="text-[11px] font-semibold uppercase tracking-wide text-[var(--accent)]">
|
|
{notifications.length} notification
|
|
{notifications.length === 1 ? "" : "s"}
|
|
</h2>
|
|
<Button
|
|
onClick={async () => {
|
|
try {
|
|
await clearSchedulerNotifications(project.id);
|
|
setNotifications([]);
|
|
} catch (e) {
|
|
pushToast({
|
|
kind: "error",
|
|
message: "Could not clear notifications",
|
|
detail: String(e),
|
|
});
|
|
}
|
|
}}
|
|
>
|
|
Clear all
|
|
</Button>
|
|
</header>
|
|
<ul className="divide-y divide-[var(--border-color)]">
|
|
{notifications.map((n, i) => (
|
|
<li key={`${n.task_id}-${i}`} className="px-3 py-2">
|
|
<div className="flex items-center gap-2 text-xs">
|
|
<span className="font-medium text-[var(--text-primary)]">
|
|
{n.task_name ?? n.task_id}
|
|
</span>
|
|
{n.status && (
|
|
<StatusIndicator
|
|
tone={n.status.toLowerCase() === "success" ? "ok" : "error"}
|
|
label={n.status}
|
|
/>
|
|
)}
|
|
<span className="text-[var(--text-secondary)] ml-auto">
|
|
{formatAge(n.created_at) ?? n.time ?? ""}
|
|
</span>
|
|
</div>
|
|
<p className="mt-0.5 text-xs text-[var(--text-secondary)] whitespace-pre-wrap break-words">
|
|
{n.summary ?? n.body}
|
|
</p>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</section>
|
|
)}
|
|
|
|
<section>
|
|
<div className="flex items-center justify-between mb-3">
|
|
<p className="text-xs text-[var(--text-secondary)]">
|
|
Recurring Claude Code runs managed by{" "}
|
|
<code className="font-mono text-[var(--text-primary)]">
|
|
triple-c-scheduler
|
|
</code>{" "}
|
|
inside the container.
|
|
</p>
|
|
<div className="flex items-center gap-2">
|
|
<Button onClick={load} disabled={!running || loading}>
|
|
{loading ? "Refreshing…" : "Refresh"}
|
|
</Button>
|
|
<Button variant="primary" disabled={!running} onClick={() => setEditing(null)}>
|
|
New task
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
{!running ? (
|
|
<p className="text-[13px] text-[var(--text-secondary)]">
|
|
Start the container to list its scheduled tasks.
|
|
</p>
|
|
) : tasks.length === 0 && !loading ? (
|
|
<p className="text-[13px] text-[var(--text-secondary)]">
|
|
No scheduled tasks yet. Use <strong>New task</strong>, or ask Claude to add one with{" "}
|
|
<code className="font-mono">triple-c-scheduler add</code>.
|
|
</p>
|
|
) : (
|
|
<ul className="space-y-1">
|
|
{tasks.map((task) => (
|
|
<li
|
|
key={task.id}
|
|
className="flex items-center gap-3 px-3 py-2 bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-[var(--radius-control)]"
|
|
>
|
|
<div className="flex-1 min-w-0">
|
|
<div className="flex items-center gap-2">
|
|
<span className="text-[13px] font-medium text-[var(--text-primary)] truncate">
|
|
{task.name}
|
|
</span>
|
|
<span className="text-[10px] uppercase tracking-wide px-1.5 py-0.5 rounded-[var(--radius-control)] bg-[var(--bg-tertiary)] text-[var(--text-secondary)]">
|
|
{task.task_type}
|
|
</span>
|
|
</div>
|
|
<div className="text-xs text-[var(--text-secondary)] font-mono truncate">
|
|
{task.at ?? task.schedule}
|
|
{task.last_run ? ` · last run ${formatAge(task.last_run) ?? task.last_run}` : ""}
|
|
</div>
|
|
</div>
|
|
<Toggle
|
|
label={`${task.name} enabled`}
|
|
checked={task.enabled}
|
|
disabled={busyTaskId === task.id}
|
|
onChange={(v) =>
|
|
withTask(task.id, "Toggle task", () =>
|
|
setScheduledTaskEnabled(project.id, task.id, v),
|
|
)
|
|
}
|
|
/>
|
|
<Button
|
|
disabled={busyTaskId === task.id}
|
|
onClick={() =>
|
|
withTask(task.id, "Run now", () =>
|
|
runScheduledTaskNow(project.id, task.id),
|
|
)
|
|
}
|
|
>
|
|
Run now
|
|
</Button>
|
|
<Button disabled={busyTaskId === task.id} onClick={() => setEditing(task)}>
|
|
Edit
|
|
</Button>
|
|
<Button disabled={busyTaskId === task.id} onClick={() => openLog(task)}>
|
|
Log
|
|
</Button>
|
|
<Button
|
|
variant="danger"
|
|
disabled={busyTaskId === task.id}
|
|
onClick={() => setConfirmRemoveId(task.id)}
|
|
>
|
|
Remove
|
|
</Button>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
)}
|
|
</section>
|
|
|
|
{editing !== undefined && (
|
|
<TaskEditorModal
|
|
project={project}
|
|
task={editing}
|
|
onClose={() => setEditing(undefined)}
|
|
onSaved={load}
|
|
/>
|
|
)}
|
|
|
|
{log && (
|
|
<Modal
|
|
title={`Log — ${log.task.name}`}
|
|
onClose={() => setLog(null)}
|
|
widthClassName="w-[46rem]"
|
|
footer={<Button onClick={() => setLog(null)}>Close</Button>}
|
|
>
|
|
<pre className="whitespace-pre-wrap break-words font-mono text-xs text-[var(--text-secondary)]">
|
|
{log.text.trim() || "(empty log)"}
|
|
</pre>
|
|
</Modal>
|
|
)}
|
|
|
|
{removing && (
|
|
<Modal
|
|
title="Remove scheduled task"
|
|
onClose={() => setConfirmRemoveId(null)}
|
|
widthClassName="w-[26rem]"
|
|
footer={
|
|
<>
|
|
<Button variant="ghost" onClick={() => setConfirmRemoveId(null)}>
|
|
Cancel
|
|
</Button>
|
|
<Button
|
|
className="bg-[var(--error-emphasis)] text-white border border-transparent hover:opacity-90"
|
|
onClick={() => {
|
|
setConfirmRemoveId(null);
|
|
withTask(removing.id, "Remove task", () =>
|
|
removeScheduledTask(project.id, removing.id),
|
|
);
|
|
}}
|
|
>
|
|
Remove
|
|
</Button>
|
|
</>
|
|
}
|
|
>
|
|
<p className="text-[13px] text-[var(--text-secondary)]">
|
|
Remove <strong className="text-[var(--text-primary)]">{removing.name}</strong>{" "}
|
|
from this container’s scheduler?
|
|
</p>
|
|
</Modal>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|