use crate::utils::lock::LockRecover;
use crate::paths::routines_dir;
use std::collections::HashMap;
#[cfg(test)]
use std::sync::{Arc, Mutex};
use serde::{Deserialize, Serialize};
use crate::paths::{
routine_compiled_prompt_path, routine_cron_path, routine_dir, 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>,
#[serde(default)]
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>,
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
env: HashMap<String, String>,
}
#[derive(Debug, Default, Deserialize)]
struct RoutineLocalToml {
#[serde(default)]
env: HashMap<String, 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_routine_cron(path: &std::path::PathBuf) -> Option<String> {
let text = std::fs::read_to_string(path).ok()?;
let schedule = text
.lines()
.map(str::trim)
.find(|line| !line.is_empty() && !line.starts_with('#'))?;
Some(schedule.to_string())
}
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()
}
pub(crate) fn read_local_env(id: &str) -> HashMap<String, String> {
std::fs::read_to_string(crate::paths::routine_local_toml_path(id))
.ok()
.and_then(|text| toml::from_str::<RoutineLocalToml>(&text).ok())
.map(|local| local.env)
.unwrap_or_default()
}
pub(crate) fn local_env_keys(id: &str) -> Vec<String> {
read_local_env(id).into_keys().collect()
}
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))?;
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(),
env: routine.env.clone(),
};
let text = toml::to_string_pretty(&toml_routine).map_err(std::io::Error::other)?;
atomic_write(&routine_toml_path(&slug), text.as_bytes())?;
atomic_write(
&routine_cron_path(&slug),
format!("{}\n", routine.schedule).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).map_err(std::io::Error::other)?;
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_more_tests.rs"]
mod routine_storage_more_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;
#[cfg(test)]
#[path = "routine_storage_gitignore_tests.rs"]
mod routine_storage_gitignore_tests;
#[cfg(test)]
#[path = "routine_storage_env_tests.rs"]
mod routine_storage_env_tests;