Two independent sets of fixes.
## Disk: stop the growth, no UI this round
The dangling-snapshot sweep was already correct and was never the leak. The
leak is that every `docker commit` **stacks** a layer and nothing compacts one:
a file deleted after it has been committed becomes a whiteout, not free bytes.
24 conditions trigger recreation+commit, so changing one settings field costs a
multi-gigabyte layer for the life of the project. One project was measured with
14 stacked commit layers, ~5.1 GB above its base.
* **Scrub the writable layer before every commit** (`docker/container.rs`,
`SNAPSHOT_SCRUB_PATHS` / `scrub_writable_layer`). The one moment those bytes
are still free to drop is before the commit that captures them. Measured on
one container's 4.48 GB pending layer: 3.0 GB of agent scratchpad under
`/tmp/claude-*`, the terminal drag-drop staging area (256 MiB per file, with
no `rm` for it anywhere in the repo), a PNG per pasted image, and the apt
lists/cache/logs that `browser_view/install.rs` and `triple-c-playwright-heal`
leave behind with no `apt-get clean`. A hardcoded list, never a heuristic:
`/workspace/{mount_name}` is a host bind mount and nothing here may reach one,
and the three `/tmp` globs cannot select the read-only `.host-ca`/`.host-aws`
mounts. Failure is a log line — a scrub must never block a snapshot.
* **Cap container logs** (`capped_log_config`). There was no `LogConfig`
anywhere, so containers ran on the daemon's unbounded `json-file` default.
Deliberately *not* wired into `container_needs_recreation`: participating
would recreate every project once, and a recreation costs a commit, which is
the thing being fixed. Picked up on the next natural recreation.
* **Make superseded base images sweepable** (`container/Dockerfile`). It carried
no `LABEL` at all, so `orphan_sweep_filters`' `dangling` + `triple-c.managed`
pair provably could not match one — ~11.9 GB observed stranded. Stamping
`triple-c.managed=true` is the whole fix; the sweep needed no change.
`create_container` writes the new `triple-c.base` key explicitly empty, or
Docker's label inheritance plus `docker commit` would make every snapshot
claim to be a base image. `force: false` stays, and now says why.
* **Sweep at startup** (`lib.rs`), not only after recreation: probes first
(a probe pins an image the unforced sweep then refuses), pins second, sweep
last. `sweep_orphaned_snapshots_logged` exists because all three callers threw
the report away — `reclaimed_bytes`, `failed` and `unavailable` included.
* **Reap migration leftovers.** `rollback_migration` retagged and orphaned the
migrated snapshot with no sweep. Stale `pre-migration-*` pins are now
age-reaped by scanning the tag pattern rather than trusting the state file —
`migration_store::load` reports an unparseable record as absent, which
stranded a 4-12 GB pin nothing could name again; `load` now moves a corrupt
record aside so `has_record` is trustworthy. A pin whose migration is still
awaiting confirmation is never reaped at any age. The probe container's
removal was a plain statement after an await, so a dropped future (an app quit
mid-migration) leaked a container pinning a multi-gigabyte image; it is a
`Drop` guard now, with `reap_probe_containers` for the case where the process
itself dies.
* **Prune scheduler logs.** `remove` deleted a task's JSON but never its log
directory, and the task runner appended uncapped `claude -p` output.
* **Fix the delete copy.** It said "the container, config volume, and stored
credentials"; it removes *both* volumes and the snapshot image.
No prune UI, and no unfiltered `prune_images`/`prune_volumes` anywhere — the
daemon is shared with the user's unrelated work.
## Claude Code settings: two invented keys, one inverted default, one sticky bug
Verified against code.claude.com/docs/en/settings-reference.md and env-vars.md.
* `effort` -> **`effortLevel`**, the key Claude Code actually reads; the old one
was written and silently ignored. `xhigh` added to the dropdown.
* `focusMode` -> **`viewMode: "focus"`**. `focusMode` was invented. The real key
does exactly what the existing UI hint already described.
* **Session recap was inverted.** Claude Code's recap is on by default, so
`CLAUDE_CODE_ENABLE_AWAY_SUMMARY=1`-when-enabled was a no-op and the control
could never turn the recap *off*. The field is renamed to
`session_recap_disabled` rather than reused: reusing the name with the
opposite meaning would have read every stored `enable_session_recap: false` —
which is every project that never touched the control — as "the user turned
this off".
* **The stickiness, which is the important one.** Keys were emitted only when
non-default, and the entrypoint *merges* into a settings.json on a persisted
volume, so switching a setting off omitted its key, the merge preserved the
stale on-value, and the setting stayed on until a destructive Reset. The fix
already existed in the same file — the sandbox block is emitted
unconditionally for exactly this reason — and is now applied to all five keys.
A key whose neutral state is *unset* (`tui`, `effortLevel`, `viewMode`,
`awaySummaryEnabled`) is emitted as JSON `null` and the entrypoint deletes it,
because a stand-in value is not neutral: `tui: "default"` pins the classic
renderer where unset lets Claude Code choose, and `viewMode: "default"`
overrides the user's own sticky `/focus` choice.
* The same stickiness existed, unnoticed, in the **env vars**: `docker commit`
bakes container env into the snapshot image, so a `=1` written once rode it
forever. All four are now emitted on every create, extracted into
`claude_code_env_vars` and unit tested. Two use an empty value for "off"
rather than `0`, because they outrank a setting the user can change from
inside their own container and Triple-C's default must not overrule a
`/config` choice it never asked about.
* TUI mode is now a genuine three-way choice (automatic / classic / fullscreen),
which the always-emitted key makes both necessary and possible.
`merge_claude_code_settings` is untouched by choice: a project-level OFF still
cannot override a globally-ON setting.
Tests: 364 frontend (+5), 308 Rust (+23), covering the scrub path list and
script, log rotation, pin reaping, and that toggling a setting off actually
clears a previously-set ON value.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBq2rGum6GX7xXgsas1fDc
342 lines
12 KiB
TypeScript
342 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’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 && (
|
|
/*
|
|
An edit is `add` then `remove` (see `update_scheduled_task`), and
|
|
`triple-c-scheduler`'s remove now reaps the task's log directory —
|
|
so on a current container the old logs are gone, not merely filed
|
|
under the old id, which is what this used to promise.
|
|
|
|
It is deliberately not stated as a certainty. `/usr/local/bin` only
|
|
changes on base-image migration or Reset, so a project still running
|
|
an older base image carries the older scheduler, whose remove leaves
|
|
the log directory behind. "Assume they go with it" is true in both
|
|
worlds and spares the user a paragraph about which one they are in.
|
|
*/
|
|
<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>. Assume its earlier run logs go
|
|
with it.
|
|
</p>
|
|
)}
|
|
|
|
{submitError && (
|
|
<p role="alert" className="text-xs text-[var(--error)] whitespace-pre-wrap break-words">
|
|
{submitError}
|
|
</p>
|
|
)}
|
|
</form>
|
|
</Modal>
|
|
);
|
|
}
|