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
253pub fn restore_enabled() -> bool {
255 !super::env_is_off(std::env::var(RESTORE_ENV).ok().as_deref())
256}
257
258pub fn live_for(state: &DaemonState, journal_id: &str) -> Option<SessionHandle> {
261 state
262 .live_sessions()
263 .into_iter()
264 .filter(|h| h.is_alive() && h.meta().locked_by.is_none())
265 .find(|h| h.id.as_str() == journal_id || h.journal_id() == journal_id)
266}
267
268pub fn offline(entries: Vec<PinEntry>, live: &[crate::session::SessionMeta]) -> Vec<PinEntry> {
271 entries
272 .into_iter()
273 .filter(|e| !live.iter().any(|m| m.id.as_str() == e.journal_id || m.journal_id == e.journal_id))
274 .collect()
275}
276
277pub fn resolve_entry(entries: &[PinEntry], query: &str) -> Option<PinEntry> {
281 let q = query.trim();
282 if q.is_empty() {
283 return None;
284 }
285 if let Some(e) = entries.iter().find(|e| e.journal_id == q) {
286 return Some(e.clone());
287 }
288 let unique = |v: Vec<&PinEntry>| if v.len() == 1 { Some(v[0].clone()) } else { None };
289 unique(entries.iter().filter(|e| e.name.as_deref() == Some(q)).collect()).or_else(|| {
290 (q.chars().count() >= super::close::MIN_PREFIX_LEN)
291 .then(|| unique(entries.iter().filter(|e| e.journal_id.starts_with(q)).collect()))
292 .flatten()
293 })
294}
295
296fn lock_holder(dir: &Path, journal_id: &str) -> Option<String> {
300 use agent_core::session_lock::{LockHolder, SessionLock, SessionLockError};
301 let me = LockHolder { pid: std::process::id(), kind: "daemon".into() };
302 match SessionLock::try_acquire(dir, journal_id, me) {
303 Ok(lock) => {
304 drop(lock);
305 None
306 }
307 Err(SessionLockError::Held { holder, .. }) => {
308 Some(holder.map_or_else(|| "another process".to_string(), |h| format!("pid {}, {}", h.pid, h.kind)))
309 }
310 Err(_) => None,
312 }
313}
314
315#[derive(Debug, PartialEq, Eq)]
317pub enum Restored {
318 Live,
319 Waiting(String),
320 Failed(String),
321}
322
323pub async fn restore_one(state: &Arc<DaemonState>, entry: &PinEntry) -> Restored {
328 let id = entry.journal_id.as_str();
329 if let Some(h) = live_for(state, id) {
330 if !h.pinned() {
331 let _ = h.send(SessionCommand::KeepWarm { on: true }).await;
332 }
333 return Restored::Live;
334 }
335 let fail = |why: String| {
336 state.pins.set(id, |e| {
337 e.set_state(STATE_FAILED, why.clone());
338 e.attempts = 0;
339 });
340 Restored::Failed(why)
341 };
342 if entry.attempts >= MAX_ATTEMPTS {
343 let why = format!(
344 "the daemon died {} times while restoring it; not retried (`synaps daemon pin {id}` tries again, --off clears it)",
345 entry.attempts
346 );
347 state.pins.set(id, |e| e.set_state(STATE_FAILED, why.clone()));
348 return Restored::Failed(why);
349 }
350 let dir = agent_core::session_lock::sessions_dir();
351 if !dir.join(format!("{id}.json")).is_file() {
352 return fail(format!("journal {id} is missing (it never ran a turn, or was deleted)"));
353 }
354 let mut holder = lock_holder(&dir, id);
355 if holder.is_some() {
356 tokio::time::sleep(HELD_RECHECK).await;
357 holder = lock_holder(&dir, id);
358 }
359 if let Some(who) = holder {
360 let why = format!("held by {who}");
361 state.pins.set(id, |e| e.set_state(STATE_WAITING, why.clone()));
362 return Restored::Waiting(why);
363 }
364 state.pins.set(id, |e| e.attempts += 1);
365 let cfg = SessionConfig {
366 continue_session: Some(Some(id.to_string())),
367 cwd: entry.cwd.clone().filter(|c| c.is_dir()),
368 compaction_policy: CompactionPolicyWire::LinkedSuccessor,
369 await_extensions: false,
370 keep_warm: true,
371 ..Default::default()
372 };
373 match tokio::time::timeout(RESTORE_CREATE_TIMEOUT, state.create(cfg)).await {
374 Ok(Ok(h)) => {
376 let kw = entry.cache_keepwarm.as_deref().filter(|m| crate::core::config::CacheKeepWarm::parse(m).is_some());
380 if let Some(mode) = kw {
381 let _ = h
382 .send(SessionCommand::EngineCommand { id: u64::MAX, name: "keepwarm".into(), arg: mode.to_string() })
383 .await;
384 }
385 tracing::info!(journal = id, session = %h.id, "daemon: pinned session restored");
386 Restored::Live
387 }
388 Ok(Err(e)) if super::reload::is_lock_held_error(&e) => {
389 let why = format!("held by {}", super::reload::parse_holder_from_error(&e));
390 state.pins.set(id, |e| {
391 e.set_state(STATE_WAITING, why.clone());
392 e.attempts = 0;
393 });
394 Restored::Waiting(why)
395 }
396 Ok(Err(e)) => fail(e),
397 Err(_) => fail(format!("create did not finish within {} s", RESTORE_CREATE_TIMEOUT.as_secs())),
398 }
399}
400
401pub async fn restore_all(state: Arc<DaemonState>) {
405 let entries = state.pins.load();
406 if entries.is_empty() {
407 return;
408 }
409 state.restoring.store(true, Ordering::SeqCst);
410 tracing::info!(pins = entries.len(), "daemon: restoring pinned sessions");
411 for entry in entries {
412 if state.shutdown.is_cancelled() {
413 break;
414 }
415 match restore_one(&state, &entry).await {
416 Restored::Live => {}
417 Restored::Waiting(why) | Restored::Failed(why) => {
418 tracing::warn!(journal = %entry.journal_id, reason = %why, "daemon: pinned session not restored");
419 }
420 }
421 }
422 state.restoring.store(false, Ordering::SeqCst);
423}
424
425pub async fn pin_offline(state: &Arc<DaemonState>, id: &str, on: bool) -> Option<Result<(), String>> {
429 let entry = state.pins.load().into_iter().find(|e| e.journal_id == id)?;
430 if !on {
431 return Some(state.pins.remove(id).map(|_| ()).map_err(|e| e.to_string()));
432 }
433 let entry = PinEntry { attempts: 0, ..entry };
434 state.pins.set(id, |e| e.attempts = 0);
435 Some(match restore_one(state, &entry).await {
436 Restored::Live => Ok(()),
437 Restored::Waiting(why) => Err(format!("not restored: {why}")),
438 Restored::Failed(why) => Err(format!("restore failed: {why}")),
439 })
440}
441
442#[cfg(test)]
443mod tests {
444 use super::*;
445
446 fn book() -> (tempfile::TempDir, PinBook) {
447 let dir = tempfile::tempdir().unwrap();
448 let book = PinBook::at(dir.path().join(PINS_FILE));
449 (dir, book)
450 }
451
452 #[test]
453 fn pin_unpin_and_move_write_through() {
454 let (_d, book) = book();
455 book.pinned("a", "a", Some("axon"), Some(Path::new("/tmp")), Some("6h"));
456 book.pinned("b", "b", None, None, None);
457 let v = book.load();
458 assert_eq!(v.iter().map(|e| e.journal_id.as_str()).collect::<Vec<_>>(), ["a", "b"]);
459 assert_eq!(v[0].name.as_deref(), Some("axon"));
460 assert!(v[0].pinned_at.is_some());
461 assert_eq!(v[0].cache_keepwarm.as_deref(), Some("6h"));
462 book.moved("a", "a2");
463 book.pinned("a", "a2", Some("axon"), None, None); assert_eq!(book.load().iter().map(|e| e.journal_id.as_str()).collect::<Vec<_>>(), ["a2", "b"]);
465 book.unpinned("a", "a2");
466 assert_eq!(book.load().len(), 1);
467 #[cfg(unix)]
468 {
469 use std::os::unix::fs::PermissionsExt;
470 let mode = std::fs::metadata(book.path()).unwrap().permissions().mode() & 0o777;
471 assert_eq!(mode, 0o600);
472 }
473 }
474
475 #[test]
476 fn an_empty_book_never_creates_the_file() {
477 let (_d, book) = book();
478 book.unpinned("x", "x");
479 assert!(!book.path().exists());
480 }
481
482 #[test]
483 fn one_bad_entry_never_loses_the_rest_and_is_written_back() {
484 let (_d, book) = book();
485 std::fs::write(
486 book.path(),
487 r#"{"version":1,"pins":[
488 {"journal_id":"good","future_field":{"x":1}},
489 {"journal_id":42},
490 "garbage",
491 {"journal_id":"also","attempts":"lots"}
492 ],"future_top":true}"#,
493 )
494 .unwrap();
495 let v = book.load();
496 assert_eq!(v.len(), 1, "{v:?}");
497 assert_eq!(v[0].extra.get("future_field"), Some(&serde_json::json!({"x":1})));
498 book.pinned("new", "new", None, None, None);
499 let text = std::fs::read_to_string(book.path()).unwrap();
500 assert!(text.contains("future_field") && text.contains("garbage") && text.contains("\"lots\""), "{text}");
501 assert_eq!(book.load().len(), 2);
502 }
503
504 #[test]
505 fn a_newer_or_unreadable_file_is_read_only() {
506 let (_d, book) = book();
507 std::fs::write(book.path(), r#"{"version":9,"pins":[{"journal_id":"a"}]}"#).unwrap();
508 assert_eq!(book.load().len(), 1, "still readable");
509 assert!(book.remove("a").is_err());
510 assert!(std::fs::read_to_string(book.path()).unwrap().contains("\"version\":9"));
511 std::fs::write(book.path(), "{not json").unwrap();
512 assert!(book.load().is_empty());
513 book.pinned("b", "b", None, None, None);
514 assert_eq!(std::fs::read_to_string(book.path()).unwrap(), "{not json", "never overwritten");
515 }
516
517 #[test]
518 fn resolve_entry_by_id_name_or_long_prefix() {
519 let e = |id: &str, name: Option<&str>| PinEntry { journal_id: id.into(), name: name.map(Into::into), ..Default::default() };
520 let v = [e("20261025-101010-aaaa", Some("axon")), e("20261025-111111-bbbb", None)];
521 assert_eq!(resolve_entry(&v, "axon").map(|e| e.journal_id), Some("20261025-101010-aaaa".into()));
522 assert_eq!(resolve_entry(&v, "20261025-11").map(|e| e.journal_id), Some("20261025-111111-bbbb".into()));
523 assert!(resolve_entry(&v, "2026").is_none(), "too short");
524 assert!(resolve_entry(&v, "20261025-1").is_none(), "ambiguous");
525 assert!(resolve_entry(&v, "nope").is_none());
526 }
527
528 #[test]
529 fn state_text_reads_as_listed() {
530 let mut e = PinEntry { journal_id: "a".into(), ..Default::default() };
531 assert_eq!(e.state_text(), "not restored yet");
532 e.set_state(STATE_WAITING, "held by pid 7, tui".into());
533 assert_eq!(e.state_text(), "waiting: held by pid 7, tui");
534 }
535}