diff --git a/app/src-tauri/Cargo.lock b/app/src-tauri/Cargo.lock index b7c4c7d..f30f0b8 100644 --- a/app/src-tauri/Cargo.lock +++ b/app/src-tauri/Cargo.lock @@ -4675,6 +4675,7 @@ dependencies = [ "dirs", "fern", "futures-util", + "iana-time-zone", "keyring", "log", "reqwest 0.12.28", diff --git a/app/src-tauri/Cargo.toml b/app/src-tauri/Cargo.toml index 37b64de..ad2ba45 100644 --- a/app/src-tauri/Cargo.toml +++ b/app/src-tauri/Cargo.toml @@ -29,6 +29,7 @@ log = "0.4" fern = { version = "0.7", features = ["date-based"] } tar = "0.4" reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] } +iana-time-zone = "0.1" [build-dependencies] tauri-build = { version = "2", features = [] } diff --git a/app/src-tauri/src/commands/project_commands.rs b/app/src-tauri/src/commands/project_commands.rs index 92b72f9..e1ba914 100644 --- a/app/src-tauri/src/commands/project_commands.rs +++ b/app/src-tauri/src/commands/project_commands.rs @@ -160,6 +160,7 @@ pub async fn start_project_container( &project, settings.global_claude_instructions.as_deref(), &settings.global_custom_env_vars, + settings.timezone.as_deref(), ) .await .unwrap_or(false); @@ -175,6 +176,7 @@ pub async fn start_project_container( &settings.global_aws, settings.global_claude_instructions.as_deref(), &settings.global_custom_env_vars, + settings.timezone.as_deref(), ).await?; docker::start_container(&new_id).await?; new_id @@ -191,6 +193,7 @@ pub async fn start_project_container( &settings.global_aws, settings.global_claude_instructions.as_deref(), &settings.global_custom_env_vars, + settings.timezone.as_deref(), ).await?; docker::start_container(&new_id).await?; new_id diff --git a/app/src-tauri/src/commands/settings_commands.rs b/app/src-tauri/src/commands/settings_commands.rs index 811f59d..e83bd6c 100644 --- a/app/src-tauri/src/commands/settings_commands.rs +++ b/app/src-tauri/src/commands/settings_commands.rs @@ -29,6 +29,33 @@ pub async fn pull_image( .await } +#[tauri::command] +pub async fn detect_host_timezone() -> Result { + // Try the iana-time-zone crate first (cross-platform) + match iana_time_zone::get_timezone() { + Ok(tz) => return Ok(tz), + Err(e) => log::debug!("iana_time_zone::get_timezone() failed: {}", e), + } + + // Fallback: check TZ env var + if let Ok(tz) = std::env::var("TZ") { + if !tz.is_empty() { + return Ok(tz); + } + } + + // Fallback: read /etc/timezone (Linux) + if let Ok(tz) = std::fs::read_to_string("/etc/timezone") { + let tz = tz.trim().to_string(); + if !tz.is_empty() { + return Ok(tz); + } + } + + // Default to UTC if detection fails + Ok("UTC".to_string()) +} + #[tauri::command] pub async fn detect_aws_config() -> Result, String> { if let Some(home) = dirs::home_dir() { diff --git a/app/src-tauri/src/docker/container.rs b/app/src-tauri/src/docker/container.rs index 06d2237..6e6a188 100644 --- a/app/src-tauri/src/docker/container.rs +++ b/app/src-tauri/src/docker/container.rs @@ -10,6 +10,36 @@ use std::hash::{Hash, Hasher}; use super::client::get_docker; use crate::models::{AuthMode, BedrockAuthMethod, ContainerInfo, EnvVar, GlobalAwsSettings, PortMapping, Project, ProjectPath}; +const SCHEDULER_INSTRUCTIONS: &str = r#"## Scheduled Tasks + +This container supports scheduled tasks via `triple-c-scheduler`. You can set up recurring or one-time tasks that run as separate Claude Code agents. + +### Commands +- `triple-c-scheduler add --name "NAME" --schedule "CRON" --prompt "TASK"` — Add a recurring task +- `triple-c-scheduler add --name "NAME" --at "YYYY-MM-DD HH:MM" --prompt "TASK"` — Add a one-time task +- `triple-c-scheduler list` — List all scheduled tasks +- `triple-c-scheduler remove --id ID` — Remove a task +- `triple-c-scheduler enable --id ID` / `triple-c-scheduler disable --id ID` — Toggle tasks +- `triple-c-scheduler logs [--id ID] [--tail N]` — View execution logs +- `triple-c-scheduler run --id ID` — Manually trigger a task immediately +- `triple-c-scheduler notifications [--clear]` — View or clear completion notifications + +### Cron format +Standard 5-field cron: `minute hour day-of-month month day-of-week` +Examples: `*/30 * * * *` (every 30 min), `0 9 * * 1-5` (9am weekdays), `0 */2 * * *` (every 2 hours) + +### One-time tasks +Use `--at "YYYY-MM-DD HH:MM"` instead of `--schedule`. The task automatically removes itself after execution. + +### Working directory +Use `--working-dir /workspace/project` to set where the task runs (default: /workspace). + +### Checking results +After tasks run, check notifications with `triple-c-scheduler notifications` and detailed output with `triple-c-scheduler logs`. + +### Timezone +Scheduled times use the container's configured timezone (check with `date`). If no timezone is configured, UTC is used."#; + /// Compute a fingerprint string for the custom environment variables. /// Sorted alphabetically so order changes do not cause spurious recreation. fn compute_env_fingerprint(custom_env_vars: &[EnvVar]) -> String { @@ -147,6 +177,7 @@ pub async fn create_container( global_aws: &GlobalAwsSettings, global_claude_instructions: Option<&str>, global_custom_env_vars: &[EnvVar], + timezone: Option<&str>, ) -> Result { let docker = get_docker()?; let container_name = project.container_name(); @@ -269,6 +300,13 @@ pub async fn create_container( let custom_env_fingerprint = compute_env_fingerprint(&merged_env); env_vars.push(format!("TRIPLE_C_CUSTOM_ENV={}", custom_env_fingerprint)); + // Container timezone + if let Some(tz) = timezone { + if !tz.is_empty() { + env_vars.push(format!("TZ={}", tz)); + } + } + // Claude instructions (global + per-project, plus port mapping info) let mut combined_instructions = merge_claude_instructions( global_claude_instructions, @@ -290,6 +328,13 @@ pub async fn create_container( None => port_info, }); } + // Scheduler instructions (always appended so all containers get scheduling docs) + let scheduler_docs = SCHEDULER_INSTRUCTIONS; + combined_instructions = Some(match combined_instructions { + Some(existing) => format!("{}\n\n{}", existing, scheduler_docs), + None => scheduler_docs.to_string(), + }); + if let Some(ref instructions) = combined_instructions { env_vars.push(format!("CLAUDE_INSTRUCTIONS={}", instructions)); } @@ -400,10 +445,12 @@ pub async fn create_container( labels.insert("triple-c.bedrock-fingerprint".to_string(), compute_bedrock_fingerprint(project)); labels.insert("triple-c.ports-fingerprint".to_string(), compute_ports_fingerprint(&project.port_mappings)); labels.insert("triple-c.image".to_string(), image_name.to_string()); + labels.insert("triple-c.timezone".to_string(), timezone.unwrap_or("").to_string()); let host_config = HostConfig { mounts: Some(mounts), port_bindings: if port_bindings.is_empty() { None } else { Some(port_bindings) }, + init: Some(true), ..Default::default() }; @@ -484,6 +531,7 @@ pub async fn container_needs_recreation( project: &Project, global_claude_instructions: Option<&str>, global_custom_env_vars: &[EnvVar], + timezone: Option<&str>, ) -> Result { let docker = get_docker()?; let info = docker @@ -571,6 +619,14 @@ pub async fn container_needs_recreation( } } + // ── Timezone ───────────────────────────────────────────────────────── + let expected_tz = timezone.unwrap_or(""); + let container_tz = get_label("triple-c.timezone").unwrap_or_default(); + if container_tz != expected_tz { + log::info!("Timezone mismatch (container={:?}, expected={:?})", container_tz, expected_tz); + return Ok(true); + } + // ── SSH key path mount ─────────────────────────────────────────────── let ssh_mount_source = mounts .and_then(|m| { diff --git a/app/src-tauri/src/docker/image.rs b/app/src-tauri/src/docker/image.rs index de6cebd..215bfa0 100644 --- a/app/src-tauri/src/docker/image.rs +++ b/app/src-tauri/src/docker/image.rs @@ -9,6 +9,8 @@ use crate::models::container_config; const DOCKERFILE: &str = include_str!("../../../../container/Dockerfile"); const ENTRYPOINT: &str = include_str!("../../../../container/entrypoint.sh"); +const SCHEDULER: &str = include_str!("../../../../container/triple-c-scheduler"); +const TASK_RUNNER: &str = include_str!("../../../../container/triple-c-task-runner"); pub async fn image_exists(image_name: &str) -> Result { let docker = get_docker()?; @@ -135,6 +137,20 @@ fn create_build_context() -> Result, std::io::Error> { header.set_cksum(); archive.append_data(&mut header, "entrypoint.sh", entrypoint_bytes)?; + let scheduler_bytes = SCHEDULER.as_bytes(); + let mut header = tar::Header::new_gnu(); + header.set_size(scheduler_bytes.len() as u64); + header.set_mode(0o755); + header.set_cksum(); + archive.append_data(&mut header, "triple-c-scheduler", scheduler_bytes)?; + + let task_runner_bytes = TASK_RUNNER.as_bytes(); + let mut header = tar::Header::new_gnu(); + header.set_size(task_runner_bytes.len() as u64); + header.set_mode(0o755); + header.set_cksum(); + archive.append_data(&mut header, "triple-c-task-runner", task_runner_bytes)?; + archive.finish()?; } diff --git a/app/src-tauri/src/lib.rs b/app/src-tauri/src/lib.rs index 1844397..e8a9d74 100644 --- a/app/src-tauri/src/lib.rs +++ b/app/src-tauri/src/lib.rs @@ -84,6 +84,7 @@ pub fn run() { commands::settings_commands::pull_image, commands::settings_commands::detect_aws_config, commands::settings_commands::list_aws_profiles, + commands::settings_commands::detect_host_timezone, // Terminal commands::terminal_commands::open_terminal_session, commands::terminal_commands::terminal_input, diff --git a/app/src-tauri/src/models/app_settings.rs b/app/src-tauri/src/models/app_settings.rs index 4f99e60..4311498 100644 --- a/app/src-tauri/src/models/app_settings.rs +++ b/app/src-tauri/src/models/app_settings.rs @@ -68,6 +68,8 @@ pub struct AppSettings { pub auto_check_updates: bool, #[serde(default)] pub dismissed_update_version: Option, + #[serde(default)] + pub timezone: Option, } impl Default for AppSettings { @@ -84,6 +86,7 @@ impl Default for AppSettings { global_custom_env_vars: Vec::new(), auto_check_updates: true, dismissed_update_version: None, + timezone: None, } } } diff --git a/app/src/components/settings/SettingsPanel.tsx b/app/src/components/settings/SettingsPanel.tsx index f811423..347e9f0 100644 --- a/app/src/components/settings/SettingsPanel.tsx +++ b/app/src/components/settings/SettingsPanel.tsx @@ -6,6 +6,7 @@ import { useSettings } from "../../hooks/useSettings"; import { useUpdates } from "../../hooks/useUpdates"; import ClaudeInstructionsModal from "../projects/ClaudeInstructionsModal"; import EnvVarsModal from "../projects/EnvVarsModal"; +import { detectHostTimezone } from "../../lib/tauri-commands"; import type { EnvVar } from "../../lib/types"; export default function SettingsPanel() { @@ -14,6 +15,7 @@ export default function SettingsPanel() { const [globalInstructions, setGlobalInstructions] = useState(appSettings?.global_claude_instructions ?? ""); const [globalEnvVars, setGlobalEnvVars] = useState(appSettings?.global_custom_env_vars ?? []); const [checkingUpdates, setCheckingUpdates] = useState(false); + const [timezone, setTimezone] = useState(appSettings?.timezone ?? ""); const [showInstructionsModal, setShowInstructionsModal] = useState(false); const [showEnvVarsModal, setShowEnvVarsModal] = useState(false); @@ -21,7 +23,18 @@ export default function SettingsPanel() { useEffect(() => { setGlobalInstructions(appSettings?.global_claude_instructions ?? ""); setGlobalEnvVars(appSettings?.global_custom_env_vars ?? []); - }, [appSettings?.global_claude_instructions, appSettings?.global_custom_env_vars]); + setTimezone(appSettings?.timezone ?? ""); + }, [appSettings?.global_claude_instructions, appSettings?.global_custom_env_vars, appSettings?.timezone]); + + // Auto-detect timezone on first load if not yet set + useEffect(() => { + if (appSettings && !appSettings.timezone) { + detectHostTimezone().then((tz) => { + setTimezone(tz); + saveSettings({ ...appSettings, timezone: tz }); + }).catch(() => {}); + } + }, [appSettings?.timezone]); const handleCheckNow = async () => { setCheckingUpdates(true); @@ -46,6 +59,26 @@ export default function SettingsPanel() { + {/* Container Timezone */} +
+ +

