use crate::utils::lock::LockRecover;
use crate::paths::routines_dir;
#[cfg(test)]
use std::collections::HashMap;
#[cfg(test)]
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_script_path, routine_skip_log_path,
routine_state_path, routine_toml_path,
};
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(base: &std::path::Path, dir_name: &str) -> RuntimeState {
std::fs::read_to_string(base.join(dir_name).join("state.local.toml"))
.ok()
.and_then(|text| toml::from_str(&text).ok())
.unwrap_or_default()
}
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 append_skip_log(slug: &str, ts: u64, reason: &str) {
let path = routine_skip_log_path(slug);
let line = format!("{ts}\t{reason}\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_skip_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
);
}
}
}
#[path = "routine_storage_load.rs"]
mod routine_storage_load;
use routine_storage_load::load_routine_from_dir;
pub use routine_storage_load::load_store;
#[cfg(test)]
pub(crate) use routine_storage_load::load_store_from_dir;
pub(crate) use routine_storage_load::reload_store_from_dir;
#[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;