Files
Triple-C/app/src/components/projects/home/AutomationTab.tsx
T
shadow-testandClaude Opus 5 fa4940dd7d
Build App (Preview) / compute-version (pull_request) Successful in 7s
Build Container / build-container (pull_request) Successful in 2m53s
Build App (Preview) / create-release (pull_request) Successful in 5s
Build App (Preview) / build-macos (pull_request) Successful in 2m37s
Build App (Preview) / build-windows (pull_request) Successful in 6m2s
Build App (Preview) / build-linux (pull_request) Successful in 6m53s
Build App (Preview) / prune-previews (pull_request) Successful in 2s
Say when a scheduled task is running
A run is detached — cron has no terminal, and the app fires it as a detached
exec — so triggering one and watching the log was indistinguishable from
triggering one that died. Worse, `claude -p` writes its answer in a single
burst at the end, so a healthy run shows nothing but its log header for as
long as it is thinking. The honest reading of the old UI was "it stalled".

triple-c-task-runner now publishes a state file per run (pid, start time, log
path) and removes it from an EXIT trap. flock remains what actually prevents
overlapping runs; this is purely observability, so every reader verifies the
pid rather than trusting the file — a container stopped mid-run cannot fire a
trap, and a task stuck on "running" forever would be a worse lie than no
indicator at all. Stale files are cleared on read.

On top of that:

- `list` grows a status column: "running 4m12s" or "idle".
- `status [--id] [--watch]` answers "is it still going?" directly, with
  elapsed time and the tail of the log when there is any output yet.
- `run` streams the log instead of blocking silently, and refuses to start a
  task that is already running.
- The Automation tab marks a running task, disables its Run now button, and
  polls while anything is in flight — including the second or two between
  firing a run and the runner registering it, which is the exact window that
  used to read as dead.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 06:33:49 -07:00

311 lines
12 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, formatRunningFor } 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]);
// A task in flight is the one state this view cannot sit still for: runs are
// detached, so without polling "Run now" looks like it did nothing until the
// user reaches for Refresh. Polling stops as soon as nothing is running.
//
// `justTriggered` covers the gap between firing a run and the runner writing
// its state file — a second or two in which the task still reads as idle, and
// where giving up on polling would reproduce the exact silence this fixes.
const anyTaskRunning = tasks.some((t) => t.running);
const [justTriggered, setJustTriggered] = useState(0);
useEffect(() => {
if (!running) return;
if (!anyTaskRunning && Date.now() - justTriggered > 20_000) return;
const timer = setInterval(load, anyTaskRunning ? 5000 : 1500);
return () => clearInterval(timer);
}, [running, anyTaskRunning, justTriggered, 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>
{task.running && (
<StatusIndicator
tone="busy"
label={`Running ${formatRunningFor(task.running_since) ?? ""}`.trim()}
/>
)}
</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 || task.running}
onClick={() =>
withTask(task.id, "Run now", async () => {
await runScheduledTaskNow(project.id, task.id);
setJustTriggered(Date.now());
})
}
>
{task.running ? "Running…" : "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&rsquo;s scheduler?
</p>
</Modal>
)}
</div>
);
}