1use aidens_contracts::{
4 ArtifactId, CanonicalToolSideEffectClass, DaemonNamespaceV1, JobV1, QueueHopReportV1,
5 QueueLeaseV1, SafeModeReportV1,
6};
7use aidens_queue_kit::{
8 DurableQueueLogV1, QueueEnqueueOutcomeV1, QueueError, QueueLeaseOutcomeV1, QueueSnapshotV1,
9};
10use aidens_schedule_kit::{one_shot_occurrence, ScheduleError};
11use aidens_wake_kit::{wake_signal, WakeError};
12use chrono::{DateTime, Utc};
13use std::path::{Path, PathBuf};
14use thiserror::Error;
15
16#[derive(Debug, Error)]
17pub enum DaemonError {
18 #[error(transparent)]
19 Queue(#[from] QueueError),
20 #[error(transparent)]
21 Schedule(#[from] ScheduleError),
22 #[error(transparent)]
23 Wake(#[from] WakeError),
24}
25
26#[derive(Debug, Clone)]
27pub struct DaemonControllerV1 {
28 queue: DurableQueueLogV1,
29}
30
31impl DaemonControllerV1 {
32 pub fn namespace(
33 root_path: impl AsRef<Path>,
34 name: impl Into<String>,
35 owner: impl Into<String>,
36 ) -> DaemonNamespaceV1 {
37 let root_path = root_path.as_ref().display().to_string();
38 DaemonNamespaceV1::new(name, root_path, owner)
39 }
40
41 pub fn open(
42 root_path: impl AsRef<Path>,
43 namespace: DaemonNamespaceV1,
44 owner: impl Into<String>,
45 ) -> Result<Self, DaemonError> {
46 Ok(Self {
47 queue: DurableQueueLogV1::open(root_path, namespace, owner)?,
48 })
49 }
50
51 pub fn open_read_only(
52 root_path: impl AsRef<Path>,
53 namespace: DaemonNamespaceV1,
54 ) -> Result<Self, DaemonError> {
55 Ok(Self {
56 queue: DurableQueueLogV1::open_read_only(root_path, namespace)?,
57 })
58 }
59
60 pub fn queue_root(&self) -> PathBuf {
61 self.queue.root_path().to_path_buf()
62 }
63
64 pub fn namespace_id(&self) -> ArtifactId {
65 self.queue.namespace().namespace_id.clone()
66 }
67
68 pub fn enqueue_schedule_occurrence(
69 &self,
70 schedule_id: impl Into<String>,
71 occurrence_key: impl Into<String>,
72 due_at: DateTime<Utc>,
73 payload: serde_json::Value,
74 risk: CanonicalToolSideEffectClass,
75 ) -> Result<QueueEnqueueOutcomeV1, DaemonError> {
76 let occurrence = one_shot_occurrence(
77 self.namespace_id(),
78 schedule_id,
79 occurrence_key,
80 due_at,
81 payload,
82 risk,
83 )?;
84 Ok(self.queue.enqueue_from_schedule(occurrence)?)
85 }
86
87 pub fn enqueue_wake_signal(
88 &self,
89 source: impl Into<String>,
90 signal_key: impl Into<String>,
91 payload: serde_json::Value,
92 risk: CanonicalToolSideEffectClass,
93 ) -> Result<QueueEnqueueOutcomeV1, DaemonError> {
94 let signal = wake_signal(self.namespace_id(), source, signal_key, payload, risk)?;
95 Ok(self.queue.enqueue_from_wake(signal)?)
96 }
97
98 pub fn enqueue_job(&self, job: JobV1) -> Result<QueueEnqueueOutcomeV1, DaemonError> {
99 Ok(self.queue.enqueue_job(job)?)
100 }
101
102 pub fn acquire_next(
103 &self,
104 owner: impl Into<String>,
105 ttl_seconds: i64,
106 ) -> Result<Option<QueueLeaseOutcomeV1>, DaemonError> {
107 Ok(self.queue.acquire_next_lease(owner, ttl_seconds)?)
108 }
109
110 pub fn cancel(
111 &self,
112 job_id: &ArtifactId,
113 reason: impl Into<String>,
114 ) -> Result<QueueHopReportV1, DaemonError> {
115 Ok(self.queue.cancel_job(job_id, reason)?)
116 }
117
118 pub fn complete(
119 &self,
120 job_id: &ArtifactId,
121 lease: &QueueLeaseV1,
122 ) -> Result<QueueHopReportV1, DaemonError> {
123 Ok(self
124 .queue
125 .complete_job(job_id, Some(lease.lease_id.clone()))?)
126 }
127
128 pub fn set_safe_mode(
129 &self,
130 enabled: bool,
131 reason: impl Into<String>,
132 ) -> Result<SafeModeReportV1, DaemonError> {
133 Ok(self.queue.set_safe_mode(enabled, reason)?)
134 }
135
136 pub fn drain(&self, reason: impl Into<String>) -> Result<Vec<QueueHopReportV1>, DaemonError> {
137 Ok(self.queue.drain_non_terminal(reason)?)
138 }
139
140 pub fn snapshot(&self) -> Result<QueueSnapshotV1, DaemonError> {
141 Ok(self.queue.snapshot()?)
142 }
143}
144
145#[cfg(test)]
146mod tests {
147 use super::*;
148 use aidens_contracts::JobStateV1;
149
150 fn temp_root(name: &str) -> PathBuf {
151 let root =
152 std::env::temp_dir().join(format!("aidens-daemon-p11-{name}-{}", std::process::id()));
153 let _ = std::fs::remove_dir_all(&root);
154 root
155 }
156
157 #[test]
158 fn daemon_restart_preserves_jobs_and_cancelled_state() {
159 let root = temp_root("restart");
160 let ns = DaemonControllerV1::namespace(&root, "daemon-restart", "daemon-a");
161 let daemon = DaemonControllerV1::open(&root, ns.clone(), "daemon-a").unwrap();
162 let outcome = daemon
163 .enqueue_schedule_occurrence(
164 "once",
165 "occurrence-1",
166 Utc::now(),
167 serde_json::json!({"task":"work"}),
168 CanonicalToolSideEffectClass::ReadOnly,
169 )
170 .unwrap();
171 let job = outcome.job.unwrap();
172 daemon.cancel(&job.job_id, "operator-cancelled").unwrap();
173
174 let restarted = DaemonControllerV1::open(&root, ns, "daemon-a").unwrap();
175 let snapshot = restarted.snapshot().unwrap();
176 assert_eq!(snapshot.jobs.len(), 1);
177 assert_eq!(snapshot.jobs[0].state, JobStateV1::Cancelled);
178 assert!(restarted.acquire_next("daemon-a", 30).unwrap().is_none());
179 let _ = std::fs::remove_dir_all(&root);
180 }
181
182 #[test]
183 fn repeated_schedule_occurrence_suppresses_duplicate_job() {
184 let root = temp_root("duplicate");
185 let ns = DaemonControllerV1::namespace(&root, "daemon-duplicate", "daemon-a");
186 let daemon = DaemonControllerV1::open(&root, ns, "daemon-a").unwrap();
187 let first = daemon
188 .enqueue_schedule_occurrence(
189 "once",
190 "same-logical-occurrence",
191 Utc::now(),
192 serde_json::json!({"task":"work"}),
193 CanonicalToolSideEffectClass::ReadOnly,
194 )
195 .unwrap();
196 let second = daemon
197 .enqueue_schedule_occurrence(
198 "once",
199 "same-logical-occurrence",
200 Utc::now(),
201 serde_json::json!({"task":"work"}),
202 CanonicalToolSideEffectClass::ReadOnly,
203 )
204 .unwrap();
205 assert!(first.enqueued);
206 assert!(!second.enqueued);
207 assert!(second.duplicate_suppression_receipt.is_some());
208 assert_eq!(daemon.snapshot().unwrap().jobs.len(), 1);
209 let _ = std::fs::remove_dir_all(&root);
210 }
211
212 #[test]
213 fn safe_mode_blocks_risky_wake_and_allows_drain() {
214 let root = temp_root("safe");
215 let ns = DaemonControllerV1::namespace(&root, "daemon-safe", "daemon-a");
216 let daemon = DaemonControllerV1::open(&root, ns, "daemon-a").unwrap();
217 daemon.set_safe_mode(true, "operator-safe-mode").unwrap();
218 let blocked = daemon
219 .enqueue_wake_signal(
220 "filesystem",
221 "dangerous-change",
222 serde_json::json!({"cmd":"cargo test"}),
223 CanonicalToolSideEffectClass::Admin,
224 )
225 .unwrap();
226 assert!(!blocked.enqueued);
227 assert!(blocked.safe_mode_receipt.is_some());
228
229 daemon
230 .enqueue_wake_signal(
231 "filesystem",
232 "inspect-change",
233 serde_json::json!({"path":"README.md"}),
234 CanonicalToolSideEffectClass::ReadOnly,
235 )
236 .unwrap();
237 assert_eq!(daemon.snapshot().unwrap().jobs.len(), 1);
238 assert_eq!(daemon.drain("safe-mode-drain").unwrap().len(), 1);
239 assert_eq!(
240 daemon.snapshot().unwrap().jobs[0].state,
241 JobStateV1::Cancelled
242 );
243 let _ = std::fs::remove_dir_all(&root);
244 }
245}