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
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
//! TOML-backed persistence for routines, plus the tracked `schedule.cron` and the
//! `prompts/prompt.pure.md` (raw) / `prompts/prompt.compiled.local.md` (composed) sidecar files.
use crate::utils::lock::LockRecover;
#[allow(
clippy::missing_docs_in_private_items,
reason = "split-out module keeps the file under the linecheck limit"
)]
#[path = "read_runtime_state.rs"]
mod read_runtime_state;
pub(crate) use read_runtime_state::*;
#[path = "routine_disabled_state.rs"]
mod routine_disabled_state;
pub(crate) use routine_disabled_state::*;
#[path = "routine_overlap_policy.rs"]
mod routine_overlap_policy;
pub(crate) use routine_overlap_policy::*;
#[cfg(test)]
#[path = "routine_overlap_policy_tests.rs"]
mod routine_overlap_policy_tests;
// Re-exported (as `super::routines_dir`) for `routine_storage_migrations`; not called directly
// in this file since `load_store`/`load_store_from_dir` moved to `routine_storage_load`.
use crate::paths::routines_dir;
use std::collections::HashMap;
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, FailureNotificationConfig, Repository, Routine, RoutineStore,
};
use crate::utils::atomic::atomic_write;
/// TOML representation of a routine on disk.
#[derive(Debug, Deserialize, Serialize)]
struct RoutineToml {
/// UUID that uniquely identifies this routine (stable across renames).
id: Option<String>,
/// Human name.
title: Option<String>,
/// Agent registry key.
agent: Option<String>,
/// Model ID override for the agent invocation; absent means the agent's own default.
#[serde(default)]
model: Option<String>,
/// Task prompt.
///
/// **Read-only / legacy.** The prompt now lives in the `prompts/prompt.pure.md` sidecar so it
/// is diff/edit-friendly markdown instead of an escaped TOML string. This field is still parsed
/// so routines written by older daemons keep their prompt (the value migrates into the sidecar
/// via [`migrate_prompts_to_subfolder`] on the next startup), but it is never written back —
/// `skip_serializing` keeps it out of every freshly written `routine.toml`.
#[serde(default, skip_serializing)]
prompt: Option<String>,
/// Short (≤5 line) goal statement; absent means unset.
#[serde(default)]
goal: Option<String>,
/// Context repositories.
#[serde(default)]
repositories: Vec<Repository>,
/// Machines this routine is assigned to run on (empty = nowhere). Tracked config: the
/// targeting decision is authored once in the shared repo, not per-machine sidecar state.
#[serde(default)]
machines: Vec<String>,
/// Whether the routine is enabled.
///
/// **Read-only / legacy.** Disable intent now lives in the tracked `disabled.json` marker:
/// marker present means disabled, marker absent means enabled. This field is still parsed so
/// older routine.toml files with `enabled = false` continue to load during migration, but it is
/// never written back.
#[serde(default, skip_serializing)]
enabled: Option<bool>,
/// Whether the routine may launch while the host is in system power saving.
#[serde(default)]
power_saving_exempt: bool,
/// Unix creation timestamp.
created_at: Option<u64>,
/// Unix last-updated timestamp.
updated_at: Option<u64>,
/// Unix timestamp of last manual trigger.
///
/// **Read-only / legacy.** Runtime trigger state now lives in the gitignored `state.local.toml`
/// sidecar ([`RuntimeState`]) so it no longer churns the version-controlled `routine.toml`.
/// This field is still parsed so routines written by older daemons keep their timestamp (the
/// value migrates into the sidecar on the next [`write_routine`]), but it is never written back
/// — `skip_serializing` keeps it out of every freshly written `routine.toml`. Accepts the
/// legacy `last_triggered_at` key so routine.toml files written before the rename still load.
#[serde(default, skip_serializing, alias = "last_triggered_at")]
last_manual_trigger_at: Option<u64>,
/// Workbench retention in seconds for finished runs; absent means the daemon default.
#[serde(default)]
ttl_secs: Option<u64>,
/// Max wall-clock seconds a single run may execute before the watchdog kills it; absent means
/// the daemon default.
#[serde(default)]
max_runtime_secs: Option<u64>,
/// Consecutive failed-or-unknown runs after which the daemon auto-disables this routine
/// (the failure circuit-breaker); `None`/`0` opts out. See
/// [`crate::routines::Routine::failure_threshold`] (#521).
#[serde(default)]
failure_threshold: Option<u32>,
/// Failure notification hook config.
#[serde(default, skip_serializing_if = "FailureNotificationConfig::is_empty")]
notifications: FailureNotificationConfig,
/// Free-form labels for the routine; absent means no tags.
#[serde(default)]
tags: Vec<String>,
/// Non-secret environment variables injected at launch (see [`crate::routines::Routine::env`]).
/// Absent/empty means none. Tracked and git-committed — never put a secret here; use the
/// gitignored `routine.local.toml` sidecar instead ([`RoutineLocalToml`]).
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
env: HashMap<String, String>,
}
/// TOML representation of a routine's untracked `routine.local.toml` sidecar: secret or
/// machine-local environment variable overrides that win over `routine.toml`'s `[env]` table at
/// launch time (issue #408).
///
/// Deliberately **not** part of [`RoutineToml`] or [`crate::routines::Routine`] — those are held
/// in the in-memory [`crate::routines::RoutineStore`] and serialized straight into API responses,
/// so a secret parsed into either would leak into every `GET /routines` response the moment it
/// loaded. This struct is read fresh from disk only where a value is actually needed
/// ([`read_local_env`], called from `build_routine_command` at launch time) and discarded
/// immediately after.
#[derive(Debug, Default, Deserialize)]
struct RoutineLocalToml {
/// Secret / machine-local env var overrides; absent means none.
#[serde(default)]
env: HashMap<String, String>,
}
/// Daemon-written runtime state for a routine, persisted to the gitignored `state.local.toml`
/// sidecar so it never appears in the version-controlled `routine.toml`.
///
/// Trigger history (`last_manual_trigger_at`, `last_scheduled_trigger_at`) is no longer stored
/// here — it lives in the append-only `manual.log` / `scheduled.log` files instead.
#[derive(Debug, Default, Deserialize, Serialize)]
pub(crate) struct RuntimeState {
/// Unix timestamp of the last manual trigger, or `None` if it has never been triggered.
///
/// **Read-only / legacy.** Manual trigger history now lives in the `manual.log` append-only
/// sidecar. This field is still parsed so routines written by older daemons keep their
/// timestamp (the value migrates into `manual.log` on the next startup), but it is never
/// written back — `skip_serializing` keeps it out of every freshly written `state.local.toml`.
#[serde(default, skip_serializing)]
last_manual_trigger_at: Option<u64>,
/// Unix timestamp until which scheduled fires are skipped, or `None`. See
/// [`crate::routines::Routine::snoozed_until`].
#[serde(default)]
snoozed_until: Option<u64>,
/// Count of upcoming scheduled fires still to skip, or `None`. See
/// [`crate::routines::Routine::skip_runs`].
#[serde(default)]
skip_runs: Option<u32>,
/// Whether firing is paused for power saving. See
/// [`crate::routines::Routine::power_saving`].
#[serde(default)]
power_saving: bool,
/// Count of consecutive failed-or-unknown runs. See
/// [`crate::routines::Routine::consecutive_failures`].
#[serde(default)]
consecutive_failures: u32,
/// Why the failure circuit-breaker last auto-disabled this routine, or `None`. See
/// [`crate::routines::Routine::auto_disabled_reason`].
#[serde(default)]
auto_disabled_reason: Option<String>,
}
/// Parse a routine TOML file at `path`, returning `None` on any error.
fn read_routine_toml(path: &std::path::PathBuf) -> Option<RoutineToml> {
let text = std::fs::read_to_string(path).ok()?;
toml::from_str(&text).ok()
}
/// Read a routine's tracked cron entries from `schedule.cron`, returning every line that is neither
/// empty nor a `#` comment.
pub(crate) fn read_routine_crons(path: &std::path::Path) -> Vec<String> {
let Ok(text) = std::fs::read_to_string(path) else {
return Vec::new();
};
text.lines()
.map(str::trim)
.filter(|line| !line.is_empty() && !line.starts_with('#'))
.map(ToString::to_string)
.collect()
}
include!("routine_storage_2.rs");