Compare commits
1 Commits
v0.1.49-wi
...
v0.1.50-wi
| Author | SHA1 | Date | |
|---|---|---|---|
| 2e81b52205 |
1
app/src-tauri/Cargo.lock
generated
1
app/src-tauri/Cargo.lock
generated
@@ -4675,6 +4675,7 @@ dependencies = [
|
||||
"dirs",
|
||||
"fern",
|
||||
"futures-util",
|
||||
"iana-time-zone",
|
||||
"keyring",
|
||||
"log",
|
||||
"reqwest 0.12.28",
|
||||
|
||||
@@ -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 = [] }
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -29,6 +29,33 @@ pub async fn pull_image(
|
||||
.await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn detect_host_timezone() -> Result<String, String> {
|
||||
// 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<Option<String>, String> {
|
||||
if let Some(home) = dirs::home_dir() {
|
||||
|
||||
@@ -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<String, String> {
|
||||
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<bool, String> {
|
||||
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| {
|
||||
|
||||
@@ -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<bool, String> {
|
||||
let docker = get_docker()?;
|
||||
@@ -135,6 +137,20 @@ fn create_build_context() -> Result<Vec<u8>, 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()?;
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -68,6 +68,8 @@ pub struct AppSettings {
|
||||
pub auto_check_updates: bool,
|
||||
#[serde(default)]
|
||||
pub dismissed_update_version: Option<String>,
|
||||
#[serde(default)]
|
||||
pub timezone: Option<String>,
|
||||
}
|
||||
|
||||
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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<EnvVar[]>(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() {
|
||||
<DockerSettings />
|
||||
<AwsSettings />
|
||||
|
||||
{/* Container Timezone */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Container Timezone</label>
|
||||
<p className="text-xs text-[var(--text-secondary)] mb-1.5">
|
||||
Timezone for containers — affects scheduled task timing (IANA format, e.g. America/New_York)
|
||||
</p>
|
||||
<input
|
||||
type="text"
|
||||
value={timezone}
|
||||
onChange={(e) => 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)]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Global Claude Instructions */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Claude Instructions</label>
|
||||
|
||||
@@ -35,6 +35,8 @@ export const detectAwsConfig = () =>
|
||||
invoke<string | null>("detect_aws_config");
|
||||
export const listAwsProfiles = () =>
|
||||
invoke<string[]>("list_aws_profiles");
|
||||
export const detectHostTimezone = () =>
|
||||
invoke<string>("detect_host_timezone");
|
||||
|
||||
// Terminal
|
||||
export const openTerminalSession = (projectId: string, sessionId: string) =>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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"
|
||||
|
||||
436
container/triple-c-scheduler
Normal file
436
container/triple-c-scheduler
Normal file
@@ -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 <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
|
||||
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
|
||||
142
container/triple-c-task-runner
Normal file
142
container/triple-c-task-runner
Normal file
@@ -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 <task-id>" >&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" <<NOTIFY
|
||||
Task: $TASK_NAME ($TASK_ID)
|
||||
Status: $STATUS
|
||||
Time: $(date)
|
||||
Type: $TASK_TYPE
|
||||
|
||||
Summary:
|
||||
$SUMMARY
|
||||
NOTIFY
|
||||
|
||||
# ── One-time task cleanup ───────────────────────────────────────────────────
|
||||
if [ "$TASK_TYPE" = "once" ]; then
|
||||
rm -f "$TASK_FILE"
|
||||
# Rebuild crontab to remove the completed one-time task
|
||||
/usr/local/bin/triple-c-scheduler list > /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
|
||||
Reference in New Issue
Block a user