use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use serde::{Deserialize, Serialize};
use crate::paths::{
routine_dir, routine_gitignore_path, routine_prompt_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>,
prompt: Option<String>,
#[serde(default)]
repositories: Vec<Repository>,
enabled: Option<bool>,
created_at: Option<u64>,
updated_at: Option<u64>,
#[serde(default, skip_serializing)]
last_triggered_at: Option<u64>,
#[serde(default)]
ttl_secs: Option<u64>,
#[serde(default)]
max_runtime_secs: Option<u64>,
}
#[derive(Debug, Deserialize, Serialize)]
struct RuntimeState {
#[serde(default)]
last_triggered_at: Option<u64>,
}
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) -> Option<u64> {
let text = std::fs::read_to_string(routine_state_path(dir_name)).ok()?;
toml::from_str::<RuntimeState>(&text)
.ok()?
.last_triggered_at
}
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 last_triggered_at = read_runtime_state(dir_name).or(toml.last_triggered_at);
Some(Routine {
id,
schedule: toml.schedule?,
title,
agent: toml.agent?,
prompt: toml.prompt.unwrap_or_default(),
repositories: toml.repositories,
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_triggered_at,
ttl_secs: toml.ttl_secs,
max_runtime_secs: toml.max_runtime_secs,
})
}
pub fn write_routine(routine: &Routine) -> std::io::Result<()> {
let slug = slugify(&routine.title);
let dir = routine_dir(&slug);
std::fs::create_dir_all(&dir)?;
let gitignore = routine_gitignore_path(&slug);
if !gitignore.exists() {
std::fs::write(&gitignore, "*.local.*\n*.log\n")?;
}
let toml_routine = RoutineToml {
id: Some(routine.id.clone()),
schedule: Some(routine.schedule.clone()),
title: Some(routine.title.clone()),
agent: Some(routine.agent.clone()),
prompt: Some(routine.prompt.clone()),
repositories: routine.repositories.clone(),
enabled: Some(routine.enabled),
created_at: Some(routine.created_at),
updated_at: Some(routine.updated_at),
last_triggered_at: None,
ttl_secs: routine.ttl_secs,
max_runtime_secs: routine.max_runtime_secs,
};
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_prompt_path(&slug),
compose_prompt(routine).as_bytes(),
)?;
write_runtime_state(&slug, routine.last_triggered_at)?;
Ok(())
}
fn write_runtime_state(slug: &str, last_triggered_at: Option<u64>) -> std::io::Result<()> {
let path = routine_state_path(slug);
match last_triggered_at {
Some(_) => {
let state = RuntimeState { last_triggered_at };
let text = toml::to_string_pretty(&state).map_err(std::io::Error::other)?;
atomic_write(&path, text.as_bytes())?;
}
None => {
if path.exists() {
std::fs::remove_file(&path)?;
}
}
}
Ok(())
}
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 migrate_prompt_files() {
migrate_prompt_files_from_dir(&routines_dir());
}
pub(crate) fn migrate_prompt_files_from_dir(dir: &std::path::Path) {
let entries = match std::fs::read_dir(dir) {
Ok(entries) => entries,
Err(_) => return,
};
for entry in entries.flatten() {
if !entry.file_type().map(|ft| ft.is_dir()).unwrap_or(false) {
continue;
}
let old = entry.path().join("prompt.txt");
let new = entry.path().join("prompt.md");
if old.exists() && !new.exists() {
if let Err(err) = std::fs::rename(&old, &new) {
log::warn!("migrate_prompt_files: failed to rename {:?}: {err}", old);
}
}
}
}
pub fn migrate_routine_dirs() {
migrate_routine_dirs_from_dir(&routines_dir());
}
pub(crate) fn migrate_routine_dirs_from_dir(dir: &std::path::Path) {
let entries = match std::fs::read_dir(dir) {
Ok(entries) => entries,
Err(_) => return,
};
for entry in entries.flatten() {
if !entry.file_type().map(|ft| ft.is_dir()).unwrap_or(false) {
continue;
}
let dir_name = entry.file_name().to_string_lossy().to_string();
let Some(routine) = load_routine_from_dir(&dir_name) else {
continue;
};
let slug = slugify(&routine.title);
if slug == dir_name {
continue;
}
if let Err(err) = write_routine(&routine) {
log::warn!("migrate_routine_dirs: failed to write {slug:?}: {err}; leaving legacy dir");
continue;
}
if let Err(err) = remove_routine_dir(&dir_name) {
log::warn!("migrate_routine_dirs: failed to remove legacy dir {dir_name:?}: {err}");
}
}
}
pub fn repersist_routines(store: &RoutineStore) {
let routines: Vec<Routine> = store.lock().unwrap().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().map(|ft| ft.is_dir()).unwrap_or(false) {
let dir_name = entry.file_name().to_string_lossy().to_string();
if let Some(routine) = load_routine_from_dir(&dir_name) {
routines.insert(routine.id.clone(), routine);
}
}
}
}
Arc::new(Mutex::new(routines))
}
#[cfg(test)]
#[path = "routine_storage_tests.rs"]
mod routine_storage_tests;