use crate::utils::lock::LockRecover;
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use serde::{Deserialize, Serialize};
use crate::paths::{
routine_compiled_prompt_path, routine_dir, routine_gitignore_path, routine_manual_log_path,
routine_prompts_dir, routine_pure_prompt_path, routine_scheduled_log_path, routine_script_path,
routine_state_path, routine_toml_path, routines_dir,
};
use crate::routines::{compose_prompt, slugify, Repository, Routine, RoutineStore};
use crate::utils::atomic::atomic_write;
#[derive(Debug, Deserialize, Serialize)]
struct RoutineToml {
id: Option<String>,
schedule: Option<String>,
title: Option<String>,
agent: Option<String>,
#[serde(default)]
model: Option<String>,
#[serde(default, skip_serializing)]
prompt: Option<String>,
#[serde(default)]
goal: Option<String>,
#[serde(default)]
repositories: Vec<Repository>,
#[serde(default)]
machines: Vec<String>,
enabled: Option<bool>,
created_at: Option<u64>,
updated_at: Option<u64>,
#[serde(default, skip_serializing, alias = "last_triggered_at")]
last_manual_trigger_at: Option<u64>,
#[serde(default)]
ttl_secs: Option<u64>,
#[serde(default)]
max_runtime_secs: Option<u64>,
#[serde(default)]
tags: Vec<String>,
}
#[derive(Debug, Default, Deserialize, Serialize)]
struct RuntimeState {
#[serde(default, skip_serializing)]
last_manual_trigger_at: Option<u64>,
#[serde(default)]
snoozed_until: Option<u64>,
#[serde(default)]
skip_runs: Option<u32>,
#[serde(default)]
power_saving: bool,
}
fn read_routine_toml(path: &std::path::PathBuf) -> Option<RoutineToml> {
let text = std::fs::read_to_string(path).ok()?;
toml::from_str(&text).ok()
}
fn read_runtime_state(dir_name: &str) -> RuntimeState {
std::fs::read_to_string(routine_state_path(dir_name))
.ok()
.and_then(|text| toml::from_str(&text).ok())
.unwrap_or_default()
}
fn read_last_log_timestamp(path: &std::path::Path) -> Option<u64> {
let text = std::fs::read_to_string(path).ok()?;
text.lines()
.rev()
.find_map(|line| line.trim().parse::<u64>().ok())
}
fn read_scheduled_state(dir_name: &str) -> Option<u64> {
read_last_log_timestamp(&routine_scheduled_log_path(dir_name))
}
fn read_manual_state(dir_name: &str) -> Option<u64> {
read_last_log_timestamp(&routine_manual_log_path(dir_name))
}
fn read_pure_prompt(dir_name: &str, legacy: Option<String>) -> String {
std::fs::read_to_string(routine_pure_prompt_path(dir_name))
.ok()
.or(legacy)
.unwrap_or_default()
}
fn load_routine_from_dir(dir_name: &str) -> Option<Routine> {
let toml = read_routine_toml(&routine_toml_path(dir_name))?;
let title = toml.title?;
let id = toml.id.unwrap_or_else(|| dir_name.to_string());
let runtime_state = read_runtime_state(dir_name);
let last_manual_trigger_at = read_manual_state(dir_name)
.or(runtime_state.last_manual_trigger_at)
.or(toml.last_manual_trigger_at);
let last_scheduled_trigger_at = read_scheduled_state(dir_name);
let prompt = read_pure_prompt(dir_name, toml.prompt);
Some(Routine {
id,
schedule: toml.schedule?,
title,
agent: toml.agent?,
model: toml.model,
prompt,
goal: toml.goal,
repositories: toml.repositories,
machines: toml.machines,
enabled: toml.enabled.unwrap_or(true),
source: "managed".to_string(),
created_at: toml.created_at.unwrap_or(0),
updated_at: toml.updated_at.unwrap_or(0),
last_manual_trigger_at,
last_scheduled_trigger_at,
snoozed_until: runtime_state.snoozed_until,
skip_runs: runtime_state.skip_runs,
power_saving: runtime_state.power_saving,
ttl_secs: toml.ttl_secs,
max_runtime_secs: toml.max_runtime_secs,
tags: toml.tags,
})
}
const ROUTINE_GITIGNORE_REQUIRED: &[&str] = &["*.local.*", "*.log", "run.sh"];
fn ensure_routine_gitignore(path: &std::path::Path) -> std::io::Result<()> {
let existing = std::fs::read_to_string(path).unwrap_or_default();
let lines: Vec<&str> = existing.lines().collect();
let missing: Vec<&str> = ROUTINE_GITIGNORE_REQUIRED
.iter()
.copied()
.filter(|pat| !lines.iter().any(|line| line.trim() == *pat))
.collect();
if missing.is_empty() {
return Ok(());
}
let mut content = existing;
if !content.is_empty() && !content.ends_with('\n') {
content.push('\n');
}
for pattern in &missing {
content.push_str(pattern);
content.push('\n');
}
std::fs::write(path, &content)
}
pub fn write_routine(routine: &Routine) -> std::io::Result<()> {
let slug = slugify(&routine.title);
let dir = routine_dir(&slug);
if let Some(existing_id) =
read_routine_toml(&routine_toml_path(&slug)).and_then(|existing| existing.id)
{
if existing_id != routine.id {
return Err(std::io::Error::new(
std::io::ErrorKind::AlreadyExists,
format!(
"slug \"{slug}\" is already used on disk by routine {existing_id}; refusing to overwrite it"
),
));
}
}
crate::utils::fs_perms::create_private_dir_all(&dir)?;
crate::utils::fs_perms::create_private_dir_all(&routine_prompts_dir(&slug))?;
ensure_routine_gitignore(&routine_gitignore_path(&slug))?;
let _ = std::fs::remove_file(routine_script_path(&slug));
let toml_routine = RoutineToml {
id: Some(routine.id.clone()),
schedule: Some(routine.schedule.clone()),
title: Some(routine.title.clone()),
agent: Some(routine.agent.clone()),
model: routine.model.clone(),
prompt: None,
goal: routine.goal.clone(),
repositories: routine.repositories.clone(),
machines: routine.machines.clone(),
enabled: Some(routine.enabled),
created_at: Some(routine.created_at),
updated_at: Some(routine.updated_at),
last_manual_trigger_at: None,
ttl_secs: routine.ttl_secs,
max_runtime_secs: routine.max_runtime_secs,
tags: routine.tags.clone(),
};
let text = toml::to_string_pretty(&toml_routine).expect(
"RoutineToml serialization cannot fail for a struct with only primitive and Option fields",
);
atomic_write(&routine_toml_path(&slug), text.as_bytes())?;
atomic_write(&routine_pure_prompt_path(&slug), routine.prompt.as_bytes())?;
atomic_write(
&routine_compiled_prompt_path(&slug),
compose_prompt(routine).as_bytes(),
)?;
write_runtime_state(&slug, routine)?;
Ok(())
}
fn write_runtime_state(slug: &str, routine: &Routine) -> std::io::Result<()> {
let path = routine_state_path(slug);
if routine.snoozed_until.is_none() && routine.skip_runs.is_none() && !routine.power_saving {
if path.exists() {
std::fs::remove_file(&path)?;
}
return Ok(());
}
let state = RuntimeState {
last_manual_trigger_at: None,
snoozed_until: routine.snoozed_until,
skip_runs: routine.skip_runs,
power_saving: routine.power_saving,
};
let text = toml::to_string_pretty(&state)
.expect("RuntimeState serialization cannot fail for a struct with only Option fields");
atomic_write(&path, text.as_bytes())?;
Ok(())
}
pub fn append_manual_trigger_log(slug: &str, ts: u64) {
let path = routine_manual_log_path(slug);
let line = format!("{ts}\n");
if let Err(err) = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(&path)
.and_then(|mut file| std::io::Write::write_all(&mut file, line.as_bytes()))
{
log::warn!(
"append_manual_trigger_log: failed to write {}: {err}",
path.display()
);
}
}
pub fn remove_routine_dir(slug: &str) -> std::io::Result<()> {
let dir = routine_dir(slug);
if dir.exists() {
std::fs::remove_dir_all(dir)?;
}
Ok(())
}
pub fn repersist_routines(store: &RoutineStore) {
let routines: Vec<Routine> = store.lock_recover().values().cloned().collect();
for routine in &routines {
if let Err(err) = write_routine(routine) {
log::warn!(
"repersist_routines: failed to write routine {:?}: {err}",
routine.id
);
}
}
}
pub fn load_store() -> RoutineStore {
load_store_from_dir(&routines_dir())
}
pub(crate) fn load_store_from_dir(dir: &std::path::Path) -> RoutineStore {
let mut routines = HashMap::new();
if let Ok(entries) = std::fs::read_dir(dir) {
for entry in entries.flatten() {
if entry.file_type().is_ok_and(|ft| ft.is_dir()) {
let dir_name = entry.file_name().to_string_lossy().to_string();
match load_routine_from_dir(&dir_name) {
Some(routine) => {
routines.insert(routine.id.clone(), routine);
}
None if routine_toml_path(&dir_name).exists() => {
log::warn!(
"load_store: skipping routine dir {dir_name:?}: its routine.toml is \
unparsable or missing a required field (title, schedule, or agent)"
);
}
None => {}
}
}
}
}
Arc::new(Mutex::new(routines))
}
#[path = "routine_storage_migrations.rs"]
mod routine_storage_migrations;
pub use routine_storage_migrations::{
migrate_compiled_prompt_filename, migrate_prompt_files, migrate_prompts_to_subfolder,
migrate_routine_dirs, migrate_trigger_logs,
};
#[cfg(test)]
pub(crate) use routine_storage_migrations::{
migrate_compiled_prompt_filename_from_dir, migrate_prompt_files_from_dir,
migrate_prompts_to_subfolder_from_dir, migrate_routine_dirs_from_dir,
migrate_trigger_logs_from_dir,
};
#[cfg(test)]
#[path = "routine_storage_tests.rs"]
mod routine_storage_tests;
#[cfg(test)]
#[path = "routine_storage_prompt_sidecar_tests.rs"]
mod routine_storage_prompt_sidecar_tests;
#[cfg(test)]
#[path = "routine_storage_migration_tests.rs"]
mod routine_storage_migration_tests;
#[cfg(test)]
#[path = "routine_storage_prompt_file_migration_tests.rs"]
mod routine_storage_prompt_file_migration_tests;
#[cfg(test)]
#[path = "routine_storage_compiled_prompt_migration_tests.rs"]
mod routine_storage_compiled_prompt_migration_tests;
#[cfg(test)]
#[path = "routine_storage_snooze_tests.rs"]
mod routine_storage_snooze_tests;
#[cfg(test)]
#[path = "routine_storage_trigger_log_migration_tests.rs"]
mod routine_storage_trigger_log_migration_tests;
#[cfg(test)]
#[path = "routine_storage_sidecar_state_tests.rs"]
mod routine_storage_sidecar_state_tests;
#[cfg(test)]
#[path = "routine_storage_slug_collision_tests.rs"]
mod routine_storage_slug_collision_tests;