Add scheduled task creation, and stop a bad cron unscheduling everything
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
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>
This commit is contained in:
@@ -14,6 +14,7 @@ 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 {
|
||||
@@ -31,6 +32,8 @@ export default function AutomationTab({ project }: Props) {
|
||||
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";
|
||||
|
||||
@@ -148,9 +151,14 @@ export default function AutomationTab({ project }: Props) {
|
||||
</code>{" "}
|
||||
inside the container.
|
||||
</p>
|
||||
<Button onClick={load} disabled={!running || loading}>
|
||||
{loading ? "Refreshing…" : "Refresh"}
|
||||
</Button>
|
||||
<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 ? (
|
||||
@@ -159,7 +167,7 @@ export default function AutomationTab({ project }: Props) {
|
||||
</p>
|
||||
) : tasks.length === 0 && !loading ? (
|
||||
<p className="text-[13px] text-[var(--text-secondary)]">
|
||||
No scheduled tasks. Ask Claude to add one with{" "}
|
||||
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>
|
||||
) : (
|
||||
@@ -203,6 +211,9 @@ export default function AutomationTab({ project }: Props) {
|
||||
>
|
||||
Run now
|
||||
</Button>
|
||||
<Button disabled={busyTaskId === task.id} onClick={() => setEditing(task)}>
|
||||
Edit
|
||||
</Button>
|
||||
<Button disabled={busyTaskId === task.id} onClick={() => openLog(task)}>
|
||||
Log
|
||||
</Button>
|
||||
@@ -219,6 +230,15 @@ export default function AutomationTab({ project }: Props) {
|
||||
)}
|
||||
</section>
|
||||
|
||||
{editing !== undefined && (
|
||||
<TaskEditorModal
|
||||
project={project}
|
||||
task={editing}
|
||||
onClose={() => setEditing(undefined)}
|
||||
onSaved={load}
|
||||
/>
|
||||
)}
|
||||
|
||||
{log && (
|
||||
<Modal
|
||||
title={`Log — ${log.task.name}`}
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { render, screen, fireEvent, act } from "@testing-library/react";
|
||||
import TaskEditorModal from "./TaskEditorModal";
|
||||
import type { Project, ScheduledTask } from "../../../lib/types";
|
||||
|
||||
const addScheduledTask = vi.fn(async () => "a1b2c3d4");
|
||||
const updateScheduledTask = vi.fn(async () => "e5f6a7b8");
|
||||
|
||||
vi.mock("../../../lib/tauri-commands", () => ({
|
||||
addScheduledTask: (...args: unknown[]) => addScheduledTask(...(args as [])),
|
||||
updateScheduledTask: (...args: unknown[]) => updateScheduledTask(...(args as [])),
|
||||
}));
|
||||
|
||||
/** Modal focuses via rAF; jsdom needs a flush. */
|
||||
async function flushFocus() {
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(20);
|
||||
});
|
||||
}
|
||||
|
||||
const baseProject: Project = {
|
||||
id: "p1",
|
||||
name: "api-server",
|
||||
paths: [{ host_path: "/home/user/api", mount_name: "api" }],
|
||||
container_id: "c1",
|
||||
status: "running",
|
||||
backend: "anthropic",
|
||||
bedrock_config: null,
|
||||
ollama_config: null,
|
||||
openai_compatible_config: null,
|
||||
allow_docker_access: false,
|
||||
sandbox_mode_enabled: true,
|
||||
mission_control_enabled: false,
|
||||
auth_bridge_enabled: false,
|
||||
use_shared_auth_token: true,
|
||||
full_permissions: false,
|
||||
permission_mode: "bypass",
|
||||
ssh_key_path: null,
|
||||
git_token: null,
|
||||
git_user_name: null,
|
||||
git_user_email: null,
|
||||
custom_env_vars: [],
|
||||
port_mappings: [],
|
||||
claude_instructions: null,
|
||||
claude_code_settings: null,
|
||||
renamed_session_names: {},
|
||||
created_at: "2026-01-01T00:00:00Z",
|
||||
updated_at: "2026-01-01T00:00:00Z",
|
||||
};
|
||||
|
||||
const existingTask: ScheduledTask = {
|
||||
id: "a1b2c3d4",
|
||||
name: "nightly",
|
||||
prompt: "Run the suite",
|
||||
schedule: "0 3 * * *",
|
||||
task_type: "recurring",
|
||||
at: null,
|
||||
enabled: false,
|
||||
working_dir: "/workspace/api",
|
||||
created_at: null,
|
||||
last_run: null,
|
||||
next_run: null,
|
||||
};
|
||||
|
||||
async function renderEditor(task: ScheduledTask | null = null, project = baseProject) {
|
||||
const onClose = vi.fn();
|
||||
const onSaved = vi.fn();
|
||||
render(
|
||||
<TaskEditorModal project={project} task={task} onClose={onClose} onSaved={onSaved} />,
|
||||
);
|
||||
await flushFocus();
|
||||
return { onClose, onSaved };
|
||||
}
|
||||
|
||||
const field = (name: RegExp) => screen.getByLabelText(name) as HTMLInputElement;
|
||||
const submit = async () =>
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole("button", { name: /create task|save changes/i }));
|
||||
});
|
||||
|
||||
describe("TaskEditorModal", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.useFakeTimers({ toFake: ["requestAnimationFrame", "setTimeout"] });
|
||||
});
|
||||
afterEach(() => vi.useRealTimers());
|
||||
|
||||
it("sends the typed values through as data, untouched", async () => {
|
||||
await renderEditor();
|
||||
fireEvent.change(field(/^name$/i), { target: { value: " nightly " } });
|
||||
// A prompt full of shell syntax must reach the backend verbatim.
|
||||
fireEvent.change(field(/^prompt$/i), {
|
||||
target: { value: 'echo "hi"; rm -rf / $(id)\nsecond line' },
|
||||
});
|
||||
fireEvent.change(field(/cron expression/i), { target: { value: "0 3 * * *" } });
|
||||
await submit();
|
||||
|
||||
expect(addScheduledTask).toHaveBeenCalledWith("p1", {
|
||||
name: "nightly",
|
||||
prompt: 'echo "hi"; rm -rf / $(id)\nsecond line',
|
||||
scheduleKind: "recurring",
|
||||
schedule: "0 3 * * *",
|
||||
workingDir: "/workspace",
|
||||
});
|
||||
});
|
||||
|
||||
it("refuses to submit an invalid cron expression and says why", async () => {
|
||||
const { onSaved } = await renderEditor();
|
||||
fireEvent.change(field(/^name$/i), { target: { value: "nightly" } });
|
||||
fireEvent.change(field(/^prompt$/i), { target: { value: "do the thing" } });
|
||||
fireEvent.change(field(/cron expression/i), { target: { value: "99 * * * *" } });
|
||||
await submit();
|
||||
|
||||
expect(addScheduledTask).not.toHaveBeenCalled();
|
||||
expect(onSaved).not.toHaveBeenCalled();
|
||||
expect(screen.getByRole("alert")).toHaveTextContent(/out of range for the minute field/i);
|
||||
});
|
||||
|
||||
it("refuses a relative working directory", async () => {
|
||||
await renderEditor();
|
||||
fireEvent.change(field(/^name$/i), { target: { value: "nightly" } });
|
||||
fireEvent.change(field(/^prompt$/i), { target: { value: "do the thing" } });
|
||||
fireEvent.change(field(/working directory/i), { target: { value: "relative/path" } });
|
||||
await submit();
|
||||
|
||||
expect(addScheduledTask).not.toHaveBeenCalled();
|
||||
expect(screen.getByRole("alert")).toHaveTextContent(/absolute path/i);
|
||||
});
|
||||
|
||||
it("reads the cron expression back in English", async () => {
|
||||
await renderEditor();
|
||||
fireEvent.change(field(/cron expression/i), { target: { value: "0 9 * * 1-5" } });
|
||||
expect(screen.getByText("At 09:00, on Monday to Friday.")).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Hourly" }));
|
||||
expect(field(/cron expression/i).value).toBe("0 * * * *");
|
||||
expect(screen.getByText("At :00 past every hour, every day.")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("switches to a one-shot time and validates its format", async () => {
|
||||
await renderEditor();
|
||||
fireEvent.change(field(/^name$/i), { target: { value: "one-off" } });
|
||||
fireEvent.change(field(/^prompt$/i), { target: { value: "commit" } });
|
||||
fireEvent.click(screen.getByRole("radio", { name: "Once" }));
|
||||
|
||||
fireEvent.change(field(/run at/i), { target: { value: "tomorrow" } });
|
||||
await submit();
|
||||
expect(addScheduledTask).not.toHaveBeenCalled();
|
||||
expect(screen.getByRole("alert")).toHaveTextContent(/YYYY-MM-DD HH:MM/);
|
||||
|
||||
fireEvent.change(field(/run at/i), { target: { value: "2099-12-25 09:05" } });
|
||||
await submit();
|
||||
expect(addScheduledTask).toHaveBeenCalledWith(
|
||||
"p1",
|
||||
expect.objectContaining({ scheduleKind: "once", schedule: "2099-12-25 09:05" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("warns that a headless run cannot answer a permission prompt", async () => {
|
||||
// Bypass is the only mode where an unattended run is safe from stalling.
|
||||
await renderEditor(null, { ...baseProject, permission_mode: "bypass" });
|
||||
expect(screen.getByText(/headless/i)).toBeInTheDocument();
|
||||
expect(screen.queryByText(/cannot answer a permission prompt/i)).toBeNull();
|
||||
});
|
||||
|
||||
it("spells out the stall risk in any non-Bypass mode", async () => {
|
||||
await renderEditor(null, { ...baseProject, permission_mode: "default" });
|
||||
expect(screen.getByText(/cannot answer a permission prompt/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("edits an existing task, carrying its enabled state and warning about the new id", async () => {
|
||||
const { onSaved, onClose } = await renderEditor(existingTask);
|
||||
expect(field(/^name$/i).value).toBe("nightly");
|
||||
expect(field(/cron expression/i).value).toBe("0 3 * * *");
|
||||
expect(field(/working directory/i).value).toBe("/workspace/api");
|
||||
// The id changes on edit; the user is told before they save.
|
||||
expect(screen.getByText(/re-creates this task under a new id/i)).toBeInTheDocument();
|
||||
|
||||
fireEvent.change(field(/^name$/i), { target: { value: "nightly-v2" } });
|
||||
await submit();
|
||||
|
||||
expect(updateScheduledTask).toHaveBeenCalledWith(
|
||||
"p1",
|
||||
"a1b2c3d4",
|
||||
expect.objectContaining({ name: "nightly-v2", workingDir: "/workspace/api" }),
|
||||
false, // the task was disabled and must not come back enabled
|
||||
);
|
||||
expect(onSaved).toHaveBeenCalled();
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("surfaces a backend rejection instead of closing", async () => {
|
||||
addScheduledTask.mockRejectedValueOnce(new Error("Container is not running"));
|
||||
const { onClose } = await renderEditor();
|
||||
fireEvent.change(field(/^name$/i), { target: { value: "nightly" } });
|
||||
fireEvent.change(field(/^prompt$/i), { target: { value: "do the thing" } });
|
||||
await submit();
|
||||
|
||||
expect(screen.getByRole("alert")).toHaveTextContent(/Container is not running/);
|
||||
expect(onClose).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,329 @@
|
||||
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’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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
atTimestampIsPast,
|
||||
describeCron,
|
||||
validateAtTimestamp,
|
||||
validateCronExpression,
|
||||
validateTaskName,
|
||||
validateTaskPrompt,
|
||||
validateWorkingDir,
|
||||
MAX_TASK_NAME_LEN,
|
||||
MAX_TASK_PROMPT_LEN,
|
||||
} from "./taskValidation";
|
||||
|
||||
describe("task field validation", () => {
|
||||
it("requires a name that cannot be read as an option", () => {
|
||||
expect(validateTaskName("nightly")).toBeNull();
|
||||
expect(validateTaskName(" nightly ")).toBeNull();
|
||||
expect(validateTaskName("")).toMatch(/required/i);
|
||||
expect(validateTaskName(" ")).toMatch(/required/i);
|
||||
expect(validateTaskName("-id")).toMatch(/cannot start/i);
|
||||
expect(validateTaskName("--prompt")).toMatch(/cannot start/i);
|
||||
expect(validateTaskName("two\nlines")).toMatch(/single line/i);
|
||||
expect(validateTaskName("n".repeat(MAX_TASK_NAME_LEN + 1))).toMatch(/too long/i);
|
||||
});
|
||||
|
||||
it("treats shell syntax in a name or prompt as ordinary text", () => {
|
||||
// Nothing downstream is a shell, so these must not be rejected —
|
||||
// over-blocking would be its own bug.
|
||||
for (const value of ["; rm -rf /", "$(id)", "`id`", "a | b && c", "%pct"]) {
|
||||
expect(validateTaskName(value)).toBeNull();
|
||||
expect(validateTaskPrompt(value)).toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
it("requires a prompt and allows it to be multi-line", () => {
|
||||
expect(validateTaskPrompt("Run the tests\nthen report")).toBeNull();
|
||||
expect(validateTaskPrompt("")).toMatch(/required/i);
|
||||
expect(validateTaskPrompt(" \n ")).toMatch(/required/i);
|
||||
expect(validateTaskPrompt("p".repeat(MAX_TASK_PROMPT_LEN + 1))).toMatch(/too long/i);
|
||||
expect(validateTaskPrompt("bad\u0000nul")).toMatch(/unsupported/i);
|
||||
});
|
||||
|
||||
it("requires an absolute working directory, defaulting when blank", () => {
|
||||
expect(validateWorkingDir("")).toBeNull();
|
||||
expect(validateWorkingDir("/workspace/app")).toBeNull();
|
||||
expect(validateWorkingDir("workspace")).toMatch(/absolute/i);
|
||||
expect(validateWorkingDir("./rel")).toMatch(/absolute/i);
|
||||
expect(validateWorkingDir("~/home")).toMatch(/absolute/i);
|
||||
expect(validateWorkingDir("/workspace/../etc")).toMatch(/\.\./);
|
||||
});
|
||||
});
|
||||
|
||||
describe("cron validation", () => {
|
||||
// Every expression below was checked against the container's own
|
||||
// Debian/vixie `crontab` binary, which is the thing that ultimately accepts
|
||||
// or rejects the schedule.
|
||||
it("accepts expressions vixie cron accepts", () => {
|
||||
for (const good of [
|
||||
"* * * * *",
|
||||
"*/30 * * * *",
|
||||
"0 3 * * *",
|
||||
"0 9 * * 1-5",
|
||||
"0,30 9-17 * * 1-5",
|
||||
"15 0 1 1 *",
|
||||
"0 9 * * 0",
|
||||
"0 9 * * 7",
|
||||
"0 9 * * MON-FRI",
|
||||
"0 0 1 JAN *",
|
||||
"0-59/70 * * * *",
|
||||
"1-5/2 * * * *",
|
||||
"05 09 * * *",
|
||||
]) {
|
||||
expect(validateCronExpression(good), good).toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects expressions vixie cron rejects", () => {
|
||||
for (const bad of [
|
||||
"",
|
||||
"* * * *",
|
||||
"* * * * * *",
|
||||
"@daily",
|
||||
"not a cron",
|
||||
"99 * * * *",
|
||||
"0 24 * * *",
|
||||
"0 0 0 1 *",
|
||||
"0 9 * * 8",
|
||||
"0 9 * 13 *",
|
||||
"*/0 * * * *",
|
||||
"1/2 * * * *",
|
||||
"0 9 * * jan",
|
||||
"jan 9 * * *",
|
||||
"0 9 * * mon,",
|
||||
"0 9 * * 1--5",
|
||||
"0 9 * * 1-5/x",
|
||||
"0 9 * * *; rm -rf /",
|
||||
"$(id) * * * *",
|
||||
]) {
|
||||
expect(validateCronExpression(bad), bad).not.toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
it("matches the backend's message shape for the field count", () => {
|
||||
expect(validateCronExpression("* * * *")).toMatch(/exactly 5 fields/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("describeCron", () => {
|
||||
const cases: [string, string][] = [
|
||||
["* * * * *", "Every minute, every day."],
|
||||
["*/30 * * * *", "Every 30 minutes, every day."],
|
||||
["0 * * * *", "At :00 past every hour, every day."],
|
||||
["0,30 * * * *", "At :00 and :30 past every hour, every day."],
|
||||
["0 9 * * *", "At 09:00, every day."],
|
||||
["30 9 * * 1-5", "At 09:30, on Monday to Friday."],
|
||||
["0 8 * * 1", "At 08:00, on Monday."],
|
||||
["0 9 * * 0", "At 09:00, on Sunday."],
|
||||
// 7 is Sunday too, and must not read as an eighth day.
|
||||
["0 9 * * 7", "At 09:00, on Sunday."],
|
||||
["0 9,17 * * *", "At 09:00 and 17:00, every day."],
|
||||
["0 9-17 * * *", "At :00 past every hour from 09:00 to 17:00, every day."],
|
||||
["0 */2 * * *", "At :00 past every 2 hours, every day."],
|
||||
["0 0 1 * *", "At 00:00, on day 1 of the month."],
|
||||
["0 0 1 1 *", "At 00:00, on day 1 of the month in January."],
|
||||
["0 9 * * MON,THU", "At 09:00, on Monday and Thursday."],
|
||||
];
|
||||
|
||||
it.each(cases)("reads %s as %s", (expression, expected) => {
|
||||
expect(describeCron(expression)).toBe(expected);
|
||||
});
|
||||
|
||||
it("says nothing rather than guessing when the expression is invalid", () => {
|
||||
expect(describeCron("nope")).toBeNull();
|
||||
expect(describeCron("99 * * * *")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("one-shot timestamps", () => {
|
||||
it("accepts only the scheduler's own format", () => {
|
||||
expect(validateAtTimestamp("2026-12-25 09:05")).toBeNull();
|
||||
expect(validateAtTimestamp("")).toMatch(/required/i);
|
||||
// The scheduler's regex demands two digits everywhere.
|
||||
expect(validateAtTimestamp("2026-1-5 09:05")).toMatch(/YYYY-MM-DD/);
|
||||
expect(validateAtTimestamp("2026-12-25T09:05")).toMatch(/YYYY-MM-DD/);
|
||||
expect(validateAtTimestamp("2026-12-25 09:05:00")).toMatch(/YYYY-MM-DD/);
|
||||
expect(validateAtTimestamp("2026-02-30 09:05")).toMatch(/not a real/i);
|
||||
expect(validateAtTimestamp("2026-12-25 25:00")).toMatch(/not a real/i);
|
||||
});
|
||||
|
||||
it("flags a time in the past, because cron would fire it next year", () => {
|
||||
const now = new Date(2026, 5, 1, 12, 0);
|
||||
expect(atTimestampIsPast("2026-05-31 09:00", now)).toBe(true);
|
||||
expect(atTimestampIsPast("2026-06-01 12:01", now)).toBe(false);
|
||||
expect(atTimestampIsPast("nonsense", now)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,329 @@
|
||||
/**
|
||||
* Client-side mirror of the scheduled-task rules in
|
||||
* `src-tauri/src/commands/inspect_commands.rs`, plus a plain-English reading of
|
||||
* a cron expression.
|
||||
*
|
||||
* The backend remains the authority — it re-validates everything and is the
|
||||
* only thing standing between a prompt and the container. This module exists so
|
||||
* the form can say what is wrong *before* a round trip, and so the cron field
|
||||
* can show the user what they actually typed.
|
||||
*
|
||||
* The cron rules match Debian/vixie cron, which is what the container runs:
|
||||
* five fields, names in month and day-of-week only, day-of-week 0–7, and a
|
||||
* `/step` only after `*` or a range (vixie rejects `1/2`).
|
||||
*/
|
||||
|
||||
export const MAX_TASK_NAME_LEN = 100;
|
||||
export const MAX_TASK_PROMPT_LEN = 8000;
|
||||
export const MAX_WORKING_DIR_LEN = 512;
|
||||
export const DEFAULT_WORKING_DIR = "/workspace";
|
||||
|
||||
const MAX_CRON_LEN = 256;
|
||||
const MAX_CRON_STEP = 1000;
|
||||
|
||||
const MONTH_NAMES = [
|
||||
"jan", "feb", "mar", "apr", "may", "jun",
|
||||
"jul", "aug", "sep", "oct", "nov", "dec",
|
||||
];
|
||||
const DOW_NAMES = ["sun", "mon", "tue", "wed", "thu", "fri", "sat"];
|
||||
|
||||
const MONTH_LABELS = [
|
||||
"January", "February", "March", "April", "May", "June",
|
||||
"July", "August", "September", "October", "November", "December",
|
||||
];
|
||||
const DOW_LABELS = [
|
||||
"Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday",
|
||||
];
|
||||
|
||||
interface CronFieldSpec {
|
||||
label: string;
|
||||
min: number;
|
||||
max: number;
|
||||
names: string[];
|
||||
/** Numeric value of `names[0]` — 1 for January, 0 for Sunday. */
|
||||
nameBase: number;
|
||||
}
|
||||
|
||||
const CRON_FIELDS: CronFieldSpec[] = [
|
||||
{ label: "minute", min: 0, max: 59, names: [], nameBase: 0 },
|
||||
{ label: "hour", min: 0, max: 23, names: [], nameBase: 0 },
|
||||
{ label: "day of month", min: 1, max: 31, names: [], nameBase: 0 },
|
||||
{ label: "month", min: 1, max: 12, names: MONTH_NAMES, nameBase: 1 },
|
||||
{ label: "day of week", min: 0, max: 7, names: DOW_NAMES, nameBase: 0 },
|
||||
];
|
||||
|
||||
/** A handful of schedules that cover most of what people actually want. */
|
||||
export const CRON_PRESETS: { label: string; expression: string }[] = [
|
||||
{ label: "Every 30 minutes", expression: "*/30 * * * *" },
|
||||
{ label: "Hourly", expression: "0 * * * *" },
|
||||
{ label: "Daily at 09:00", expression: "0 9 * * *" },
|
||||
{ label: "Weekdays at 09:00", expression: "0 9 * * 1-5" },
|
||||
{ label: "Mondays at 08:00", expression: "0 8 * * 1" },
|
||||
];
|
||||
|
||||
// ── Field validation ─────────────────────────────────────────────────────────
|
||||
|
||||
/** `null` means valid; otherwise the message to show under the field. */
|
||||
export type FieldError = string | null;
|
||||
|
||||
/** C0 and C1 control characters. */
|
||||
// eslint-disable-next-line no-control-regex
|
||||
const CONTROL_CHARS = /[\u0000-\u001F\u007F-\u009F]/;
|
||||
/** The same, minus tab / LF / CR — a multi-line prompt is normal. */
|
||||
// eslint-disable-next-line no-control-regex
|
||||
const CONTROL_CHARS_EXCEPT_WHITESPACE =
|
||||
/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F-\u009F]/;
|
||||
|
||||
const hasControlChars = (value: string, allowNewlines: boolean) =>
|
||||
(allowNewlines ? CONTROL_CHARS_EXCEPT_WHITESPACE : CONTROL_CHARS).test(value);
|
||||
|
||||
export function validateTaskName(name: string): FieldError {
|
||||
const trimmed = name.trim();
|
||||
if (!trimmed) return "Task name is required.";
|
||||
if ([...trimmed].length > MAX_TASK_NAME_LEN)
|
||||
return `Task name is too long (max ${MAX_TASK_NAME_LEN} characters).`;
|
||||
if (hasControlChars(trimmed, false)) return "Task name must be a single line.";
|
||||
if (trimmed.startsWith("-")) return "Task name cannot start with “-”.";
|
||||
return null;
|
||||
}
|
||||
|
||||
export function validateTaskPrompt(prompt: string): FieldError {
|
||||
const trimmed = prompt.trim();
|
||||
if (!trimmed) return "Task prompt is required.";
|
||||
if ([...trimmed].length > MAX_TASK_PROMPT_LEN)
|
||||
return `Task prompt is too long (max ${MAX_TASK_PROMPT_LEN} characters).`;
|
||||
if (hasControlChars(trimmed, true)) return "Task prompt contains an unsupported character.";
|
||||
return null;
|
||||
}
|
||||
|
||||
export function validateWorkingDir(dir: string): FieldError {
|
||||
const trimmed = dir.trim();
|
||||
if (!trimmed) return null; // Blank falls back to /workspace, as the CLI does.
|
||||
if ([...trimmed].length > MAX_WORKING_DIR_LEN)
|
||||
return `Working directory is too long (max ${MAX_WORKING_DIR_LEN} characters).`;
|
||||
if (hasControlChars(trimmed, false)) return "Working directory must be a single line.";
|
||||
if (!trimmed.startsWith("/"))
|
||||
return "Working directory must be an absolute path inside the container, e.g. /workspace.";
|
||||
if (trimmed.split("/").includes("..")) return "Working directory cannot contain “..”.";
|
||||
return null;
|
||||
}
|
||||
|
||||
// ── Cron ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
function cronValue(spec: CronFieldSpec, token: string): number | null {
|
||||
if (token.length > 0 && /^[0-9]+$/.test(token)) {
|
||||
const value = Number(token);
|
||||
return value >= spec.min && value <= spec.max ? value : null;
|
||||
}
|
||||
const index = spec.names.indexOf(token.toLowerCase());
|
||||
return index >= 0 ? index + spec.nameBase : null;
|
||||
}
|
||||
|
||||
function validateCronElement(spec: CronFieldSpec, element: string): FieldError {
|
||||
if (!element) return `Empty value in the ${spec.label} field.`;
|
||||
|
||||
const slash = element.indexOf("/");
|
||||
const base = slash === -1 ? element : element.slice(0, slash);
|
||||
|
||||
if (slash !== -1) {
|
||||
const raw = element.slice(slash + 1);
|
||||
if (!/^[0-9]{1,4}$/.test(raw))
|
||||
return `“${element}” in the ${spec.label} field: a step must be a number, like */5.`;
|
||||
const step = Number(raw);
|
||||
if (step < 1 || step > MAX_CRON_STEP)
|
||||
return `“${element}” in the ${spec.label} field: a step must be between 1 and ${MAX_CRON_STEP}.`;
|
||||
if (base !== "*" && !base.includes("-"))
|
||||
return `“${element}” in the ${spec.label} field: a step can only follow * or a range, like */5 or 1-5/2.`;
|
||||
}
|
||||
|
||||
if (base === "*") return null;
|
||||
|
||||
const dash = base.indexOf("-");
|
||||
const tokens = dash === -1 ? [base] : [base.slice(0, dash), base.slice(dash + 1)];
|
||||
for (const token of tokens) {
|
||||
if (cronValue(spec, token) === null) {
|
||||
return /^[0-9]+$/.test(token)
|
||||
? `“${token}” is out of range for the ${spec.label} field (${spec.min}–${spec.max}).`
|
||||
: `“${token}” is not valid in the ${spec.label} field.`;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function validateCronExpression(expression: string): FieldError {
|
||||
if (expression.length > MAX_CRON_LEN)
|
||||
return `Cron expression is too long (max ${MAX_CRON_LEN} characters).`;
|
||||
const fields = expression.trim().split(/\s+/).filter(Boolean);
|
||||
if (fields.length !== 5)
|
||||
return `A cron schedule needs exactly 5 fields (minute hour day-of-month month day-of-week); got ${fields.length}.`;
|
||||
|
||||
for (let i = 0; i < 5; i++) {
|
||||
for (const element of fields[i].split(",")) {
|
||||
const error = validateCronElement(CRON_FIELDS[i], element);
|
||||
if (error) return error;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Matches the scheduler's own `--at` regex, then checks it is a real instant. */
|
||||
export function validateAtTimestamp(at: string): FieldError {
|
||||
const trimmed = at.trim();
|
||||
if (!trimmed) return "A date and time is required.";
|
||||
const match = /^(\d{4})-(\d{2})-(\d{2}) (\d{2}):(\d{2})$/.exec(trimmed);
|
||||
if (!match) return "Use the format YYYY-MM-DD HH:MM, e.g. 2026-12-25 09:05.";
|
||||
const [, y, mo, d, h, mi] = match.map(Number);
|
||||
const date = new Date(y, mo - 1, d, h, mi);
|
||||
const real =
|
||||
date.getFullYear() === y &&
|
||||
date.getMonth() === mo - 1 &&
|
||||
date.getDate() === d &&
|
||||
date.getHours() === h &&
|
||||
date.getMinutes() === mi;
|
||||
return real ? null : "That is not a real date and time.";
|
||||
}
|
||||
|
||||
/** `true` when a valid one-shot time has already passed (a warning, not an error). */
|
||||
export function atTimestampIsPast(at: string, now: Date = new Date()): boolean {
|
||||
if (validateAtTimestamp(at)) return false;
|
||||
const [datePart, timePart] = at.trim().split(" ");
|
||||
const [y, mo, d] = datePart.split("-").map(Number);
|
||||
const [h, mi] = timePart.split(":").map(Number);
|
||||
return new Date(y, mo - 1, d, h, mi).getTime() < now.getTime();
|
||||
}
|
||||
|
||||
// ── Plain-English reading of a cron expression ───────────────────────────────
|
||||
|
||||
const pad = (n: number) => String(n).padStart(2, "0");
|
||||
|
||||
function joinList(items: string[]): string {
|
||||
if (items.length <= 1) return items[0] ?? "";
|
||||
if (items.length === 2) return `${items[0]} and ${items[1]}`;
|
||||
return `${items.slice(0, -1).join(", ")} and ${items[items.length - 1]}`;
|
||||
}
|
||||
|
||||
/** The step of a bare `*/n` field, or `null` for anything else. */
|
||||
function simpleStep(field: string): number | null {
|
||||
const match = /^\*\/([0-9]+)$/.exec(field);
|
||||
return match ? Number(match[1]) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Every value a (already valid) field selects, or `null` for "all of them".
|
||||
* Bounded by the field's own range, so this cannot run away.
|
||||
*/
|
||||
function expandField(spec: CronFieldSpec, field: string): number[] | null {
|
||||
if (field === "*") return null;
|
||||
const values = new Set<number>();
|
||||
for (const element of field.split(",")) {
|
||||
const slash = element.indexOf("/");
|
||||
const base = slash === -1 ? element : element.slice(0, slash);
|
||||
const step = slash === -1 ? 1 : Number(element.slice(slash + 1));
|
||||
|
||||
let from: number;
|
||||
let to: number;
|
||||
if (base === "*") {
|
||||
from = spec.min;
|
||||
to = spec.max;
|
||||
} else {
|
||||
const dash = base.indexOf("-");
|
||||
if (dash === -1) {
|
||||
from = to = cronValue(spec, base) as number;
|
||||
} else {
|
||||
from = cronValue(spec, base.slice(0, dash)) as number;
|
||||
to = cronValue(spec, base.slice(dash + 1)) as number;
|
||||
}
|
||||
}
|
||||
for (let v = from; v <= to; v += step) values.add(v);
|
||||
}
|
||||
const sorted = [...values].sort((a, b) => a - b);
|
||||
// A field that names every value reads better as "every".
|
||||
return sorted.length >= spec.max - spec.min + 1 ? null : sorted;
|
||||
}
|
||||
|
||||
const isContiguous = (values: number[]) =>
|
||||
values.every((v, i) => i === 0 || v === values[i - 1] + 1);
|
||||
|
||||
function timePhrase(
|
||||
minutes: number[] | null,
|
||||
hours: number[] | null,
|
||||
minuteField: string,
|
||||
hourField: string,
|
||||
): string {
|
||||
if (minutes === null && hours === null) return "Every minute";
|
||||
|
||||
if (hours === null) {
|
||||
const step = simpleStep(minuteField);
|
||||
if (step !== null) return step === 1 ? "Every minute" : `Every ${step} minutes`;
|
||||
return `At ${joinList((minutes as number[]).map((m) => `:${pad(m)}`))} past every hour`;
|
||||
}
|
||||
|
||||
if (minutes === null) {
|
||||
return `Every minute of ${joinList(hours.map((h) => `${pad(h)}:00`))}`;
|
||||
}
|
||||
|
||||
const hourStep = simpleStep(hourField);
|
||||
if (hourStep !== null && minutes.length === 1) {
|
||||
return `At :${pad(minutes[0])} past every ${hourStep === 1 ? "hour" : `${hourStep} hours`}`;
|
||||
}
|
||||
if (minutes.length === 1 && hours.length >= 3 && isContiguous(hours)) {
|
||||
return `At :${pad(minutes[0])} past every hour from ${pad(hours[0])}:00 to ${pad(
|
||||
hours[hours.length - 1],
|
||||
)}:00`;
|
||||
}
|
||||
|
||||
const times: string[] = [];
|
||||
for (const h of hours) for (const m of minutes) times.push(`${pad(h)}:${pad(m)}`);
|
||||
if (times.length <= 6) return `At ${joinList(times)}`;
|
||||
return `At minute ${joinList(minutes.map(String))} of hour ${joinList(hours.map(String))}`;
|
||||
}
|
||||
|
||||
function weekdayPhrase(dows: number[]): string {
|
||||
const labels = dows.map((d) => DOW_LABELS[d]);
|
||||
if (dows.length >= 3 && isContiguous(dows))
|
||||
return `${labels[0]} to ${labels[labels.length - 1]}`;
|
||||
return joinList(labels);
|
||||
}
|
||||
|
||||
function dayPhrase(doms: number[] | null, dows: number[] | null): string {
|
||||
if (doms === null && dows === null) return "every day";
|
||||
if (dows !== null && doms === null) return `on ${weekdayPhrase(dows)}`;
|
||||
if (doms !== null && dows === null)
|
||||
return `on day ${joinList(doms.map(String))} of the month`;
|
||||
// Cron ORs the two day fields when both are restricted.
|
||||
return `on day ${joinList((doms as number[]).map(String))} of the month or on ${weekdayPhrase(
|
||||
dows as number[],
|
||||
)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a cron expression back to the user in English, or `null` if it is not
|
||||
* valid. Deliberately a *reading*, not a scheduler: it never claims to know the
|
||||
* next run time.
|
||||
*/
|
||||
export function describeCron(expression: string): string | null {
|
||||
if (validateCronExpression(expression)) return null;
|
||||
const [minuteField, hourField, domField, monthField, dowField] = expression
|
||||
.trim()
|
||||
.split(/\s+/);
|
||||
|
||||
const minutes = expandField(CRON_FIELDS[0], minuteField);
|
||||
const hours = expandField(CRON_FIELDS[1], hourField);
|
||||
const doms = expandField(CRON_FIELDS[2], domField);
|
||||
const months = expandField(CRON_FIELDS[3], monthField);
|
||||
|
||||
let dows = expandField(CRON_FIELDS[4], dowField);
|
||||
if (dows) {
|
||||
// 0 and 7 are both Sunday.
|
||||
dows = [...new Set(dows.map((d) => (d === 7 ? 0 : d)))].sort((a, b) => a - b);
|
||||
if (dows.length === 7) dows = null;
|
||||
}
|
||||
|
||||
const monthPart =
|
||||
months === null ? "" : ` in ${joinList(months.map((m) => MONTH_LABELS[m - 1]))}`;
|
||||
|
||||
return `${timePhrase(minutes, hours, minuteField, hourField)}, ${dayPhrase(
|
||||
doms,
|
||||
dows,
|
||||
)}${monthPart}.`;
|
||||
}
|
||||
Reference in New Issue
Block a user