Build App (Preview) / compute-version (pull_request) Successful in 7s
Build Container / build-container (pull_request) Successful in 2m53s
Build App (Preview) / create-release (pull_request) Successful in 5s
Build App (Preview) / build-macos (pull_request) Successful in 2m37s
Build App (Preview) / build-windows (pull_request) Successful in 6m2s
Build App (Preview) / build-linux (pull_request) Successful in 6m53s
Build App (Preview) / prune-previews (pull_request) Successful in 2s
A run is detached — cron has no terminal, and the app fires it as a detached exec — so triggering one and watching the log was indistinguishable from triggering one that died. Worse, `claude -p` writes its answer in a single burst at the end, so a healthy run shows nothing but its log header for as long as it is thinking. The honest reading of the old UI was "it stalled". triple-c-task-runner now publishes a state file per run (pid, start time, log path) and removes it from an EXIT trap. flock remains what actually prevents overlapping runs; this is purely observability, so every reader verifies the pid rather than trusting the file — a container stopped mid-run cannot fire a trap, and a task stuck on "running" forever would be a worse lie than no indicator at all. Stale files are cleared on read. On top of that: - `list` grows a status column: "running 4m12s" or "idle". - `status [--id] [--watch]` answers "is it still going?" directly, with elapsed time and the tail of the log when there is any output yet. - `run` streams the log instead of blocking silently, and refuses to start a task that is already running. - The Automation tab marks a running task, disables its Run now button, and polls while anything is in flight — including the second or two between firing a run and the runner registering it, which is the exact window that used to read as dead. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
651 lines
20 KiB
Bash
651 lines
20 KiB
Bash
#!/bin/bash
|
|
# triple-c-scheduler — CLI for managing scheduled tasks in Triple-C containers
|
|
# Tasks are stored as JSON files and crontab is rebuilt from them as the source of truth.
|
|
|
|
set -euo pipefail
|
|
|
|
SCHEDULER_DIR="${HOME}/.claude/scheduler"
|
|
TASKS_DIR="${SCHEDULER_DIR}/tasks"
|
|
LOGS_DIR="${SCHEDULER_DIR}/logs"
|
|
NOTIFICATIONS_DIR="${SCHEDULER_DIR}/notifications"
|
|
RUNNING_DIR="${SCHEDULER_DIR}/running"
|
|
|
|
# ── Helpers ──────────────────────────────────────────────────────────────────
|
|
|
|
ensure_dirs() {
|
|
mkdir -p "$TASKS_DIR" "$LOGS_DIR" "$NOTIFICATIONS_DIR" "$RUNNING_DIR"
|
|
}
|
|
|
|
generate_id() {
|
|
head -c 4 /dev/urandom | od -An -tx1 | tr -d ' \n'
|
|
}
|
|
|
|
# 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.
|
|
#
|
|
# triple-c-task-runner writes the file and removes it from an EXIT trap, but a
|
|
# trap cannot fire for SIGKILL or a container stop mid-run. So the pid is
|
|
# checked rather than believed, and a state file whose process is gone is
|
|
# cleared here — otherwise one hard stop leaves a task reading as "running"
|
|
# forever, which is worse than no indicator at all.
|
|
run_state() {
|
|
local id="$1"
|
|
local state_file="${RUNNING_DIR}/${id}.json"
|
|
[ -f "$state_file" ] || return 1
|
|
|
|
local pid
|
|
pid=$(jq -r '.pid // empty' "$state_file" 2>/dev/null)
|
|
if [ -z "$pid" ] || ! kill -0 "$pid" 2>/dev/null; then
|
|
rm -f "$state_file"
|
|
return 1
|
|
fi
|
|
|
|
printf '%s\t%s\t%s\n' \
|
|
"$pid" \
|
|
"$(jq -r '.started_epoch // 0' "$state_file")" \
|
|
"$(jq -r '.log // ""' "$state_file")"
|
|
}
|
|
|
|
# Compact elapsed time since an epoch, e.g. "8s", "4m12s", "1h07m".
|
|
elapsed_since() {
|
|
local start="$1" now delta
|
|
now=$(date +%s)
|
|
delta=$(( now - start ))
|
|
[ "$delta" -lt 0 ] && delta=0
|
|
if [ "$delta" -ge 3600 ]; then
|
|
printf '%dh%02dm' $(( delta / 3600 )) $(( (delta % 3600) / 60 ))
|
|
elif [ "$delta" -ge 60 ]; then
|
|
printf '%dm%02ds' $(( delta / 60 )) $(( delta % 60 ))
|
|
else
|
|
printf '%ds' "$delta"
|
|
fi
|
|
}
|
|
|
|
# Reject a malformed cron expression at the point of entry.
|
|
#
|
|
# Without this an invalid schedule is written to a task file, and the next
|
|
# rebuild hands crontab a file it refuses wholesale — taking every other task
|
|
# down with it. Deliberately shape-only: five fields, each built from `*`,
|
|
# numbers, ranges, lists and steps. Names (JAN, MON) and `@daily` are not
|
|
# accepted here.
|
|
validate_cron() {
|
|
local expr="$1"
|
|
# Exactly five whitespace-separated fields.
|
|
read -ra _cron_fields <<< "$expr"
|
|
[ "${#_cron_fields[@]}" -eq 5 ] || return 1
|
|
local field
|
|
for field in "${_cron_fields[@]}"; do
|
|
# A field is one or more comma-separated terms; a term is `*`, a number,
|
|
# or a range, each optionally followed by a `/step`. Vixie only allows a
|
|
# step after `*` or a range, which this mirrors.
|
|
[[ "$field" =~ ^(\*(/[0-9]+)?|[0-9]+(-[0-9]+(/[0-9]+)?)?)(,(\*(/[0-9]+)?|[0-9]+(-[0-9]+(/[0-9]+)?)?))*$ ]] || return 1
|
|
done
|
|
return 0
|
|
}
|
|
|
|
rebuild_crontab() {
|
|
local tmp
|
|
tmp=$(mktemp)
|
|
# Header
|
|
echo "# Triple-C scheduled tasks — managed by triple-c-scheduler" > "$tmp"
|
|
echo "# Do not edit manually; changes will be overwritten." >> "$tmp"
|
|
echo "" >> "$tmp"
|
|
|
|
for task_file in "$TASKS_DIR"/*.json; do
|
|
[ -f "$task_file" ] || continue
|
|
local enabled schedule id
|
|
enabled=$(jq -r '.enabled' "$task_file")
|
|
[ "$enabled" = "true" ] || continue
|
|
schedule=$(jq -r '.schedule' "$task_file")
|
|
id=$(jq -r '.id' "$task_file")
|
|
echo "$schedule /usr/local/bin/triple-c-task-runner $id" >> "$tmp"
|
|
done
|
|
|
|
# `crontab` validates the WHOLE file and rejects all of it if any single
|
|
# line is malformed. Swallowing that error silently unschedules every task
|
|
# in the container, so report it loudly and leave the previous crontab
|
|
# (which `crontab` keeps on rejection) in place.
|
|
local crontab_err
|
|
if ! crontab_err=$(crontab "$tmp" 2>&1); then
|
|
echo "ERROR: crontab rejected the generated schedule; NO tasks are scheduled." >&2
|
|
echo " ${crontab_err}" >&2
|
|
echo " Offending file kept at ${tmp} for inspection." >&2
|
|
echo " Fix or remove the task with the bad schedule, then re-run any" >&2
|
|
echo " scheduler command to rebuild." >&2
|
|
return 1
|
|
fi
|
|
rm -f "$tmp"
|
|
}
|
|
|
|
usage() {
|
|
cat <<'EOF'
|
|
Usage: triple-c-scheduler <command> [options]
|
|
|
|
Commands:
|
|
add Add a new scheduled task
|
|
remove Remove a task
|
|
enable Enable a disabled task
|
|
disable Disable a task
|
|
list List all tasks
|
|
status Show which tasks are running right now
|
|
logs Show execution logs
|
|
run Manually trigger a task now (streams its log)
|
|
notifications Show or clear completion notifications
|
|
|
|
Add options:
|
|
--name NAME Task name (required)
|
|
--prompt "TASK" Task prompt for Claude (required)
|
|
--schedule "CRON" Cron schedule expression (for recurring tasks)
|
|
--at "DATETIME" Target datetime as "YYYY-MM-DD HH:MM" (for one-time tasks)
|
|
--working-dir DIR Working directory (default: /workspace)
|
|
|
|
Remove/Enable/Disable/Run options:
|
|
--id ID Task ID (required)
|
|
|
|
Status options:
|
|
--id ID Show one task, including its last result when idle
|
|
--watch, -w Refresh every 5s until the run finishes
|
|
|
|
Logs options:
|
|
--id ID Show logs for a specific task (optional)
|
|
--tail N Show last N lines (default: 50)
|
|
|
|
Notifications options:
|
|
--clear Clear all notifications
|
|
|
|
Examples:
|
|
triple-c-scheduler add --name "run-tests" --schedule "*/30 * * * *" --prompt "Run the test suite and report results"
|
|
triple-c-scheduler add --name "friday-commit" --at "2026-03-06 16:00" --prompt "Commit all changes with a descriptive message"
|
|
triple-c-scheduler list
|
|
triple-c-scheduler logs --id a1b2c3d4 --tail 20
|
|
triple-c-scheduler run --id a1b2c3d4
|
|
EOF
|
|
}
|
|
|
|
# ── Commands ─────────────────────────────────────────────────────────────────
|
|
|
|
cmd_add() {
|
|
local name="" prompt="" schedule="" at="" working_dir="/workspace"
|
|
|
|
while [[ $# -gt 0 ]]; do
|
|
case "$1" in
|
|
--name) name="$2"; shift 2 ;;
|
|
--prompt) prompt="$2"; shift 2 ;;
|
|
--schedule) schedule="$2"; shift 2 ;;
|
|
--at) at="$2"; shift 2 ;;
|
|
--working-dir) working_dir="$2"; shift 2 ;;
|
|
*) echo "Unknown option: $1" >&2; return 1 ;;
|
|
esac
|
|
done
|
|
|
|
if [ -z "$name" ]; then
|
|
echo "Error: --name is required" >&2
|
|
return 1
|
|
fi
|
|
if [ -z "$prompt" ]; then
|
|
echo "Error: --prompt is required" >&2
|
|
return 1
|
|
fi
|
|
if [ -z "$schedule" ] && [ -z "$at" ]; then
|
|
echo "Error: either --schedule or --at is required" >&2
|
|
return 1
|
|
fi
|
|
if [ -n "$schedule" ] && [ -n "$at" ]; then
|
|
echo "Error: use either --schedule or --at, not both" >&2
|
|
return 1
|
|
fi
|
|
|
|
local id task_type cron_expr
|
|
id=$(generate_id)
|
|
|
|
if [ -n "$at" ]; then
|
|
task_type="once"
|
|
# Parse "YYYY-MM-DD HH:MM" into cron expression
|
|
local year month day hour minute
|
|
if ! [[ "$at" =~ ^([0-9]{4})-([0-9]{2})-([0-9]{2})\ ([0-9]{2}):([0-9]{2})$ ]]; then
|
|
echo "Error: --at must be in format 'YYYY-MM-DD HH:MM'" >&2
|
|
return 1
|
|
fi
|
|
year="${BASH_REMATCH[1]}"
|
|
month="${BASH_REMATCH[2]}"
|
|
day="${BASH_REMATCH[3]}"
|
|
hour="${BASH_REMATCH[4]}"
|
|
minute="${BASH_REMATCH[5]}"
|
|
# Remove leading zeros for cron
|
|
month=$((10#$month))
|
|
day=$((10#$day))
|
|
hour=$((10#$hour))
|
|
minute=$((10#$minute))
|
|
cron_expr="$minute $hour $day $month *"
|
|
else
|
|
task_type="recurring"
|
|
cron_expr="$schedule"
|
|
if ! validate_cron "$cron_expr"; then
|
|
echo "Error: invalid cron expression: '$cron_expr'" >&2
|
|
echo " Expected five fields: minute hour day-of-month month day-of-week" >&2
|
|
echo " e.g. '*/30 * * * *' (every 30 min), '0 9 * * 1-5' (9am weekdays)" >&2
|
|
exit 1
|
|
fi
|
|
fi
|
|
|
|
local created_at
|
|
created_at=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
|
|
|
|
local task_json
|
|
task_json=$(jq -n \
|
|
--arg id "$id" \
|
|
--arg name "$name" \
|
|
--arg prompt "$prompt" \
|
|
--arg schedule "$cron_expr" \
|
|
--arg type "$task_type" \
|
|
--arg at "$at" \
|
|
--arg created_at "$created_at" \
|
|
--argjson enabled true \
|
|
--arg working_dir "$working_dir" \
|
|
'{
|
|
id: $id,
|
|
name: $name,
|
|
prompt: $prompt,
|
|
schedule: $schedule,
|
|
type: $type,
|
|
at: $at,
|
|
created_at: $created_at,
|
|
enabled: $enabled,
|
|
working_dir: $working_dir
|
|
}')
|
|
|
|
echo "$task_json" > "$TASKS_DIR/${id}.json"
|
|
rebuild_crontab
|
|
|
|
echo "Task created:"
|
|
echo " ID: $id"
|
|
echo " Name: $name"
|
|
echo " Type: $task_type"
|
|
if [ "$task_type" = "once" ]; then
|
|
echo " At: $at"
|
|
fi
|
|
echo " Schedule: $cron_expr"
|
|
echo " Prompt: $prompt"
|
|
}
|
|
|
|
cmd_remove() {
|
|
local id=""
|
|
|
|
while [[ $# -gt 0 ]]; do
|
|
case "$1" in
|
|
--id) id="$2"; shift 2 ;;
|
|
*) echo "Unknown option: $1" >&2; return 1 ;;
|
|
esac
|
|
done
|
|
|
|
if [ -z "$id" ]; then
|
|
echo "Error: --id is required" >&2
|
|
return 1
|
|
fi
|
|
|
|
local task_file="$TASKS_DIR/${id}.json"
|
|
if [ ! -f "$task_file" ]; then
|
|
echo "Error: task '$id' not found" >&2
|
|
return 1
|
|
fi
|
|
|
|
local name
|
|
name=$(jq -r '.name' "$task_file")
|
|
rm -f "$task_file"
|
|
rebuild_crontab
|
|
echo "Removed task '$name' ($id)"
|
|
}
|
|
|
|
cmd_enable() {
|
|
local id=""
|
|
|
|
while [[ $# -gt 0 ]]; do
|
|
case "$1" in
|
|
--id) id="$2"; shift 2 ;;
|
|
*) echo "Unknown option: $1" >&2; return 1 ;;
|
|
esac
|
|
done
|
|
|
|
if [ -z "$id" ]; then
|
|
echo "Error: --id is required" >&2
|
|
return 1
|
|
fi
|
|
|
|
local task_file="$TASKS_DIR/${id}.json"
|
|
if [ ! -f "$task_file" ]; then
|
|
echo "Error: task '$id' not found" >&2
|
|
return 1
|
|
fi
|
|
|
|
local tmp
|
|
tmp=$(mktemp)
|
|
jq '.enabled = true' "$task_file" > "$tmp" && mv "$tmp" "$task_file"
|
|
rebuild_crontab
|
|
|
|
local name
|
|
name=$(jq -r '.name' "$task_file")
|
|
echo "Enabled task '$name' ($id)"
|
|
}
|
|
|
|
cmd_disable() {
|
|
local id=""
|
|
|
|
while [[ $# -gt 0 ]]; do
|
|
case "$1" in
|
|
--id) id="$2"; shift 2 ;;
|
|
*) echo "Unknown option: $1" >&2; return 1 ;;
|
|
esac
|
|
done
|
|
|
|
if [ -z "$id" ]; then
|
|
echo "Error: --id is required" >&2
|
|
return 1
|
|
fi
|
|
|
|
local task_file="$TASKS_DIR/${id}.json"
|
|
if [ ! -f "$task_file" ]; then
|
|
echo "Error: task '$id' not found" >&2
|
|
return 1
|
|
fi
|
|
|
|
local tmp
|
|
tmp=$(mktemp)
|
|
jq '.enabled = false' "$task_file" > "$tmp" && mv "$tmp" "$task_file"
|
|
rebuild_crontab
|
|
|
|
local name
|
|
name=$(jq -r '.name' "$task_file")
|
|
echo "Disabled task '$name' ($id)"
|
|
}
|
|
|
|
cmd_list() {
|
|
local found=false
|
|
printf "%-10s %-20s %-10s %-9s %-20s %-12s %s\n" "ID" "NAME" "TYPE" "ENABLED" "SCHEDULE" "STATUS" "PROMPT"
|
|
printf "%-10s %-20s %-10s %-9s %-20s %-12s %s\n" "──────────" "────────────────────" "──────────" "─────────" "────────────────────" "────────────" "──────────────────────────────"
|
|
|
|
for task_file in "$TASKS_DIR"/*.json; do
|
|
[ -f "$task_file" ] || continue
|
|
found=true
|
|
local id name type enabled schedule at prompt
|
|
id=$(jq -r '.id' "$task_file")
|
|
name=$(jq -r '.name' "$task_file")
|
|
type=$(jq -r '.type' "$task_file")
|
|
enabled=$(jq -r '.enabled' "$task_file")
|
|
schedule=$(jq -r '.schedule' "$task_file")
|
|
at=$(jq -r '.at // ""' "$task_file")
|
|
prompt=$(jq -r '.prompt' "$task_file")
|
|
|
|
local display_schedule="$schedule"
|
|
if [ "$type" = "once" ] && [ -n "$at" ]; then
|
|
display_schedule="at $at"
|
|
fi
|
|
|
|
local status state started
|
|
if state=$(run_state "$id"); then
|
|
started=$(printf '%s' "$state" | cut -f2)
|
|
status="running $(elapsed_since "$started")"
|
|
else
|
|
status="idle"
|
|
fi
|
|
|
|
# Truncate long fields for display
|
|
[ ${#name} -gt 20 ] && name="${name:0:17}..."
|
|
[ ${#display_schedule} -gt 20 ] && display_schedule="${display_schedule:0:17}..."
|
|
[ ${#prompt} -gt 30 ] && prompt="${prompt:0:27}..."
|
|
|
|
printf "%-10s %-20s %-10s %-9s %-20s %-12s %s\n" \
|
|
"$id" "$name" "$type" "$enabled" "$display_schedule" "$status" "$prompt"
|
|
done
|
|
|
|
if [ "$found" = "false" ]; then
|
|
echo "No scheduled tasks."
|
|
fi
|
|
}
|
|
|
|
# Is anything running, and how far along is it?
|
|
#
|
|
# This is the command for the question "did my `run` do anything, or has it
|
|
# stalled?" — `logs` alone cannot answer it, because a log that stops growing
|
|
# looks identical whether Claude is thinking or the run is dead.
|
|
cmd_status() {
|
|
local id="" watch=false
|
|
|
|
while [[ $# -gt 0 ]]; do
|
|
case "$1" in
|
|
--id) id="$2"; shift 2 ;;
|
|
--watch|-w) watch=true; shift ;;
|
|
*) echo "Unknown option: $1" >&2; return 1 ;;
|
|
esac
|
|
done
|
|
|
|
while true; do
|
|
local any=false
|
|
for task_file in "$TASKS_DIR"/*.json; do
|
|
[ -f "$task_file" ] || continue
|
|
local tid
|
|
tid=$(jq -r '.id' "$task_file")
|
|
[ -z "$id" ] || [ "$tid" = "$id" ] || continue
|
|
|
|
local name state
|
|
name=$(jq -r '.name' "$task_file")
|
|
if state=$(run_state "$tid"); then
|
|
any=true
|
|
local pid started log
|
|
pid=$(printf '%s' "$state" | cut -f1)
|
|
started=$(printf '%s' "$state" | cut -f2)
|
|
log=$(printf '%s' "$state" | cut -f3)
|
|
echo "● RUNNING $name ($tid)"
|
|
echo " elapsed: $(elapsed_since "$started") pid: $pid"
|
|
echo " log: $log"
|
|
# `claude -p` writes its answer in one go at the end, so a log
|
|
# with only its header is the normal state of a healthy run —
|
|
# print the tail only when there is something to show, rather
|
|
# than an empty "last output:" that reads like a stall.
|
|
# `|| true` throughout: under `set -e` a grep matching nothing
|
|
# would otherwise abort the whole command.
|
|
local tail_out=""
|
|
if [ -f "$log" ]; then
|
|
tail_out=$({ grep -v '^===' "$log" || true; } \
|
|
| { grep -v '^$' || true; } | tail -n 3)
|
|
fi
|
|
if [ -n "$tail_out" ]; then
|
|
echo " last output:"
|
|
printf '%s\n' "$tail_out" | sed 's/^/ /'
|
|
fi
|
|
elif [ -n "$id" ]; then
|
|
echo "○ idle $name ($tid)"
|
|
local latest
|
|
latest=$(ls -t "$LOGS_DIR/$tid"/*.log 2>/dev/null | head -1) || true
|
|
if [ -n "$latest" ]; then
|
|
echo " last run: $(basename "$latest" .log) $(grep -o 'Exit code: [0-9]*' "$latest" | tail -1)"
|
|
fi
|
|
fi
|
|
done
|
|
|
|
if [ "$any" = "false" ] && [ -z "$id" ]; then
|
|
echo "Nothing running."
|
|
fi
|
|
|
|
[ "$watch" = "true" ] || break
|
|
# Stop watching once the thing being watched has finished.
|
|
[ "$any" = "true" ] || break
|
|
sleep 5
|
|
echo ""
|
|
done
|
|
}
|
|
|
|
cmd_logs() {
|
|
local id="" tail_n=50
|
|
|
|
while [[ $# -gt 0 ]]; do
|
|
case "$1" in
|
|
--id) id="$2"; shift 2 ;;
|
|
--tail) tail_n="$2"; shift 2 ;;
|
|
*) echo "Unknown option: $1" >&2; return 1 ;;
|
|
esac
|
|
done
|
|
|
|
if [ -n "$id" ]; then
|
|
local log_dir="$LOGS_DIR/$id"
|
|
if [ ! -d "$log_dir" ]; then
|
|
echo "No logs found for task '$id'"
|
|
return 0
|
|
fi
|
|
# Show the most recent log file
|
|
local latest
|
|
latest=$(ls -t "$log_dir"/*.log 2>/dev/null | head -1)
|
|
if [ -z "$latest" ]; then
|
|
echo "No logs found for task '$id'"
|
|
return 0
|
|
fi
|
|
echo "=== Latest log for task $id: $(basename "$latest") ==="
|
|
tail -n "$tail_n" "$latest"
|
|
else
|
|
# Show recent logs across all tasks
|
|
local all_logs
|
|
all_logs=$(find "$LOGS_DIR" -name "*.log" -type f 2>/dev/null | sort -r | head -n 10)
|
|
if [ -z "$all_logs" ]; then
|
|
echo "No logs found."
|
|
return 0
|
|
fi
|
|
for log_file in $all_logs; do
|
|
local task_id
|
|
task_id=$(basename "$(dirname "$log_file")")
|
|
echo "=== Task $task_id: $(basename "$log_file") ==="
|
|
tail -n 5 "$log_file"
|
|
echo ""
|
|
done
|
|
fi
|
|
}
|
|
|
|
cmd_run() {
|
|
local id=""
|
|
|
|
while [[ $# -gt 0 ]]; do
|
|
case "$1" in
|
|
--id) id="$2"; shift 2 ;;
|
|
*) echo "Unknown option: $1" >&2; return 1 ;;
|
|
esac
|
|
done
|
|
|
|
if [ -z "$id" ]; then
|
|
echo "Error: --id is required" >&2
|
|
return 1
|
|
fi
|
|
|
|
local task_file="$TASKS_DIR/${id}.json"
|
|
if [ ! -f "$task_file" ]; then
|
|
echo "Error: task '$id' not found" >&2
|
|
return 1
|
|
fi
|
|
|
|
local name
|
|
name=$(jq -r '.name' "$task_file")
|
|
|
|
if run_state "$id" >/dev/null; then
|
|
echo "Task '$name' ($id) is already running — see: triple-c-scheduler status --id $id"
|
|
return 0
|
|
fi
|
|
|
|
echo "Manually triggering task '$name' ($id)..."
|
|
|
|
# Run in the background and stream its log. A task can easily think for
|
|
# minutes, and the previous behaviour — block with no output until it is
|
|
# over — is indistinguishable from a hang.
|
|
/usr/local/bin/triple-c-task-runner "$id" &
|
|
local runner_pid=$!
|
|
|
|
local state="" waited=0
|
|
while [ "$waited" -lt 20 ]; do
|
|
if state=$(run_state "$id"); then
|
|
break
|
|
fi
|
|
kill -0 "$runner_pid" 2>/dev/null || break
|
|
sleep 0.5
|
|
waited=$(( waited + 1 ))
|
|
done
|
|
|
|
local log=""
|
|
[ -n "$state" ] && log=$(printf '%s' "$state" | cut -f3)
|
|
|
|
if [ -n "$log" ]; then
|
|
echo " log: $log"
|
|
echo " elsewhere: triple-c-scheduler status --id $id --watch"
|
|
echo ""
|
|
# --pid stops the follow when the runner exits, so this returns on its own.
|
|
tail -n +1 -f --pid="$runner_pid" "$log" 2>/dev/null
|
|
fi
|
|
|
|
local rc=0
|
|
wait "$runner_pid" || rc=$?
|
|
|
|
# A run short enough that its state file was never observed still deserves
|
|
# its output shown rather than swallowed.
|
|
if [ -z "$log" ]; then
|
|
local latest
|
|
latest=$(ls -t "$LOGS_DIR/$id"/*.log 2>/dev/null | head -1) || true
|
|
[ -n "$latest" ] && tail -n 20 "$latest"
|
|
fi
|
|
|
|
return $rc
|
|
}
|
|
|
|
cmd_notifications() {
|
|
local clear=false
|
|
|
|
while [[ $# -gt 0 ]]; do
|
|
case "$1" in
|
|
--clear) clear=true; shift ;;
|
|
*) echo "Unknown option: $1" >&2; return 1 ;;
|
|
esac
|
|
done
|
|
|
|
if [ "$clear" = "true" ]; then
|
|
rm -f "$NOTIFICATIONS_DIR"/*.notify
|
|
echo "Notifications cleared."
|
|
return 0
|
|
fi
|
|
|
|
local found=false
|
|
for notify_file in $(ls -t "$NOTIFICATIONS_DIR"/*.notify 2>/dev/null); do
|
|
[ -f "$notify_file" ] || continue
|
|
found=true
|
|
cat "$notify_file"
|
|
echo "---"
|
|
done
|
|
|
|
if [ "$found" = "false" ]; then
|
|
echo "No notifications."
|
|
fi
|
|
}
|
|
|
|
# ── Main ─────────────────────────────────────────────────────────────────────
|
|
|
|
ensure_dirs
|
|
|
|
if [ $# -eq 0 ]; then
|
|
usage
|
|
exit 1
|
|
fi
|
|
|
|
command="$1"
|
|
shift
|
|
|
|
case "$command" in
|
|
add) cmd_add "$@" ;;
|
|
remove) cmd_remove "$@" ;;
|
|
enable) cmd_enable "$@" ;;
|
|
disable) cmd_disable "$@" ;;
|
|
list) cmd_list ;;
|
|
status) cmd_status "$@" ;;
|
|
logs) cmd_logs "$@" ;;
|
|
run) cmd_run "$@" ;;
|
|
notifications) cmd_notifications "$@" ;;
|
|
help|--help|-h) usage ;;
|
|
*)
|
|
echo "Unknown command: $command" >&2
|
|
usage
|
|
exit 1
|
|
;;
|
|
esac
|