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>,
}
#[derive(Debug, Deserialize, Serialize)]
struct LegacyScheduledState {
#[serde(default)]
last_scheduled_trigger_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) -> 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,
ttl_secs: toml.ttl_secs,
max_runtime_secs: toml.max_runtime_secs,
tags: toml.tags,
})
}
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)?;
std::fs::create_dir_all(routine_prompts_dir(&slug))?;
let gitignore = routine_gitignore_path(&slug);
if !gitignore.exists() {
std::fs::write(&gitignore, "*.local.*\n*.log\nrun.sh\n")?;
}
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() {
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,
};
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 migrate_trigger_logs() {
migrate_trigger_logs_from_dir(&routines_dir());
}
pub(crate) fn migrate_trigger_logs_from_dir(dir: &std::path::Path) {
let Ok(entries) = std::fs::read_dir(dir) else {
return;
};
for entry in entries.flatten() {
if !entry.file_type().is_ok_and(|ft| ft.is_dir()) {
continue;
}
let routine_dir = entry.path();
let old_sched = routine_dir.join("scheduled.local.toml");
let new_sched = routine_dir.join("scheduled.log");
if old_sched.exists() && !new_sched.exists() {
if let Some(ts) = std::fs::read_to_string(&old_sched)
.ok()
.and_then(|text| toml::from_str::<LegacyScheduledState>(&text).ok())
.and_then(|state| state.last_scheduled_trigger_at)
{
let line = format!("{ts}\n");
if let Err(err) = std::fs::write(&new_sched, line.as_bytes()) {
log::warn!(
"migrate_trigger_logs: failed to write {}: {err}",
new_sched.display()
);
continue;
}
}
let _ = std::fs::remove_file(&old_sched);
}
let new_manual = routine_dir.join("manual.log");
if !new_manual.exists() {
if let Some(ts) = std::fs::read_to_string(routine_dir.join("state.local.toml"))
.ok()
.and_then(|text| toml::from_str::<RuntimeState>(&text).ok())
.and_then(|state| state.last_manual_trigger_at)
{
let line = format!("{ts}\n");
if let Err(err) = std::fs::write(&new_manual, line.as_bytes()) {
log::warn!(
"migrate_trigger_logs: failed to write {}: {err}",
new_manual.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 migrate_prompt_files() {
migrate_prompt_files_from_dir(&routines_dir());
}
pub(crate) fn migrate_prompt_files_from_dir(dir: &std::path::Path) {
let Ok(entries) = std::fs::read_dir(dir) else {
return;
};
for entry in entries.flatten() {
if !entry.file_type().is_ok_and(|ft| ft.is_dir()) {
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.display()
);
}
}
}
}
pub fn migrate_prompts_to_subfolder() {
migrate_prompts_to_subfolder_from_dir(&routines_dir());
}
pub(crate) fn migrate_prompts_to_subfolder_from_dir(dir: &std::path::Path) {
let Ok(entries) = std::fs::read_dir(dir) else {
return;
};
for entry in entries.flatten() {
if !entry.file_type().is_ok_and(|ft| ft.is_dir()) {
continue;
}
let prompts_dir = entry.path().join("prompts");
if let Err(err) = std::fs::create_dir_all(&prompts_dir) {
log::warn!(
"migrate_prompts_to_subfolder: failed to create {}: {err}",
prompts_dir.display()
);
continue;
}
let old_compiled = entry.path().join("prompt.md");
let new_compiled = prompts_dir.join("prompt.compiled.md");
if old_compiled.exists() && !new_compiled.exists() {
if let Err(err) = std::fs::rename(&old_compiled, &new_compiled) {
log::warn!(
"migrate_prompts_to_subfolder: failed to rename {}: {err}",
old_compiled.display()
);
}
}
let pure = prompts_dir.join("prompt.pure.md");
if !pure.exists() {
let legacy_prompt = read_routine_toml(&entry.path().join("routine.toml"))
.and_then(|toml| toml.prompt)
.unwrap_or_default();
if let Err(err) = std::fs::write(&pure, legacy_prompt.as_bytes()) {
log::warn!(
"migrate_prompts_to_subfolder: failed to write {}: {err}",
pure.display()
);
}
}
}
}
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 Ok(entries) = std::fs::read_dir(dir) else {
return;
};
for entry in entries.flatten() {
if !entry.file_type().is_ok_and(|ft| ft.is_dir()) {
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_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))
}
#[cfg(test)]
#[path = "routine_storage_tests.rs"]
mod routine_storage_tests;