1use std::io;
18use std::path::{Path, PathBuf};
19use std::sync::atomic::Ordering;
20use std::sync::Arc;
21use std::time::Duration;
22
23use chrono::{DateTime, Utc};
24use fs4::fs_std::FileExt;
25use serde::{Deserialize, Serialize};
26
27use super::DaemonState;
28use crate::session::{CompactionPolicyWire, PinSink, SessionCommand, SessionConfig, SessionHandle};
29
30pub const PINS_FILE: &str = "daemon-pins.json";
31pub const FILE_VERSION: u64 = 1;
32pub const RESTORE_ENV: &str = "SYNAPS_DAEMON_RESTORE_PINS";
34pub const MAX_ATTEMPTS: u32 = 2;
37pub const HELD_RECHECK: Duration = Duration::from_secs(2);
39pub const RESTORE_CREATE_TIMEOUT: Duration = Duration::from_secs(60);
41
42pub const STATE_WAITING: &str = "waiting";
43pub const STATE_FAILED: &str = "failed";
44
45#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
47pub struct PinEntry {
48 pub journal_id: String,
49 #[serde(default, skip_serializing_if = "Option::is_none")]
50 pub name: Option<String>,
51 #[serde(default, skip_serializing_if = "Option::is_none")]
52 pub cwd: Option<PathBuf>,
53 #[serde(default)]
54 pub pinned_at: Option<DateTime<Utc>>,
55 #[serde(default, skip_serializing_if = "Option::is_none")]
57 pub state: Option<String>,
58 #[serde(default, skip_serializing_if = "Option::is_none")]
59 pub reason: Option<String>,
60 #[serde(default, skip_serializing_if = "Option::is_none")]
63 pub cache_keepwarm: Option<String>,
64 #[serde(default, skip_serializing_if = "is_zero")]
66 pub attempts: u32,
67 #[serde(flatten)]
69 pub extra: serde_json::Map<String, serde_json::Value>,
70}
71
72fn is_zero(n: &u32) -> bool {
73 *n == 0
74}
75
76impl PinEntry {
77 pub fn state_text(&self) -> String {
80 match (self.state.as_deref(), self.reason.as_deref()) {
81 (Some(s), Some(r)) => format!("{s}: {r}"),
82 (Some(s), None) => s.to_string(),
83 (None, _) => "not restored yet".to_string(),
84 }
85 }
86
87 pub fn label(&self) -> &str {
89 self.name.as_deref().filter(|n| !n.is_empty()).unwrap_or(&self.journal_id)
90 }
91
92 fn set_state(&mut self, state: &str, reason: String) {
93 self.state = Some(state.to_string());
94 self.reason = Some(reason);
95 }
96
97 fn clear_state(&mut self) {
98 self.state = None;
99 self.reason = None;
100 self.attempts = 0;
101 }
102}
103
104#[derive(Debug, Clone)]
108pub struct PinBook {
109 path: PathBuf,
110}
111
112struct Raw {
113 version: u64,
114 entries: Vec<PinEntry>,
115 unknown: Vec<serde_json::Value>,
117}
118
119impl PinBook {
120 pub fn at(path: impl Into<PathBuf>) -> Self {
121 Self { path: path.into() }
122 }
123
124 pub fn for_active_profile() -> Self {
126 Self::at(crate::config::get_active_config_dir().join(PINS_FILE))
127 }
128
129 pub fn path(&self) -> &Path {
130 &self.path
131 }
132
133 pub fn load(&self) -> Vec<PinEntry> {
135 self.read().entries
136 }
137
138 fn read(&self) -> Raw {
139 let mut raw = Raw { version: FILE_VERSION, entries: Vec::new(), unknown: Vec::new() };
140 let Ok(text) = std::fs::read_to_string(&self.path) else { return raw };
141 let Ok(serde_json::Value::Object(top)) = serde_json::from_str::<serde_json::Value>(&text) else {
142 tracing::warn!(path = %self.path.display(), "pins: unreadable pin book; leaving it alone");
143 raw.version = u64::MAX; return raw;
145 };
146 raw.version = top.get("version").and_then(|v| v.as_u64()).unwrap_or(FILE_VERSION);
147 let list = top.get("pins").and_then(|v| v.as_array()).cloned().unwrap_or_default();
148 for v in list {
149 match serde_json::from_value::<PinEntry>(v.clone()) {
150 Ok(e) if !e.journal_id.is_empty() => raw.entries.push(e),
151 _ => raw.unknown.push(v),
152 }
153 }
154 raw
155 }
156
157 pub fn update<R>(&self, f: impl FnOnce(&mut Vec<PinEntry>) -> R) -> io::Result<R> {
161 if let Some(dir) = self.path.parent() {
162 std::fs::create_dir_all(dir)?;
163 }
164 let mut lock_name = self.path.file_name().unwrap_or_default().to_os_string();
165 lock_name.push(".lock");
166 let lock = std::fs::OpenOptions::new()
167 .create(true)
168 .truncate(false)
169 .write(true)
170 .open(self.path.with_file_name(lock_name))?;
171 FileExt::lock_exclusive(&lock)?;
172 let mut raw = self.read();
173 let before = raw.entries.clone();
174 let out = f(&mut raw.entries);
175 if raw.entries == before {
176 return Ok(out);
177 }
178 if raw.version > FILE_VERSION {
179 return Err(io::Error::other(format!(
180 "{} is from a newer synaps (or unreadable); not changing it",
181 self.path.display()
182 )));
183 }
184 let mut pins: Vec<serde_json::Value> =
185 raw.entries.iter().filter_map(|e| serde_json::to_value(e).ok()).collect();
186 pins.extend(raw.unknown);
187 let body = serde_json::to_vec_pretty(&serde_json::json!({ "version": FILE_VERSION, "pins": pins }))
188 .map_err(io::Error::other)?;
189 super::registry::write_private_atomic(&self.path, &body)?;
190 Ok(out)
191 }
192
193 fn update_logged(&self, what: &str, f: impl FnOnce(&mut Vec<PinEntry>)) {
194 if let Err(e) = self.update(f) {
195 tracing::warn!(path = %self.path.display(), error = %e, "pins: {what} not written");
196 }
197 }
198
199 pub fn remove(&self, id: &str) -> io::Result<bool> {
201 self.update(|v| {
202 let n = v.len();
203 v.retain(|e| e.journal_id != id);
204 v.len() != n
205 })
206 }
207
208 fn set(&self, journal_id: &str, f: impl FnOnce(&mut PinEntry)) {
209 self.update_logged("state", |v| {
210 if let Some(e) = v.iter_mut().find(|e| e.journal_id == journal_id) {
211 f(e);
212 }
213 });
214 }
215}
216
217impl PinSink for PinBook {
218 fn pinned(&self, id: &str, journal_id: &str, name: Option<&str>, cwd: Option<&Path>, cache_keepwarm: Option<&str>) {
219 self.update_logged("pin", |v| {
220 let at = v.iter().position(|e| e.journal_id == journal_id).or_else(|| v.iter().position(|e| e.journal_id == id));
221 let e = match at {
222 Some(i) => &mut v[i],
223 None => {
224 v.push(PinEntry { pinned_at: Some(Utc::now()), ..Default::default() });
225 v.last_mut().expect("just pushed")
226 }
227 };
228 e.journal_id = journal_id.to_string();
229 e.name = name.map(str::to_owned).or(e.name.take());
230 if let Some(cwd) = cwd {
231 e.cwd = Some(cwd.to_path_buf());
232 }
233 if let Some(kw) = cache_keepwarm {
234 e.cache_keepwarm = Some(kw.to_string());
235 }
236 e.clear_state();
237 });
238 }
239
240 fn unpinned(&self, id: &str, journal_id: &str) {
241 self.update_logged("unpin", |v| v.retain(|e| e.journal_id != journal_id && e.journal_id != id));
242 }
243
244 fn moved(&self, from: &str, to: &str) {
245 self.update_logged("move", |v| {
246 if let Some(e) = v.iter_mut().find(|e| e.journal_id == from) {
247 e.journal_id = to.to_string();
248 }
249 });
250 }
251
252 fn renamed(&self, journal_id: &str, name: Option<&str>) {
253 self.update_logged("rename", |v| {
254 if let Some(e) = v.iter_mut().find(|e| e.journal_id == journal_id) {
255 e.name = name.map(str::to_owned);
256 }
257 });
258 }
259}
260
261pub fn restore_enabled() -> bool {
263 !super::env_is_off(std::env::var(RESTORE_ENV).ok().as_deref())
264}
265
266pub fn live_for(state: &DaemonState, journal_id: &str) -> Option<SessionHandle> {
269 state
270 .live_sessions()
271 .into_iter()
272 .filter(|h| h.is_alive() && h.meta().locked_by.is_none())
273 .find(|h| h.id.as_str() == journal_id || h.journal_id() == journal_id)
274}
275
276pub fn offline(entries: Vec<PinEntry>, live: &[crate::session::SessionMeta]) -> Vec<PinEntry> {
279 entries
280 .into_iter()
281 .filter(|e| !live.iter().any(|m| m.id.as_str() == e.journal_id || m.journal_id == e.journal_id))
282 .collect()
283}
284
285pub fn resolve_entry(entries: &[PinEntry], query: &str) -> Option<PinEntry> {
289 let q = query.trim();
290 if q.is_empty() {
291 return None;
292 }
293 if let Some(e) = entries.iter().find(|e| e.journal_id == q) {
294 return Some(e.clone());
295 }
296 let unique = |v: Vec<&PinEntry>| if v.len() == 1 { Some(v[0].clone()) } else { None };
297 unique(entries.iter().filter(|e| e.name.as_deref() == Some(q)).collect()).or_else(|| {
298 (q.chars().count() >= super::close::MIN_PREFIX_LEN)
299 .then(|| unique(entries.iter().filter(|e| e.journal_id.starts_with(q)).collect()))
300 .flatten()
301 })
302}
303
304fn lock_holder(dir: &Path, journal_id: &str) -> Option<String> {
308 use agent_core::session_lock::{LockHolder, SessionLock, SessionLockError};
309 let me = LockHolder { pid: std::process::id(), kind: "daemon".into() };
310 match SessionLock::try_acquire(dir, journal_id, me) {
311 Ok(lock) => {
312 drop(lock);
313 None
314 }
315 Err(SessionLockError::Held { holder, .. }) => {
316 Some(holder.map_or_else(|| "another process".to_string(), |h| format!("pid {}, {}", h.pid, h.kind)))
317 }
318 Err(_) => None,
320 }
321}
322
323#[derive(Debug, PartialEq, Eq)]
325pub enum Restored {
326 Live,
327 Waiting(String),
328 Failed(String),
329}
330
331pub async fn restore_one(state: &Arc<DaemonState>, entry: &PinEntry) -> Restored {
336 let id = entry.journal_id.as_str();
337 if let Some(h) = live_for(state, id) {
338 if !h.pinned() {
339 let _ = h.send(SessionCommand::KeepWarm { on: true }).await;
340 }
341 return Restored::Live;
342 }
343 let fail = |why: String| {
344 state.pins.set(id, |e| {
345 e.set_state(STATE_FAILED, why.clone());
346 e.attempts = 0;
347 });
348 Restored::Failed(why)
349 };
350 if entry.attempts >= MAX_ATTEMPTS {
351 let why = format!(
352 "the daemon died {} times while restoring it; not retried (`synaps daemon pin {id}` tries again, --off clears it)",
353 entry.attempts
354 );
355 state.pins.set(id, |e| e.set_state(STATE_FAILED, why.clone()));
356 return Restored::Failed(why);
357 }
358 let dir = agent_core::session_lock::sessions_dir();
359 if !dir.join(format!("{id}.json")).is_file() {
360 return fail(format!("journal {id} is missing (it never ran a turn, or was deleted)"));
361 }
362 let mut holder = lock_holder(&dir, id);
363 if holder.is_some() {
364 tokio::time::sleep(HELD_RECHECK).await;
365 holder = lock_holder(&dir, id);
366 }
367 if let Some(who) = holder {
368 let why = format!("held by {who}");
369 state.pins.set(id, |e| e.set_state(STATE_WAITING, why.clone()));
370 return Restored::Waiting(why);
371 }
372 state.pins.set(id, |e| e.attempts += 1);
373 let cfg = SessionConfig {
374 continue_session: Some(Some(id.to_string())),
375 cwd: entry.cwd.clone().filter(|c| c.is_dir()),
376 compaction_policy: CompactionPolicyWire::LinkedSuccessor,
377 await_extensions: false,
378 keep_warm: true,
379 ..Default::default()
380 };
381 match tokio::time::timeout(RESTORE_CREATE_TIMEOUT, state.create(cfg)).await {
382 Ok(Ok(h)) => {
384 let kw = entry.cache_keepwarm.as_deref().filter(|m| crate::core::config::CacheKeepWarm::parse(m).is_some());
388 if let Some(mode) = kw {
389 let _ = h
390 .send(SessionCommand::EngineCommand { id: u64::MAX, name: "keepwarm".into(), arg: mode.to_string() })
391 .await;
392 }
393 tracing::info!(journal = id, session = %h.id, "daemon: pinned session restored");
394 Restored::Live
395 }
396 Ok(Err(e)) if super::reload::is_lock_held_error(&e) => {
397 let why = format!("held by {}", super::reload::parse_holder_from_error(&e));
398 state.pins.set(id, |e| {
399 e.set_state(STATE_WAITING, why.clone());
400 e.attempts = 0;
401 });
402 Restored::Waiting(why)
403 }
404 Ok(Err(e)) => fail(e),
405 Err(_) => fail(format!("create did not finish within {} s", RESTORE_CREATE_TIMEOUT.as_secs())),
406 }
407}
408
409pub async fn restore_all(state: Arc<DaemonState>) {
413 let entries = state.pins.load();
414 if entries.is_empty() {
415 return;
416 }
417 state.restoring.store(true, Ordering::SeqCst);
418 tracing::info!(pins = entries.len(), "daemon: restoring pinned sessions");
419 for entry in entries {
420 if state.shutdown.is_cancelled() {
421 break;
422 }
423 match restore_one(&state, &entry).await {
424 Restored::Live => {}
425 Restored::Waiting(why) | Restored::Failed(why) => {
426 tracing::warn!(journal = %entry.journal_id, reason = %why, "daemon: pinned session not restored");
427 }
428 }
429 }
430 state.restoring.store(false, Ordering::SeqCst);
431}
432
433pub async fn pin_offline(state: &Arc<DaemonState>, id: &str, on: bool) -> Option<Result<(), String>> {
437 let entry = state.pins.load().into_iter().find(|e| e.journal_id == id)?;
438 if !on {
439 return Some(state.pins.remove(id).map(|_| ()).map_err(|e| e.to_string()));
440 }
441 let entry = PinEntry { attempts: 0, ..entry };
442 state.pins.set(id, |e| e.attempts = 0);
443 Some(match restore_one(state, &entry).await {
444 Restored::Live => Ok(()),
445 Restored::Waiting(why) => Err(format!("not restored: {why}")),
446 Restored::Failed(why) => Err(format!("restore failed: {why}")),
447 })
448}
449
450#[cfg(test)]
451mod tests {
452 use super::*;
453
454 fn book() -> (tempfile::TempDir, PinBook) {
455 let dir = tempfile::tempdir().unwrap();
456 let book = PinBook::at(dir.path().join(PINS_FILE));
457 (dir, book)
458 }
459
460 #[test]
461 fn pin_unpin_and_move_write_through() {
462 let (_d, book) = book();
463 book.pinned("a", "a", Some("axon"), Some(Path::new("/tmp")), Some("6h"));
464 book.pinned("b", "b", None, None, None);
465 let v = book.load();
466 assert_eq!(v.iter().map(|e| e.journal_id.as_str()).collect::<Vec<_>>(), ["a", "b"]);
467 assert_eq!(v[0].name.as_deref(), Some("axon"));
468 assert!(v[0].pinned_at.is_some());
469 assert_eq!(v[0].cache_keepwarm.as_deref(), Some("6h"));
470 book.moved("a", "a2");
471 book.pinned("a", "a2", Some("axon"), None, None); assert_eq!(book.load().iter().map(|e| e.journal_id.as_str()).collect::<Vec<_>>(), ["a2", "b"]);
473 book.unpinned("a", "a2");
474 assert_eq!(book.load().len(), 1);
475 #[cfg(unix)]
476 {
477 use std::os::unix::fs::PermissionsExt;
478 let mode = std::fs::metadata(book.path()).unwrap().permissions().mode() & 0o777;
479 assert_eq!(mode, 0o600);
480 }
481 }
482
483 #[test]
484 fn an_empty_book_never_creates_the_file() {
485 let (_d, book) = book();
486 book.unpinned("x", "x");
487 assert!(!book.path().exists());
488 }
489
490 #[test]
491 fn one_bad_entry_never_loses_the_rest_and_is_written_back() {
492 let (_d, book) = book();
493 std::fs::write(
494 book.path(),
495 r#"{"version":1,"pins":[
496 {"journal_id":"good","future_field":{"x":1}},
497 {"journal_id":42},
498 "garbage",
499 {"journal_id":"also","attempts":"lots"}
500 ],"future_top":true}"#,
501 )
502 .unwrap();
503 let v = book.load();
504 assert_eq!(v.len(), 1, "{v:?}");
505 assert_eq!(v[0].extra.get("future_field"), Some(&serde_json::json!({"x":1})));
506 book.pinned("new", "new", None, None, None);
507 let text = std::fs::read_to_string(book.path()).unwrap();
508 assert!(text.contains("future_field") && text.contains("garbage") && text.contains("\"lots\""), "{text}");
509 assert_eq!(book.load().len(), 2);
510 }
511
512 #[test]
513 fn a_newer_or_unreadable_file_is_read_only() {
514 let (_d, book) = book();
515 std::fs::write(book.path(), r#"{"version":9,"pins":[{"journal_id":"a"}]}"#).unwrap();
516 assert_eq!(book.load().len(), 1, "still readable");
517 assert!(book.remove("a").is_err());
518 assert!(std::fs::read_to_string(book.path()).unwrap().contains("\"version\":9"));
519 std::fs::write(book.path(), "{not json").unwrap();
520 assert!(book.load().is_empty());
521 book.pinned("b", "b", None, None, None);
522 assert_eq!(std::fs::read_to_string(book.path()).unwrap(), "{not json", "never overwritten");
523 }
524
525 #[test]
526 fn resolve_entry_by_id_name_or_long_prefix() {
527 let e = |id: &str, name: Option<&str>| PinEntry { journal_id: id.into(), name: name.map(Into::into), ..Default::default() };
528 let v = [e("20261025-101010-aaaa", Some("axon")), e("20261025-111111-bbbb", None)];
529 assert_eq!(resolve_entry(&v, "axon").map(|e| e.journal_id), Some("20261025-101010-aaaa".into()));
530 assert_eq!(resolve_entry(&v, "20261025-11").map(|e| e.journal_id), Some("20261025-111111-bbbb".into()));
531 assert!(resolve_entry(&v, "2026").is_none(), "too short");
532 assert!(resolve_entry(&v, "20261025-1").is_none(), "ambiguous");
533 assert!(resolve_entry(&v, "nope").is_none());
534 }
535
536 #[test]
537 fn state_text_reads_as_listed() {
538 let mut e = PinEntry { journal_id: "a".into(), ..Default::default() };
539 assert_eq!(e.state_text(), "not restored yet");
540 e.set_state(STATE_WAITING, "held by pid 7, tui".into());
541 assert_eq!(e.state_text(), "waiting: held by pid 7, tui");
542 }
543}