Files
Triple-C/app/src/components/projects/home/TaskEditorModal.tsx
T
shadow-testandClaude Opus 5 7d00390e1f
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
Add scheduled task creation, and stop a bad cron unscheduling everything
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>
2026-08-09 12:20:52 -07:00

330 lines
12 KiB
TypeScript

import { useId, useMemo, useRef, useState } from "react";
import type { Project, ScheduledTask, ScheduledTaskInput, ScheduleKind } from "../../../lib/types";
import { addScheduledTask, updateScheduledTask } from "../../../lib/tauri-commands";
import { effectivePermissionMode, PERMISSION_MODES } from "../PermissionModeControl";
import Button from "../../ui/Button";
import Modal from "../../ui/Modal";
import SegmentedControl from "../../ui/SegmentedControl";
import { inputClass, monoInputClass } from "../../ui/Field";
import {
atTimestampIsPast,
CRON_PRESETS,
DEFAULT_WORKING_DIR,
describeCron,
MAX_TASK_PROMPT_LEN,
validateAtTimestamp,
validateCronExpression,
validateTaskName,
validateTaskPrompt,
validateWorkingDir,
} from "./taskValidation";
interface Props {
project: Project;
/** `null` creates a new task; a task edits it in place. */
task: ScheduledTask | null;
onClose: () => void;
/** Called after the scheduler accepted the change, to refresh the list. */
onSaved: () => void;
}
const DEFAULT_CRON = "0 9 * * *";
/** `YYYY-MM-DD HH:MM`, one hour from now, as the one-shot default. */
function defaultAtTimestamp(now = new Date()): string {
const at = new Date(now.getTime() + 60 * 60 * 1000);
at.setSeconds(0, 0);
const pad = (n: number) => String(n).padStart(2, "0");
return `${at.getFullYear()}-${pad(at.getMonth() + 1)}-${pad(at.getDate())} ${pad(
at.getHours(),
)}:${pad(at.getMinutes())}`;
}
/**
* Create or edit a `triple-c-scheduler` task.
*
* Validation here mirrors the backend so mistakes surface before a round trip;
* the backend re-checks everything regardless.
*/
export default function TaskEditorModal({ project, task, onClose, onSaved }: Props) {
const formId = useId();
const nameRef = useRef<HTMLInputElement>(null);
const [name, setName] = useState(task?.name ?? "");
const [prompt, setPrompt] = useState(task?.prompt ?? "");
const [workingDir, setWorkingDir] = useState(task?.working_dir ?? DEFAULT_WORKING_DIR);
const [kind, setKind] = useState<ScheduleKind>(
task?.task_type === "once" ? "once" : "recurring",
);
const [cron, setCron] = useState(
task && task.task_type !== "once" ? task.schedule : DEFAULT_CRON,
);
const [at, setAt] = useState(task?.at ?? defaultAtTimestamp());
const [showAllErrors, setShowAllErrors] = useState(false);
const [touched, setTouched] = useState<Record<string, boolean>>({});
const [saving, setSaving] = useState(false);
const [submitError, setSubmitError] = useState<string | null>(null);
const errors = {
name: validateTaskName(name),
prompt: validateTaskPrompt(prompt),
workingDir: validateWorkingDir(workingDir),
schedule: kind === "recurring" ? validateCronExpression(cron) : validateAtTimestamp(at),
};
const hasErrors = Object.values(errors).some(Boolean);
const show = (field: keyof typeof errors) =>
(showAllErrors || touched[field]) && errors[field] ? errors[field] : null;
const cronReading = useMemo(() => describeCron(cron), [cron]);
const atIsPast = kind === "once" && atTimestampIsPast(at);
const mode = effectivePermissionMode(project);
const modeLabel = PERMISSION_MODES.find((m) => m.value === mode)?.label ?? mode;
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setShowAllErrors(true);
setSubmitError(null);
if (hasErrors) return;
const input: ScheduledTaskInput = {
name: name.trim(),
prompt: prompt.trim(),
scheduleKind: kind,
schedule: kind === "recurring" ? cron.trim() : at.trim(),
workingDir: workingDir.trim() || DEFAULT_WORKING_DIR,
};
setSaving(true);
try {
if (task) {
await updateScheduledTask(project.id, task.id, input, task.enabled);
} else {
await addScheduledTask(project.id, input);
}
onSaved();
onClose();
} catch (err) {
setSubmitError(String(err));
} finally {
setSaving(false);
}
};
const errorText = (message: string | null) =>
message ? (
<p role="alert" className="mt-1 text-xs text-[var(--error)]">
{message}
</p>
) : null;
return (
<Modal
title={task ? `Edit task — ${task.name}` : "New scheduled task"}
onClose={onClose}
widthClassName="w-[40rem]"
initialFocusRef={nameRef}
footer={
<>
<Button size="md" variant="ghost" onClick={onClose} disabled={saving}>
Cancel
</Button>
<Button size="md" variant="primary" type="submit" form={formId} disabled={saving}>
{saving ? "Saving…" : task ? "Save changes" : "Create task"}
</Button>
</>
}
>
<form id={formId} onSubmit={handleSubmit} className="space-y-4">
{/* Name */}
<div>
<label
htmlFor={`${formId}-name`}
className="block text-[13px] font-medium text-[var(--text-primary)] mb-1"
>
Name
</label>
<input
id={`${formId}-name`}
ref={nameRef}
value={name}
onChange={(e) => setName(e.target.value)}
onBlur={() => setTouched((t) => ({ ...t, name: true }))}
placeholder="nightly-tests"
aria-invalid={show("name") ? true : undefined}
className={inputClass}
/>
{errorText(show("name"))}
</div>
{/* Prompt */}
<div>
<label
htmlFor={`${formId}-prompt`}
className="block text-[13px] font-medium text-[var(--text-primary)]"
>
Prompt
</label>
<p className="mt-0.5 mb-1 text-xs text-[var(--text-secondary)] leading-snug">
What Claude Code is asked to do on each run.
</p>
<textarea
id={`${formId}-prompt`}
value={prompt}
onChange={(e) => setPrompt(e.target.value)}
onBlur={() => setTouched((t) => ({ ...t, prompt: true }))}
rows={4}
maxLength={MAX_TASK_PROMPT_LEN}
placeholder="Run the test suite and summarise any failures."
aria-invalid={show("prompt") ? true : undefined}
className={`${inputClass} resize-y`}
/>
{errorText(show("prompt"))}
</div>
{/* Schedule */}
<div>
<span className="block text-[13px] font-medium text-[var(--text-primary)] mb-1">
Schedule
</span>
<SegmentedControl
label="Schedule kind"
segments={[
{ value: "recurring", label: "Recurring" },
{ value: "once", label: "Once" },
]}
value={kind}
onChange={(v) => {
setKind(v);
setSubmitError(null);
}}
/>
{kind === "recurring" ? (
<div className="mt-2 space-y-2">
<div className="flex flex-wrap gap-1">
{CRON_PRESETS.map((preset) => (
<Button
key={preset.expression}
onClick={() => {
setCron(preset.expression);
setTouched((t) => ({ ...t, schedule: true }));
}}
>
{preset.label}
</Button>
))}
</div>
<input
id={`${formId}-cron`}
value={cron}
onChange={(e) => setCron(e.target.value)}
onBlur={() => setTouched((t) => ({ ...t, schedule: true }))}
placeholder="0 9 * * 1-5"
aria-label="Cron expression"
aria-describedby={`${formId}-cron-reading`}
aria-invalid={show("schedule") ? true : undefined}
className={monoInputClass}
/>
<p
id={`${formId}-cron-reading`}
aria-live="polite"
className="text-xs text-[var(--text-secondary)]"
>
<span className="font-mono">minute hour day-of-month month day-of-week</span> ·{" "}
{cronReading ? (
<span className="text-[var(--text-primary)]">{cronReading}</span>
) : (
<span>not a valid schedule yet</span>
)}
</p>
{errorText(show("schedule"))}
</div>
) : (
<div className="mt-2 space-y-1">
<input
id={`${formId}-at`}
value={at}
onChange={(e) => setAt(e.target.value)}
onBlur={() => setTouched((t) => ({ ...t, schedule: true }))}
placeholder="2026-12-25 09:05"
aria-label="Run at (YYYY-MM-DD HH:MM)"
aria-invalid={show("schedule") ? true : undefined}
className={monoInputClass}
/>
<p className="text-xs text-[var(--text-secondary)]">
Container local time, as <code className="font-mono">YYYY-MM-DD HH:MM</code>. The
task removes itself after it runs.
</p>
{atIsPast && (
<p className="text-xs text-[var(--warning)]">
That time has already passed. A one-shot task is stored as a cron entry without a
year, so it would next fire on that date next year.
</p>
)}
{errorText(show("schedule"))}
</div>
)}
</div>
{/* Working directory */}
<div>
<label
htmlFor={`${formId}-wd`}
className="block text-[13px] font-medium text-[var(--text-primary)]"
>
Working directory
</label>
<p className="mt-0.5 mb-1 text-xs text-[var(--text-secondary)] leading-snug">
Absolute path inside the container. Project folders are mounted under{" "}
<code className="font-mono">/workspace</code>.
</p>
<input
id={`${formId}-wd`}
value={workingDir}
onChange={(e) => setWorkingDir(e.target.value)}
onBlur={() => setTouched((t) => ({ ...t, workingDir: true }))}
placeholder={DEFAULT_WORKING_DIR}
aria-invalid={show("workingDir") ? true : undefined}
className={monoInputClass}
/>
{errorText(show("workingDir"))}
</div>
{/* How a scheduled run actually behaves. */}
<div className="rounded-[var(--radius-control)] border border-[var(--border-color)] bg-[var(--bg-secondary)] px-3 py-2 space-y-1">
<p className="text-xs text-[var(--text-secondary)]">
Scheduled runs are <strong className="text-[var(--text-primary)]">headless</strong>
the container executes <code className="font-mono">claude -p "…"</code> with no
terminal attached, using this project&rsquo;s permission mode (
<strong className="text-[var(--text-primary)]">{modeLabel}</strong>).
</p>
{mode !== "bypass" && (
<p className="text-xs text-[var(--warning)]">
A headless run cannot answer a permission prompt. In {modeLabel} mode the task may
stall and produce an empty log; set the mode to Bypass in the Config tab for
unattended runs.
</p>
)}
</div>
{task && (
<p className="text-xs text-[var(--text-secondary)]">
The scheduler has no edit command, so saving re-creates this task under a new id and
removes <code className="font-mono">{task.id}</code>. Its previous run logs stay under
the old id.
</p>
)}
{submitError && (
<p role="alert" className="text-xs text-[var(--error)] whitespace-pre-wrap break-words">
{submitError}
</p>
)}
</form>
</Modal>
);
}