Files
Triple-C/app/src/components/projects/home/TaskEditorModal.tsx
T
shadow-testandClaude Opus 5 dd2894cc60 Stop Docker disk growth, and fix the Claude Code settings that never worked
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
2026-08-23 08:35:10 -07:00

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&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 && (
/*
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>
);
}