1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
#![allow(
clippy::wildcard_imports,
clippy::too_many_arguments,
clippy::needless_pass_by_value,
dead_code,
reason = "split-out module preserves existing code while keeping files under the linecheck limit"
)]
use super::*;
/// Read a routine's `state.local.toml` sidecar under `base`, defaulting to an empty
/// [`RuntimeState`] when the sidecar is absent or unparsable (e.g. before the routine has ever
/// been snoozed).
///
/// Base-dir-aware so the `routine_storage_load` loaders can resolve it coherently for any scan
/// root, not only the global [`routines_dir`].
pub(crate) 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()
}
/// Read a routine's gitignored `routine.local.toml` sidecar (`{routines_dir}/{id}/routine.local.toml`,
/// see [`crate::paths::routine_local_toml_path`]), defaulting to an empty map when absent or
/// unparsable — mirrors [`read_runtime_state()`].
///
/// **Values only ever flow into [`crate::routines::build_routine_command`]**, which reads
/// this right before it shell-quotes each entry into an `export KEY=value` launch statement. Every
/// other caller (API responses, the UI, logs) must go through [`local_env_keys`] instead, which
/// discards the values this returns and keeps only the key names.
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()
}
/// Return only the *key names* set in a routine's `routine.local.toml` sidecar, never their
/// values — the redaction-safe read used by [`crate::routines::RoutineResponse::from_routine`]
/// to surface "what's configured" without ever exposing a secret over the API (#408).
pub(crate) fn local_env_keys(id: &str) -> Vec<String> {
read_local_env(id).into_keys().collect()
}
/// Write `routine` to disk: `routine.toml` (tracked config), `schedule.cron` (tracked cron entry),
/// the `prompts/prompt.pure.md` (raw) and `prompts/prompt.compiled.local.md` (composed) sidecars,
/// and the gitignored `state.local.toml` runtime sidecar. Gitignore coverage for the machine-local
/// files comes from the single config-dir `.gitignore` (`cli_system::ensure_config_gitignore`),
/// whose patterns apply recursively; per-routine `.gitignore` files are no longer generated. One
/// left behind by an older daemon is not touched — it may carry user-added patterns, and it stays
/// correct alongside the root one.
///
/// Existing routines are written back to their current filesystem location under `routines/`.
/// The title-derived slug is used only for brand-new routines that do not have a persisted
/// `routine.toml` yet. The UUID `id` is stored inside `routine.toml` so it survives a title rename
/// or explicit filesystem move. Daemon-written runtime state goes to the sidecar, not
/// `routine.toml`, so a trigger never churns the version-controlled config file.
///
/// The target directory's `routine.toml`, when present, must belong to the same id. This is the last
/// line of defense against silently overwriting another routine's files (#188).
pub fn write_routine(routine: &Routine) -> std::io::Result<()> {
let rel_dir = routine_storage_location::routine_rel_dir(routine);
write_routine_to_rel_dir(routine, &rel_dir)
}
/// Write `routine` to an explicit directory relative to `routines/`.
///
/// Used only by migrations that deliberately move legacy on-disk layouts; normal updates should use
/// [`write_routine`] so filesystem-owned locations are preserved.
pub(crate) fn write_routine_to_rel_dir(routine: &Routine, rel_dir: &str) -> std::io::Result<()> {
let dir = routine_dir(rel_dir);
if let Some(existing_id) =
read_routine_toml(&routine_toml_path(rel_dir)).and_then(|existing| existing.id)
{
if existing_id != routine.id {
return Err(std::io::Error::new(
std::io::ErrorKind::AlreadyExists,
format!(
"routine dir \"{rel_dir}\" 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(rel_dir))?;
crate::routine_storage::routine_overlap_policy::ensure_overlap_policy(rel_dir)?;
if !routine.enabled {
write_disabled_state(rel_dir, false, routine.disabled_reason.as_deref())?;
}
// Remove any stale `run.sh` left by an older daemon that generated per-routine launch scripts;
// the crontab line now invokes the binary directly, so the script is obsolete. Best-effort: a
// missing file is fine. Startup re-persists every routine, so this heals existing installs.
let _ = std::fs::remove_file(routine_script_path(rel_dir));
let toml_routine = RoutineToml {
id: Some(routine.id.clone()),
title: Some(routine.title.clone()),
agent: Some(routine.agent.clone()),
model: routine.model.clone(),
// Never written; the raw prompt now lives in the `prompts/prompt.pure.md` sidecar below.
prompt: None,
goal: routine.goal.clone(),
repositories: routine.repositories.clone(),
machines: routine.machines.clone(),
enabled: None,
power_saving_exempt: routine.power_saving_exempt,
created_at: Some(routine.created_at),
updated_at: Some(routine.updated_at),
// Runtime state is written to the sidecar below, never to the tracked `routine.toml`
// (`skip_serializing` also keeps this field out regardless of its value).
last_manual_trigger_at: None,
ttl_secs: routine.ttl_secs,
max_runtime_secs: routine.max_runtime_secs,
failure_threshold: routine.failure_threshold,
notifications: routine.notifications.clone(),
tags: routine.tags.clone(),
env: routine.env.clone(),
};
let text = toml::to_string_pretty(&toml_routine).map_err(std::io::Error::other)?;
// Atomic write (temp + rename) so any concurrent reader never observes a torn routine.toml —
// a torn file parses to `None` and would silently drop the routine from the store. (Note:
// there is no continuously-running reverse crontab sync re-reading these files; reverse sync
// is implemented but not wired up — see issue #218.)
atomic_write(&routine_toml_path(rel_dir), text.as_bytes())?;
atomic_write(
&routine_cron_path(rel_dir),
format!("{}\n", routine.effective_schedules().join("\n")).as_bytes(),
)?;
atomic_write(
&routine_pure_prompt_path(rel_dir),
routine.prompt.as_bytes(),
)?;
atomic_write(
&routine_compiled_prompt_path(rel_dir),
compose_prompt(routine).as_bytes(),
)?;
write_runtime_state(rel_dir, routine)?;
if routine.enabled {
write_disabled_state(rel_dir, true, None)?;
}
Ok(())
}
/// Persist a routine's runtime state to its gitignored `state.local.toml` sidecar.
///
/// Writes the sidecar (atomically) when any tracked field is set, and removes any stale sidecar
/// when all are `None`, so the on-disk state always mirrors the in-memory routine.
pub(crate) 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
&& routine.consecutive_failures == 0
&& routine.auto_disabled_reason.is_none()
{
if path.exists() {
std::fs::remove_file(&path)?;
}
return Ok(());
}
let state = RuntimeState {
// Not written (skip_serializing); stored only to satisfy the struct; reads migrate the
// legacy value from here into manual.log on first load.
last_manual_trigger_at: None,
snoozed_until: routine.snoozed_until,
skip_runs: routine.skip_runs,
power_saving: routine.power_saving,
consecutive_failures: routine.consecutive_failures,
auto_disabled_reason: routine.auto_disabled_reason.clone(),
};
let text = toml::to_string_pretty(&state).map_err(std::io::Error::other)?;
atomic_write(&path, text.as_bytes())?;
Ok(())
}
include!("append_manual_trigger_log.rs");