Merge branch 'feat/disk-and-settings' into integration/round-1
This commit is contained in:
@@ -1,5 +1,24 @@
|
||||
FROM ubuntu:24.04
|
||||
|
||||
# ── Provenance labels ────────────────────────────────────────────────────────
|
||||
# Without these the base image carries no labels at all, and
|
||||
# `sweep_orphaned_snapshots` (app/src-tauri/src/docker/container.rs) filters on
|
||||
# `dangling=true` **and** `triple-c.managed=true` — so a superseded base image,
|
||||
# left untagged when a newer build claims `triple-c-sandbox:latest`, could never
|
||||
# match and was never collected. ~11.9 GB of stranded base images was measured
|
||||
# on one developer's daemon this way.
|
||||
#
|
||||
# `triple-c.managed=true` is what makes them sweepable. Note that Docker merges
|
||||
# an image's labels into the containers created from it and `docker commit`
|
||||
# copies a container's labels onto the image, so this value also arrives on
|
||||
# every container and every snapshot — which is harmless, because
|
||||
# `create_container` writes the same key explicitly anyway.
|
||||
#
|
||||
# `triple-c.base=true` marks *this* image specifically, so a base image can be
|
||||
# told apart from a project snapshot without parsing repository names.
|
||||
LABEL triple-c.managed=true
|
||||
LABEL triple-c.base=true
|
||||
|
||||
# Multi-arch: builds for linux/amd64 and linux/arm64 (Apple Silicon)
|
||||
# Avoid interactive prompts during package install
|
||||
ENV DEBIAN_FRONTEND=noninteractive
|
||||
|
||||
+27
-12
@@ -405,22 +405,37 @@ install_feature_skill pia-vpn "${VPN_SUPPORT_ENABLED:-0}"
|
||||
unset VPN_SUPPORT_ENABLED
|
||||
|
||||
# ── Claude Code settings ────────────────────────────────────────────────────
|
||||
# Merge Claude Code settings into ~/.claude/settings.json (preserves existing
|
||||
# keys). Creates the file if it doesn't exist. These control TUI mode, effort
|
||||
# level, focus mode, thinking summaries, and other CLI behavior.
|
||||
# Apply the managed Claude Code settings to ~/.claude/settings.json, keeping
|
||||
# every key the user set inside the container.
|
||||
#
|
||||
# `settings.json` lives on the persisted triple-c-claude-config-{id} volume, so
|
||||
# it outlives the container and a plain `.[0] * .[1]` merge could only ever
|
||||
# *add*. That is what made every one of these settings one-way: switching one
|
||||
# off in Triple-C omitted its key, the merge preserved the old on-value, and the
|
||||
# setting stayed on until a destructive Reset. So the payload from Rust states
|
||||
# the whole managed key set on every start, and a JSON **null** in it means
|
||||
# "delete this key" rather than "merge a null" — which is how a setting whose
|
||||
# neutral state is *unset* (`tui`, `effortLevel`, `viewMode`,
|
||||
# `awaySummaryEnabled`) is turned back off without pinning a stand-in value.
|
||||
# See `build_claude_code_settings_json` in app/src-tauri/src/docker/container.rs.
|
||||
if [ -n "$CLAUDE_CODE_SETTINGS_JSON" ]; then
|
||||
SETTINGS_FILE="/home/claude/.claude/settings.json"
|
||||
mkdir -p /home/claude/.claude
|
||||
if [ -f "$SETTINGS_FILE" ]; then
|
||||
# Merge: existing settings + new settings (new keys override on conflict)
|
||||
MERGED=$(jq -s '.[0] * .[1]' "$SETTINGS_FILE" <(printf '%s' "$CLAUDE_CODE_SETTINGS_JSON") 2>/dev/null)
|
||||
if [ -n "$MERGED" ]; then
|
||||
printf '%s\n' "$MERGED" > "$SETTINGS_FILE"
|
||||
else
|
||||
echo "entrypoint: warning — failed to merge Claude Code settings into $SETTINGS_FILE"
|
||||
fi
|
||||
# One code path for "file exists" and "file doesn't": seeding an empty
|
||||
# object means the null-deleting merge below runs in both cases, so a fresh
|
||||
# container never gets a settings.json with literal nulls written into it.
|
||||
[ -f "$SETTINGS_FILE" ] || printf '{}\n' > "$SETTINGS_FILE"
|
||||
MERGED=$(jq -s '
|
||||
.[0] as $current
|
||||
| .[1] as $managed
|
||||
| ($managed | with_entries(select(.value != null))) as $set
|
||||
| ($managed | to_entries | map(select(.value == null) | [.key])) as $clear
|
||||
| ($current * $set) | delpaths($clear)
|
||||
' "$SETTINGS_FILE" <(printf '%s' "$CLAUDE_CODE_SETTINGS_JSON") 2>/dev/null)
|
||||
if [ -n "$MERGED" ]; then
|
||||
printf '%s\n' "$MERGED" > "$SETTINGS_FILE"
|
||||
else
|
||||
printf '%s\n' "$CLAUDE_CODE_SETTINGS_JSON" > "$SETTINGS_FILE"
|
||||
echo "entrypoint: warning — failed to merge Claude Code settings into $SETTINGS_FILE"
|
||||
fi
|
||||
chown claude:claude "$SETTINGS_FILE"
|
||||
chmod 600 "$SETTINGS_FILE"
|
||||
|
||||
@@ -20,6 +20,27 @@ generate_id() {
|
||||
head -c 4 /dev/urandom | od -An -tx1 | tr -d ' \n'
|
||||
}
|
||||
|
||||
# Delete a task's log directory, called wherever a task stops existing.
|
||||
#
|
||||
# The task file is the only index of a task, so a log directory that outlives
|
||||
# it is unreachable — `logs --id` needs an id nothing can hand you any more —
|
||||
# and it sits on the home volume for the life of the project. The moment of
|
||||
# removal is the last point at which we still know what to delete.
|
||||
#
|
||||
# The `rm -rf` deserves paranoia, so the id is re-validated here rather than
|
||||
# trusted from the caller: the pattern rejects an empty id (which would expand
|
||||
# to $LOGS_DIR itself), anything containing `/` or `.` (which could climb out
|
||||
# of $LOGS_DIR), and a leading `-`. It matches validate_task_id() in
|
||||
# app/src-tauri/src/commands/inspect_commands.rs. Always one literal path,
|
||||
# never a glob.
|
||||
reap_task_logs() {
|
||||
local id="${1:-}"
|
||||
[[ "$id" =~ ^[A-Za-z0-9][A-Za-z0-9_-]*$ ]] || return 0
|
||||
local dir="${LOGS_DIR:?}/${id}"
|
||||
[ -d "$dir" ] || return 0
|
||||
rm -rf -- "$dir"
|
||||
}
|
||||
|
||||
# Live run state for a task: prints "pid<TAB>started_epoch<TAB>log" and returns
|
||||
# 0 when the task is genuinely running, returns 1 otherwise.
|
||||
#
|
||||
@@ -292,6 +313,7 @@ cmd_remove() {
|
||||
local name
|
||||
name=$(jq -r '.name' "$task_file")
|
||||
rm -f "$task_file"
|
||||
reap_task_logs "$id"
|
||||
rebuild_crontab
|
||||
echo "Removed task '$name' ($id)"
|
||||
}
|
||||
|
||||
@@ -125,6 +125,37 @@ fi
|
||||
echo "=== Exit code: $EXIT_CODE ==="
|
||||
} >> "$LOG_FILE"
|
||||
|
||||
# ── Cap the size of this run's log ──────────────────────────────────────────
|
||||
# `claude -p` output is unbounded — a task told to walk a large tree can emit
|
||||
# hundreds of megabytes in one run — and the pruning below counts *files*, not
|
||||
# bytes, so twenty logs of any size are twenty logs. One chatty task can
|
||||
# therefore fill the home volume, which is also where ~/.claude and the OAuth
|
||||
# credential live.
|
||||
#
|
||||
# The tail is the half worth keeping: `claude -p` writes its answer at the end,
|
||||
# and the footer just appended carries the exit code that `status` and the app
|
||||
# both grep for. So an oversize log is rewritten as a marker line plus its last
|
||||
# MAX_LOG_BYTES rather than being deleted or capped from the front. This runs
|
||||
# before the notification below so the summary is taken from the capped file.
|
||||
#
|
||||
# Best effort throughout: the run's real result is already recorded, so a
|
||||
# failure here must not change the exit status. Note that `run` may be tailing
|
||||
# this file — it has already streamed everything up to here, and nothing is
|
||||
# appended after this point, so replacing the inode is invisible to it.
|
||||
MAX_LOG_BYTES=$(( 5 * 1024 * 1024 ))
|
||||
LOG_BYTES=$(wc -c < "$LOG_FILE" 2>/dev/null || echo 0)
|
||||
if [ "${LOG_BYTES:-0}" -gt "$MAX_LOG_BYTES" ]; then
|
||||
TRUNC_FILE="${LOG_FILE}.trunc"
|
||||
if {
|
||||
echo "=== Log truncated: $(( LOG_BYTES - MAX_LOG_BYTES )) bytes dropped from the start (cap ${MAX_LOG_BYTES} bytes) ==="
|
||||
tail -c "$MAX_LOG_BYTES" "$LOG_FILE"
|
||||
} > "$TRUNC_FILE" 2>/dev/null; then
|
||||
mv -f "$TRUNC_FILE" "$LOG_FILE" 2>/dev/null || rm -f "$TRUNC_FILE"
|
||||
else
|
||||
rm -f "$TRUNC_FILE"
|
||||
fi
|
||||
fi
|
||||
|
||||
# ── Write notification ──────────────────────────────────────────────────────
|
||||
mkdir -p "$NOTIFICATIONS_DIR"
|
||||
NOTIFY_FILE="${NOTIFICATIONS_DIR}/${TASK_ID}_${TIMESTAMP}.notify"
|
||||
@@ -176,6 +207,35 @@ if [ "$LOG_COUNT" -gt 20 ]; then
|
||||
find "$TASK_LOG_DIR" -name "*.log" -type f | sort | head -n $((LOG_COUNT - 20)) | xargs rm -f
|
||||
fi
|
||||
|
||||
# ── Reap log dirs of tasks that no longer exist ─────────────────────────────
|
||||
# `triple-c-scheduler remove` deletes a task's log dir with the task, but a
|
||||
# one-time task deletes its own task file above, so `remove` can never be run
|
||||
# for it — nothing knows the id any more — and its directory would sit on the
|
||||
# home volume forever. This is the sweep for that case.
|
||||
#
|
||||
# Deliberately delayed rather than done in the cleanup above: the run that just
|
||||
# finished has only just written the sole record of itself, `run` and the app's
|
||||
# Automation tab may still be tailing it, and `logs --id` keeps working for a
|
||||
# task whose file is gone. So a dir is reaped only once nothing in it has been
|
||||
# touched for LOG_RETENTION_DAYS, and never while a run is publishing state for
|
||||
# that id. The sweep rides on task runs, so a container whose only task was
|
||||
# one-time keeps that one directory until something else runs.
|
||||
#
|
||||
# Same paranoia as reap_task_logs() in triple-c-scheduler: the id comes from a
|
||||
# directory name and is re-validated before it is used to build an `rm -rf`
|
||||
# path, so no empty or path-bearing name can reach beyond $LOGS_DIR.
|
||||
LOG_RETENTION_DAYS=7
|
||||
for ORPHAN_DIR in "$LOGS_DIR"/*/; do
|
||||
[ -d "$ORPHAN_DIR" ] || continue
|
||||
ORPHAN_ID=$(basename "$ORPHAN_DIR")
|
||||
[[ "$ORPHAN_ID" =~ ^[A-Za-z0-9][A-Za-z0-9_-]*$ ]] || continue
|
||||
[ -f "${TASKS_DIR}/${ORPHAN_ID}.json" ] && continue
|
||||
[ -f "${RUNNING_DIR}/${ORPHAN_ID}.json" ] && continue
|
||||
# Anything modified inside the window keeps the whole directory.
|
||||
[ -n "$(find "$ORPHAN_DIR" -mmin "-$(( LOG_RETENTION_DAYS * 1440 ))" -print -quit 2>/dev/null)" ] && continue
|
||||
rm -rf -- "${LOGS_DIR:?}/${ORPHAN_ID}"
|
||||
done
|
||||
|
||||
# ── Prune old notifications (keep 50 total) ─────────────────────────────────
|
||||
NOTIFY_COUNT=$(find "$NOTIFICATIONS_DIR" -name "*.notify" -type f 2>/dev/null | wc -l)
|
||||
if [ "$NOTIFY_COUNT" -gt 50 ]; then
|
||||
|
||||
Reference in New Issue
Block a user