+ Timezone for containers — affects scheduled task timing (IANA format, e.g. America/New_York) +

+ setTimezone(e.target.value)} + onBlur={async () => { + if (appSettings) { + await saveSettings({ ...appSettings, timezone: timezone || null }); + } + }} + placeholder="UTC" + className="w-full px-2 py-1 text-sm bg-[var(--bg-primary)] border border-[var(--border-color)] rounded focus:outline-none focus:border-[var(--accent)]" + /> +
+ {/* Global Claude Instructions */}
diff --git a/app/src/lib/tauri-commands.ts b/app/src/lib/tauri-commands.ts index 830e6a7..88f25bd 100644 --- a/app/src/lib/tauri-commands.ts +++ b/app/src/lib/tauri-commands.ts @@ -35,6 +35,8 @@ export const detectAwsConfig = () => invoke("detect_aws_config"); export const listAwsProfiles = () => invoke("list_aws_profiles"); +export const detectHostTimezone = () => + invoke("detect_host_timezone"); // Terminal export const openTerminalSession = (projectId: string, sessionId: string) => diff --git a/app/src/lib/types.ts b/app/src/lib/types.ts index bb2083d..db094bd 100644 --- a/app/src/lib/types.ts +++ b/app/src/lib/types.ts @@ -98,6 +98,7 @@ export interface AppSettings { global_custom_env_vars: EnvVar[]; auto_check_updates: boolean; dismissed_update_version: string | null; + timezone: string | null; } export interface UpdateInfo { diff --git a/container/Dockerfile b/container/Dockerfile index 9803456..215c319 100644 --- a/container/Dockerfile +++ b/container/Dockerfile @@ -19,6 +19,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ unzip \ pkg-config \ libssl-dev \ + cron \ && rm -rf /var/lib/apt/lists/* # Remove default ubuntu user to free UID 1000 for host-user remapping @@ -101,5 +102,9 @@ WORKDIR /workspace USER root COPY entrypoint.sh /usr/local/bin/entrypoint.sh RUN chmod +x /usr/local/bin/entrypoint.sh +COPY triple-c-scheduler /usr/local/bin/triple-c-scheduler +RUN chmod +x /usr/local/bin/triple-c-scheduler +COPY triple-c-task-runner /usr/local/bin/triple-c-task-runner +RUN chmod +x /usr/local/bin/triple-c-task-runner ENTRYPOINT ["/usr/local/bin/entrypoint.sh"] diff --git a/container/entrypoint.sh b/container/entrypoint.sh index 7805665..98646cc 100644 --- a/container/entrypoint.sh +++ b/container/entrypoint.sh @@ -113,6 +113,59 @@ if [ -S /var/run/docker.sock ]; then usermod -aG "$DOCKER_GROUP" claude fi +# ── Timezone setup ─────────────────────────────────────────────────────────── +if [ -n "${TZ:-}" ]; then + if [ -f "/usr/share/zoneinfo/$TZ" ]; then + ln -sf "/usr/share/zoneinfo/$TZ" /etc/localtime + echo "$TZ" > /etc/timezone + echo "entrypoint: timezone set to $TZ" + else + echo "entrypoint: warning — timezone '$TZ' not found in /usr/share/zoneinfo" + fi +fi + +# ── Scheduler setup ───────────────────────────────────────────────────────── +SCHEDULER_DIR="/home/claude/.claude/scheduler" +mkdir -p "$SCHEDULER_DIR/tasks" "$SCHEDULER_DIR/logs" "$SCHEDULER_DIR/notifications" +chown -R claude:claude "$SCHEDULER_DIR" + +# Start cron daemon (runs as root, executes jobs per user crontab) +cron + +# Save environment variables for cron jobs (cron runs with a minimal env) +ENV_FILE="$SCHEDULER_DIR/.env" +: > "$ENV_FILE" +env | while IFS='=' read -r key value; do + case "$key" in + ANTHROPIC_*|AWS_*|CLAUDE_CODE_*|PATH|HOME|LANG|TZ|COLORTERM) + # Escape single quotes in value and write as KEY='VALUE' + escaped_value=$(printf '%s' "$value" | sed "s/'/'\\\\''/g") + printf "%s='%s'\n" "$key" "$escaped_value" >> "$ENV_FILE" + ;; + esac +done +chown claude:claude "$ENV_FILE" +chmod 600 "$ENV_FILE" + +# Restore crontab from persisted task JSON files (survives container recreation) +if ls "$SCHEDULER_DIR/tasks/"*.json >/dev/null 2>&1; then + CRON_TMP=$(mktemp) + echo "# Triple-C scheduled tasks — managed by triple-c-scheduler" > "$CRON_TMP" + echo "# Do not edit manually; changes will be overwritten." >> "$CRON_TMP" + echo "" >> "$CRON_TMP" + for task_file in "$SCHEDULER_DIR/tasks/"*.json; do + [ -f "$task_file" ] || continue + 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" >> "$CRON_TMP" + done + crontab -u claude "$CRON_TMP" 2>/dev/null || true + rm -f "$CRON_TMP" + echo "entrypoint: restored crontab from persisted tasks" +fi + # ── Stay alive as claude ───────────────────────────────────────────────────── echo "Triple-C container ready." exec su -s /bin/bash claude -c "exec sleep infinity" diff --git a/container/triple-c-scheduler b/container/triple-c-scheduler new file mode 100644 index 0000000..042b194 --- /dev/null +++ b/container/triple-c-scheduler @@ -0,0 +1,436 @@ +#!/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" + +# ── Helpers ────────────────────────────────────────────────────────────────── + +ensure_dirs() { + mkdir -p "$TASKS_DIR" "$LOGS_DIR" "$NOTIFICATIONS_DIR" +} + +generate_id() { + head -c 4 /dev/urandom | od -An -tx1 | tr -d ' \n' +} + +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 "$tmp" 2>/dev/null || true + rm -f "$tmp" +} + +usage() { + cat <<'EOF' +Usage: triple-c-scheduler [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 + logs Show execution logs + run Manually trigger a task now + 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) + +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" + 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 %s\n" "ID" "NAME" "TYPE" "ENABLED" "SCHEDULE" "PROMPT" + printf "%-10s %-20s %-10s %-9s %-20s %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 + + # 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 %s\n" "$id" "$name" "$type" "$enabled" "$display_schedule" "$prompt" + done + + if [ "$found" = "false" ]; then + echo "No scheduled tasks." + fi +} + +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") + echo "Manually triggering task '$name' ($id)..." + /usr/local/bin/triple-c-task-runner "$id" +} + +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 ;; + logs) cmd_logs "$@" ;; + run) cmd_run "$@" ;; + notifications) cmd_notifications "$@" ;; + help|--help|-h) usage ;; + *) + echo "Unknown command: $command" >&2 + usage + exit 1 + ;; +esac diff --git a/container/triple-c-task-runner b/container/triple-c-task-runner new file mode 100644 index 0000000..5b59d29 --- /dev/null +++ b/container/triple-c-task-runner @@ -0,0 +1,142 @@ +#!/bin/bash +# triple-c-task-runner — Executes a scheduled task via Claude Code agent +# Called by cron with a task ID argument. Handles locking, logging, +# notifications, one-time task cleanup, and log pruning. + +set -uo pipefail + +SCHEDULER_DIR="${HOME}/.claude/scheduler" +TASKS_DIR="${SCHEDULER_DIR}/tasks" +LOGS_DIR="${SCHEDULER_DIR}/logs" +NOTIFICATIONS_DIR="${SCHEDULER_DIR}/notifications" +ENV_FILE="${SCHEDULER_DIR}/.env" + +TASK_ID="${1:-}" + +if [ -z "$TASK_ID" ]; then + echo "Usage: triple-c-task-runner " >&2 + exit 1 +fi + +TASK_FILE="${TASKS_DIR}/${TASK_ID}.json" +LOCK_FILE="${SCHEDULER_DIR}/.lock-${TASK_ID}" + +if [ ! -f "$TASK_FILE" ]; then + echo "Task file not found: $TASK_FILE" >&2 + exit 1 +fi + +# ── Acquire lock (prevent overlapping runs of the same task) ───────────────── +exec 200>"$LOCK_FILE" +if ! flock -n 200; then + echo "Task $TASK_ID is already running, skipping." >&2 + exit 0 +fi + +# ── Source saved environment ───────────────────────────────────────────────── +if [ -f "$ENV_FILE" ]; then + set -a + # shellcheck disable=SC1090 + source "$ENV_FILE" + set +a +fi + +# ── Read task definition ──────────────────────────────────────────────────── +PROMPT=$(jq -r '.prompt' "$TASK_FILE") +WORKING_DIR=$(jq -r '.working_dir // "/workspace"' "$TASK_FILE") +TASK_NAME=$(jq -r '.name' "$TASK_FILE") +TASK_TYPE=$(jq -r '.type' "$TASK_FILE") + +# ── Prepare log directory ─────────────────────────────────────────────────── +TASK_LOG_DIR="${LOGS_DIR}/${TASK_ID}" +mkdir -p "$TASK_LOG_DIR" + +TIMESTAMP=$(date +"%Y%m%d-%H%M%S") +LOG_FILE="${TASK_LOG_DIR}/${TIMESTAMP}.log" + +# ── Execute Claude agent ──────────────────────────────────────────────────── +{ + echo "=== Task: $TASK_NAME ($TASK_ID) ===" + echo "=== Started: $(date) ===" + echo "=== Working dir: $WORKING_DIR ===" + echo "=== Prompt: $PROMPT ===" + echo "" +} > "$LOG_FILE" + +EXIT_CODE=0 +if [ -d "$WORKING_DIR" ]; then + cd "$WORKING_DIR" + claude -p "$PROMPT" --dangerously-skip-permissions >> "$LOG_FILE" 2>&1 || EXIT_CODE=$? +else + echo "Error: working directory '$WORKING_DIR' does not exist" >> "$LOG_FILE" + EXIT_CODE=1 +fi + +{ + echo "" + echo "=== Finished: $(date) ===" + echo "=== Exit code: $EXIT_CODE ===" +} >> "$LOG_FILE" + +# ── Write notification ────────────────────────────────────────────────────── +mkdir -p "$NOTIFICATIONS_DIR" +NOTIFY_FILE="${NOTIFICATIONS_DIR}/${TASK_ID}_${TIMESTAMP}.notify" + +if [ $EXIT_CODE -eq 0 ]; then + STATUS="SUCCESS" +else + STATUS="FAILED (exit code $EXIT_CODE)" +fi + +# Extract a summary (last 10 meaningful lines before the footer) +SUMMARY=$(grep -v "^===" "$LOG_FILE" | grep -v "^$" | tail -n 10) + +cat > "$NOTIFY_FILE" < /dev/null 2>&1 || true + # Direct crontab rebuild (in case scheduler list doesn't trigger it) + TMP_CRON=$(mktemp) + echo "# Triple-C scheduled tasks — managed by triple-c-scheduler" > "$TMP_CRON" + echo "# Do not edit manually; changes will be overwritten." >> "$TMP_CRON" + echo "" >> "$TMP_CRON" + for tf in "$TASKS_DIR"/*.json; do + [ -f "$tf" ] || continue + local_enabled=$(jq -r '.enabled' "$tf") + [ "$local_enabled" = "true" ] || continue + local_schedule=$(jq -r '.schedule' "$tf") + local_id=$(jq -r '.id' "$tf") + echo "$local_schedule /usr/local/bin/triple-c-task-runner $local_id" >> "$TMP_CRON" + done + crontab "$TMP_CRON" 2>/dev/null || true + rm -f "$TMP_CRON" +fi + +# ── Prune old logs (keep 20 per task) ─────────────────────────────────────── +LOG_COUNT=$(find "$TASK_LOG_DIR" -name "*.log" -type f 2>/dev/null | wc -l) +if [ "$LOG_COUNT" -gt 20 ]; then + find "$TASK_LOG_DIR" -name "*.log" -type f | sort | head -n $((LOG_COUNT - 20)) | xargs rm -f +fi + +# ── 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 + find "$NOTIFICATIONS_DIR" -name "*.notify" -type f | sort | head -n $((NOTIFY_COUNT - 50)) | xargs rm -f +fi + +# Release lock +flock -u 200 +rm -f "$LOCK_FILE" + +exit $EXIT_CODE