Skip to main content

start_command/
execution_store.rs

1//! Execution Store - Dual storage (.lino text + .links binary) for command execution records
2
3use crate::lino_value_json::{json_to_lino_value, lino_value_to_json};
4use crate::local_hostname;
5use chrono::Utc;
6use lino_objects_codec::{decode, encode, LinoValue};
7use serde::{Deserialize, Serialize};
8use serde_json::{json, Value};
9use std::collections::HashMap;
10use std::env;
11use std::fs::{self, OpenOptions};
12use std::io::Write;
13use std::path::{Path, PathBuf};
14use std::process::{Command, Stdio};
15use std::thread;
16use std::time::Duration;
17use uuid::Uuid;
18
19/// Default application folder name
20const DEFAULT_APP_FOLDER_NAME: &str = ".start-command";
21/// Lino database file name
22const LINO_DB_FILE: &str = "executions.lino";
23/// Links database file name
24const LINKS_DB_FILE: &str = "executions.links";
25/// Lock file name
26const LOCK_FILE: &str = "executions.lock";
27/// Lock timeout in milliseconds
28const LOCK_TIMEOUT_MS: u64 = 30000;
29/// Consider lock stale after this many milliseconds
30const LOCK_STALE_MS: u64 = 60000;
31
32/// Execution status
33#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
34#[serde(rename_all = "lowercase")]
35pub enum ExecutionStatus {
36    Executing,
37    Executed,
38}
39
40impl ExecutionStatus {
41    pub fn as_str(&self) -> &'static str {
42        match self {
43            ExecutionStatus::Executing => "executing",
44            ExecutionStatus::Executed => "executed",
45        }
46    }
47}
48
49impl std::fmt::Display for ExecutionStatus {
50    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
51        write!(f, "{}", self.as_str())
52    }
53}
54
55/// Command Execution Record
56#[derive(Debug, Clone, Serialize, Deserialize)]
57#[serde(rename_all = "camelCase")]
58pub struct ExecutionRecord {
59    pub uuid: String,
60    pub pid: Option<u32>,
61    pub status: ExecutionStatus,
62    pub exit_code: Option<i32>,
63    pub command: String,
64    pub log_path: String,
65    pub start_time: String,
66    pub end_time: Option<String>,
67    #[serde(default, skip_serializing_if = "Option::is_none")]
68    pub attempt: Option<crate::execution_attempt::ExecutionAttempt>,
69    #[serde(default, skip_serializing_if = "Vec::is_empty")]
70    pub attempt_history: Vec<Value>,
71    /// Where `end_time` came from (issue #170.2): `docker-finished-at`,
72    /// `log-footer` or `observed-at`. Without it a real finish time and a
73    /// timestamp taken when someone happened to run `--status` are
74    /// indistinguishable.
75    #[serde(default, skip_serializing_if = "Option::is_none")]
76    pub end_time_source: Option<String>,
77    /// When the end of the execution was *observed*, for the cases where no
78    /// real finish time exists (issue #170.2).
79    #[serde(default, skip_serializing_if = "Option::is_none")]
80    pub observed_at: Option<String>,
81    /// When `cleanup_stale()` gave up on a record. Deliberately not an
82    /// `end_time`: cleanup time is not a finish time (issue #170.3).
83    #[serde(default, skip_serializing_if = "Option::is_none")]
84    pub stale_detected_at: Option<String>,
85    /// `docker inspect .State.StartedAt` of the container that ran the command.
86    #[serde(default, skip_serializing_if = "Option::is_none")]
87    pub container_started_at: Option<String>,
88    #[serde(default, skip_serializing_if = "Option::is_none")]
89    pub oom_killed: Option<bool>,
90    /// cgroup v2 memory counters the detached watcher sampled while the
91    /// container ran (issue #182). Unlike the sticky `oom_killed`, they are
92    /// counted for this run only.
93    #[serde(
94        default,
95        skip_serializing_if = "Option::is_none",
96        deserialize_with = "crate::cgroup_memory::deserialize_cgroup_memory"
97    )]
98    pub cgroup_memory: Option<crate::cgroup_memory::CgroupMemory>,
99    /// Hint explaining an otherwise opaque exit code (issue #162).
100    /// Derived from the log tail on read; never a stored verdict.
101    #[serde(default, skip_serializing_if = "Option::is_none")]
102    pub exit_reason: Option<String>,
103    /// Memory-exhaustion observation derived from the log tail (issue #165).
104    /// A runtime that aborts on its own heap limit never trips `oom_killed`.
105    #[serde(default, skip_serializing_if = "Option::is_none")]
106    pub memory_exhausted: Option<bool>,
107    /// The log line that carried the memory-exhaustion evidence (issue #165).
108    #[serde(default, skip_serializing_if = "Option::is_none")]
109    pub memory_exhausted_reason: Option<String>,
110    pub working_directory: String,
111    pub shell: String,
112    pub platform: String,
113    #[serde(default)]
114    pub options: HashMap<String, Value>,
115}
116
117impl ExecutionRecord {
118    /// Create a new execution record
119    pub fn new(command: &str) -> Self {
120        let now = Utc::now();
121        ExecutionRecord {
122            uuid: Uuid::new_v4().to_string(),
123            pid: None,
124            status: ExecutionStatus::Executing,
125            exit_code: None,
126            command: command.to_string(),
127            log_path: String::new(),
128            start_time: now.to_rfc3339(),
129            end_time: None,
130            attempt: None,
131            attempt_history: Vec::new(),
132            end_time_source: None,
133            observed_at: None,
134            stale_detected_at: None,
135            container_started_at: None,
136            oom_killed: None,
137            cgroup_memory: None,
138            exit_reason: None,
139            memory_exhausted: None,
140            memory_exhausted_reason: None,
141            working_directory: env::current_dir()
142                .map(|p| p.to_string_lossy().to_string())
143                .unwrap_or_default(),
144            shell: env::var("SHELL").unwrap_or_else(|_| "/bin/sh".to_string()),
145            platform: std::env::consts::OS.to_string(),
146            options: HashMap::new(),
147        }
148    }
149
150    /// Create a new execution record with options
151    pub fn with_options(options: ExecutionRecordOptions) -> Self {
152        let mut record = Self::new(&options.command);
153        if let Some(uuid) = options.uuid {
154            record.uuid = uuid;
155        }
156        if let Some(pid) = options.pid {
157            record.pid = Some(pid);
158        }
159        if let Some(status) = options.status {
160            record.status = status;
161        }
162        if let Some(exit_code) = options.exit_code {
163            record.exit_code = Some(exit_code);
164        }
165        if let Some(log_path) = options.log_path {
166            record.log_path = log_path;
167        }
168        if let Some(start_time) = options.start_time {
169            record.start_time = start_time;
170        }
171        if let Some(end_time) = options.end_time {
172            record.end_time = Some(end_time);
173        }
174        if let Some(oom_killed) = options.oom_killed {
175            record.oom_killed = Some(oom_killed);
176        }
177        if let Some(working_directory) = options.working_directory {
178            record.working_directory = working_directory;
179        }
180        if let Some(shell) = options.shell {
181            record.shell = shell;
182        }
183        if let Some(platform) = options.platform {
184            record.platform = platform;
185        }
186        if let Some(opts) = options.options {
187            record.options = opts;
188        }
189        record
190    }
191
192    /// Mark execution as completed
193    pub fn complete(&mut self, exit_code: i32) {
194        self.status = ExecutionStatus::Executed;
195        self.exit_code = Some(exit_code);
196        self.end_time = Some(Utc::now().to_rfc3339());
197    }
198
199    /// Convert to JSON Value
200    pub fn to_json(&self) -> Value {
201        serde_json::to_value(self).unwrap_or(Value::Null)
202    }
203
204    /// Create from JSON Value
205    pub fn from_json(value: &Value) -> Option<Self> {
206        serde_json::from_value(value.clone()).ok()
207    }
208}
209
210/// Options for creating an execution record
211#[derive(Debug, Default)]
212pub struct ExecutionRecordOptions {
213    pub uuid: Option<String>,
214    pub command: String,
215    pub pid: Option<u32>,
216    pub status: Option<ExecutionStatus>,
217    pub exit_code: Option<i32>,
218    pub log_path: Option<String>,
219    pub start_time: Option<String>,
220    pub end_time: Option<String>,
221    pub oom_killed: Option<bool>,
222    pub working_directory: Option<String>,
223    pub shell: Option<String>,
224    pub platform: Option<String>,
225    pub options: Option<HashMap<String, Value>>,
226}
227
228/// File-based lock manager
229pub struct LockManager {
230    lock_file_path: PathBuf,
231    lock_acquired: bool,
232}
233
234impl LockManager {
235    /// Create a new lock manager
236    pub fn new(lock_file_path: PathBuf) -> Self {
237        LockManager {
238            lock_file_path,
239            lock_acquired: false,
240        }
241    }
242
243    /// Acquire an exclusive lock
244    pub fn acquire(&mut self, timeout_ms: u64) -> bool {
245        let start = std::time::Instant::now();
246        let timeout = Duration::from_millis(timeout_ms);
247
248        while start.elapsed() < timeout {
249            // Check if existing lock is stale
250            if self.lock_file_path.exists() {
251                if let Some(lock_data) = self.read_lock_file() {
252                    if self.is_lock_stale(&lock_data) {
253                        let _ = fs::remove_file(&self.lock_file_path);
254                    }
255                }
256            }
257
258            // Try to create lock file exclusively
259            match OpenOptions::new()
260                .write(true)
261                .create_new(true)
262                .open(&self.lock_file_path)
263            {
264                Ok(mut file) => {
265                    let lock_data = json!({
266                        "pid": std::process::id(),
267                        "timestamp": std::time::SystemTime::now()
268                            .duration_since(std::time::UNIX_EPOCH)
269                            .map(|d| d.as_millis())
270                            .unwrap_or(0),
271                        "hostname": local_hostname::get()
272                            .map(|h| h.to_string_lossy().to_string())
273                            .unwrap_or_default()
274                    });
275                    let _ = file.write_all(lock_data.to_string().as_bytes());
276                    self.lock_acquired = true;
277                    return true;
278                }
279                Err(_) => {
280                    // Lock file exists, wait and retry
281                    thread::sleep(Duration::from_millis(100));
282                    continue;
283                }
284            }
285        }
286
287        false
288    }
289
290    /// Release the lock
291    pub fn release(&mut self) {
292        if self.lock_acquired {
293            let _ = fs::remove_file(&self.lock_file_path);
294            self.lock_acquired = false;
295        }
296    }
297
298    /// Read lock file data
299    fn read_lock_file(&self) -> Option<Value> {
300        let content = fs::read_to_string(&self.lock_file_path).ok()?;
301        serde_json::from_str(&content).ok()
302    }
303
304    /// Check if lock is stale
305    fn is_lock_stale(&self, lock_data: &Value) -> bool {
306        let timestamp = lock_data.get("timestamp").and_then(|t| t.as_u64());
307
308        // Check if lock is too old
309        if let Some(ts) = timestamp {
310            let now = std::time::SystemTime::now()
311                .duration_since(std::time::UNIX_EPOCH)
312                .map(|d| d.as_millis() as u64)
313                .unwrap_or(0);
314            if now - ts > LOCK_STALE_MS {
315                return true;
316            }
317        } else {
318            return true;
319        }
320
321        // Check if the process that holds the lock is still running (Unix only)
322        #[cfg(unix)]
323        {
324            let pid = lock_data.get("pid").and_then(|p| p.as_u64());
325            if let Some(p) = pid {
326                // Check if process exists using kill(pid, 0)
327                let result = unsafe { libc::kill(p as i32, 0) };
328                if result != 0 {
329                    return true; // Process doesn't exist
330                }
331            }
332        }
333
334        false
335    }
336}
337
338impl Drop for LockManager {
339    fn drop(&mut self) {
340        self.release();
341    }
342}
343
344/// Check if clink is installed
345pub fn is_clink_installed() -> bool {
346    Command::new("clink")
347        .arg("--version")
348        .stdout(Stdio::null())
349        .stderr(Stdio::null())
350        .status()
351        .map(|s| s.success())
352        .unwrap_or(false)
353}
354
355/// Get the default application folder path
356pub fn get_default_app_folder() -> PathBuf {
357    if let Ok(custom) = env::var("START_APP_FOLDER") {
358        return PathBuf::from(custom);
359    }
360
361    let home = env::var("HOME")
362        .or_else(|_| env::var("USERPROFILE"))
363        .unwrap_or_else(|_| ".".to_string());
364    PathBuf::from(home).join(DEFAULT_APP_FOLDER_NAME)
365}
366
367/// ExecutionStore - Main store class for managing execution records
368#[derive(Clone)]
369pub struct ExecutionStore {
370    app_folder: PathBuf,
371    lino_db_path: PathBuf,
372    links_db_path: PathBuf,
373    lock_file_path: PathBuf,
374    use_links: bool,
375    verbose: bool,
376}
377
378/// Options for creating an ExecutionStore
379#[derive(Debug, Default)]
380pub struct ExecutionStoreOptions {
381    pub app_folder: Option<PathBuf>,
382    pub use_links: Option<bool>,
383    pub verbose: bool,
384}
385
386impl ExecutionStore {
387    /// Create a new ExecutionStore with default options
388    pub fn new() -> Self {
389        Self::with_options(ExecutionStoreOptions::default())
390    }
391
392    /// Create a new ExecutionStore with options
393    pub fn with_options(options: ExecutionStoreOptions) -> Self {
394        let app_folder = options.app_folder.unwrap_or_else(get_default_app_folder);
395        let lino_db_path = app_folder.join(LINO_DB_FILE);
396        let links_db_path = app_folder.join(LINKS_DB_FILE);
397        let lock_file_path = app_folder.join(LOCK_FILE);
398        let use_links = options.use_links.unwrap_or_else(is_clink_installed);
399
400        // Ensure app folder exists
401        let _ = fs::create_dir_all(&app_folder);
402
403        ExecutionStore {
404            app_folder,
405            lino_db_path,
406            links_db_path,
407            lock_file_path,
408            use_links,
409            verbose: options.verbose,
410        }
411    }
412
413    /// Log verbose message
414    fn log(&self, message: &str) {
415        if self.verbose {
416            println!("[ExecutionStore] {}", message);
417        }
418    }
419
420    /// Read all execution records from lino file
421    pub fn read_lino_records(&self) -> Vec<ExecutionRecord> {
422        if !self.lino_db_path.exists() {
423            return Vec::new();
424        }
425
426        match fs::read_to_string(&self.lino_db_path) {
427            Ok(content) => {
428                if content.trim().is_empty() {
429                    return Vec::new();
430                }
431
432                match decode(&content) {
433                    Ok(data) => {
434                        if let LinoValue::Array(arr) = data {
435                            arr.iter()
436                                .map(lino_value_to_json)
437                                .filter_map(|value| ExecutionRecord::from_json(&value))
438                                .collect()
439                        } else {
440                            Vec::new()
441                        }
442                    }
443                    Err(e) => {
444                        self.log(&format!("Error decoding lino records: {}", e));
445                        Vec::new()
446                    }
447                }
448            }
449            Err(e) => {
450                self.log(&format!("Error reading lino records: {}", e));
451                Vec::new()
452            }
453        }
454    }
455
456    /// Write execution records to lino file
457    fn write_lino_records(&self, records: &[ExecutionRecord]) -> std::io::Result<()> {
458        let data: Vec<LinoValue> = records
459            .iter()
460            .map(|record| json_to_lino_value(&record.to_json()))
461            .collect();
462        let content = encode(&LinoValue::Array(data));
463        fs::write(&self.lino_db_path, content)?;
464        self.log(&format!("Wrote {} records to lino file", records.len()));
465        Ok(())
466    }
467
468    /// Build clink query for creating/updating a record
469    fn build_clink_create_query(&self, record: &ExecutionRecord) -> String {
470        let obj = record.to_json();
471        let mut links = Vec::new();
472
473        // Create main record link
474        links.push(format!(
475            "({}: ExecutionRecord {})",
476            record.uuid, record.uuid
477        ));
478
479        // Create property links
480        if let Value::Object(map) = obj {
481            for (key, value) in map {
482                let escaped_value = match value {
483                    Value::Object(_) | Value::Array(_) => {
484                        serde_json::to_string(&value).unwrap_or_default()
485                    }
486                    Value::String(s) => s,
487                    Value::Null => "null".to_string(),
488                    other => other.to_string(),
489                };
490                links.push(format!(
491                    "({}.{}: {} \"{}\")",
492                    record.uuid,
493                    key,
494                    key,
495                    escaped_value.replace('"', "\\\"")
496                ));
497            }
498        }
499
500        format!("() (({})))", links.join(") ("))
501    }
502
503    /// Execute clink command
504    fn exec_clink(&self, query: &str) -> Result<String, String> {
505        match Command::new("clink")
506            .arg(query)
507            .arg("--db")
508            .arg(&self.links_db_path)
509            .output()
510        {
511            Ok(output) => {
512                if output.status.success() {
513                    Ok(String::from_utf8_lossy(&output.stdout).to_string())
514                } else {
515                    let stderr = String::from_utf8_lossy(&output.stderr).to_string();
516                    self.log(&format!("Clink error: {}", stderr));
517                    Err(stderr)
518                }
519            }
520            Err(e) => {
521                self.log(&format!("Clink execution error: {}", e));
522                Err(e.to_string())
523            }
524        }
525    }
526
527    /// Write a record to the links database using clink
528    fn write_links_record(&self, record: &ExecutionRecord) -> bool {
529        if !self.use_links {
530            return false;
531        }
532
533        let query = self.build_clink_create_query(record);
534        match self.exec_clink(&query) {
535            Ok(_) => {
536                self.log(&format!("Wrote record {} to links database", record.uuid));
537                true
538            }
539            Err(_) => false,
540        }
541    }
542
543    /// Delete a record from links database
544    fn delete_links_record(&self, uuid: &str) -> bool {
545        if !self.use_links {
546            return false;
547        }
548
549        let query = format!("(($id: {} $any)) ()", uuid);
550        self.exec_clink(&query).is_ok()
551    }
552
553    /// Save an execution record (creates or updates)
554    pub fn save(&self, record: &ExecutionRecord) -> Result<(), String> {
555        let mut lock = LockManager::new(self.lock_file_path.clone());
556
557        if !lock.acquire(LOCK_TIMEOUT_MS) {
558            return Err("Failed to acquire lock for database write".to_string());
559        }
560
561        // Read existing records
562        let mut records = self.read_lino_records();
563
564        // Find existing record index
565        let existing_index = records.iter().position(|r| r.uuid == record.uuid);
566
567        if let Some(idx) = existing_index {
568            // Update existing record
569            records[idx] = record.clone();
570        } else {
571            // Add new record
572            records.push(record.clone());
573        }
574
575        // Write to lino file
576        self.write_lino_records(&records)
577            .map_err(|e| e.to_string())?;
578
579        // Also write to links database if available
580        if self.use_links {
581            self.write_links_record(record);
582        }
583
584        Ok(())
585    }
586
587    /// Merge attempt metadata under the lock without replacing a watcher's terminal state.
588    pub fn patch_attempt(
589        &self,
590        uuid: &str,
591        number: u64,
592        fields: Value,
593    ) -> Result<Option<ExecutionRecord>, String> {
594        let mut lock = LockManager::new(self.lock_file_path.clone());
595        if !lock.acquire(LOCK_TIMEOUT_MS) {
596            return Err("Failed to acquire lock for attempt metadata".into());
597        }
598        let mut records = self.read_lino_records();
599        let Some(current) = records
600            .iter_mut()
601            .find(|r| r.uuid == uuid && r.attempt.as_ref().is_some_and(|a| a.number == number))
602        else {
603            return Ok(None);
604        };
605        let mut value =
606            serde_json::to_value(current.attempt.as_ref().unwrap()).map_err(|e| e.to_string())?;
607        if let Some(fields) = fields.as_object() {
608            for (key, field) in fields {
609                value[key] = field.clone();
610            }
611        }
612        current.attempt = Some(serde_json::from_value(value).map_err(|e| e.to_string())?);
613        let updated = current.clone();
614        self.write_lino_records(&records)
615            .map_err(|e| e.to_string())?;
616        if self.use_links {
617            self.write_links_record(&updated);
618        }
619        Ok(Some(updated))
620    }
621
622    /// Get an execution record by UUID or session name (falls back to options.sessionName)
623    pub fn get(&self, identifier: &str) -> Option<ExecutionRecord> {
624        let records = self.read_lino_records();
625        records
626            .iter()
627            .find(|r| r.uuid == identifier)
628            .cloned()
629            .or_else(|| {
630                records
631                    .iter()
632                    .find(|r| {
633                        r.options.get("sessionName").and_then(|v| v.as_str()) == Some(identifier)
634                    })
635                    .cloned()
636            })
637            .or_else(|| {
638                // Finally, honor session names this record used before it was
639                // resumed, so one logical session stays addressable across
640                // restarts (issue #162).
641                records.into_iter().find(|r| {
642                    r.options
643                        .get("sessionNameHistory")
644                        .and_then(|v| v.as_array())
645                        .is_some_and(|history| {
646                            history.iter().any(|name| name.as_str() == Some(identifier))
647                        })
648                })
649            })
650    }
651
652    /// Get all execution records
653    pub fn get_all(&self) -> Vec<ExecutionRecord> {
654        self.read_lino_records()
655    }
656    /// Get records filtered by status
657    pub fn get_by_status(&self, status: ExecutionStatus) -> Vec<ExecutionRecord> {
658        self.read_lino_records()
659            .into_iter()
660            .filter(|r| r.status == status)
661            .collect()
662    }
663    /// Get currently executing commands
664    pub fn get_executing(&self) -> Vec<ExecutionRecord> {
665        self.get_by_status(ExecutionStatus::Executing)
666    }
667    /// Get recently executed commands (sorted by start_time descending)
668    pub fn get_recent(&self, limit: usize) -> Vec<ExecutionRecord> {
669        let mut records = self.read_lino_records();
670        records.sort_by(|a, b| b.start_time.cmp(&a.start_time));
671        records.truncate(limit);
672        records
673    }
674    /// Clean up stale "executing" records (processes no longer running or aged out)
675    pub fn cleanup_stale(&self, options: CleanupOptions) -> CleanupResult {
676        let max_age_ms = options.max_age_ms.unwrap_or(24 * 60 * 60 * 1000);
677        let dry_run = options.dry_run;
678        let mut result = CleanupResult {
679            cleaned: 0,
680            records: Vec::new(),
681            errors: Vec::new(),
682        };
683
684        let records = self.read_lino_records();
685        let mut stale_records: Vec<ExecutionRecord> = Vec::new();
686
687        for record in records
688            .iter()
689            .filter(|r| r.status == ExecutionStatus::Executing)
690        {
691            let mut is_stale = false;
692
693            // Check if process is still running (Unix only, same platform)
694            #[cfg(unix)]
695            if let Some(pid) = record.pid {
696                if record.platform == std::env::consts::OS
697                    && unsafe { libc::kill(pid as i32, 0) } != 0
698                {
699                    is_stale = true;
700                    self.log(&format!("Stale: {} (PID {} gone)", record.uuid, pid));
701                }
702            }
703
704            // Check age
705            if !is_stale {
706                if let Ok(st) = chrono::DateTime::parse_from_rfc3339(&record.start_time) {
707                    let age_ms =
708                        (chrono::Utc::now() - st.with_timezone(&chrono::Utc)).num_milliseconds();
709                    if age_ms > max_age_ms as i64 {
710                        is_stale = true;
711                        self.log(&format!(
712                            "Stale: {} ({}min old)",
713                            record.uuid,
714                            age_ms / 60000
715                        ));
716                    }
717                }
718            }
719
720            if is_stale {
721                stale_records.push(record.clone());
722            }
723        }
724
725        result.records = stale_records.clone();
726
727        if !dry_run && !stale_records.is_empty() {
728            let mut lock = LockManager::new(self.lock_file_path.clone());
729            if !lock.acquire(LOCK_TIMEOUT_MS) {
730                result.errors.push("Failed to acquire lock".to_string());
731                return result;
732            }
733
734            let mut current = self.read_lino_records();
735            for stale in &stale_records {
736                if let Some(i) = current.iter().position(|r| r.uuid == stale.uuid) {
737                    current[i].status = ExecutionStatus::Executed;
738                    current[i].exit_code = Some(-1);
739                    // Cleanup time is not a finish time: the execution ended at
740                    // an unknown moment before this one. Record when it was
741                    // detected and leave `end_time` empty (issue #170.3).
742                    current[i].end_time = None;
743                    current[i].stale_detected_at = Some(chrono::Utc::now().to_rfc3339());
744                    result.cleaned += 1;
745                }
746            }
747
748            if let Err(e) = self.write_lino_records(&current) {
749                result.errors.push(format!("Cleanup error: {}", e));
750            } else {
751                self.log(&format!("Cleaned {} stale records", result.cleaned));
752            }
753        } else if dry_run {
754            result.cleaned = stale_records.len();
755        }
756
757        result
758    }
759
760    /// Delete an execution record
761    pub fn delete(&self, uuid: &str) -> Result<bool, String> {
762        let mut lock = LockManager::new(self.lock_file_path.clone());
763
764        if !lock.acquire(LOCK_TIMEOUT_MS) {
765            return Err("Failed to acquire lock for database write".to_string());
766        }
767
768        let records = self.read_lino_records();
769        let filtered: Vec<_> = records.iter().filter(|r| r.uuid != uuid).cloned().collect();
770
771        if filtered.len() == records.len() {
772            return Ok(false); // Record not found
773        }
774
775        self.write_lino_records(&filtered)
776            .map_err(|e| e.to_string())?;
777
778        // Also delete from links database
779        if self.use_links {
780            self.delete_links_record(uuid);
781        }
782
783        Ok(true)
784    }
785
786    /// Delete all records
787    pub fn clear(&self) -> Result<(), String> {
788        let mut lock = LockManager::new(self.lock_file_path.clone());
789
790        if !lock.acquire(LOCK_TIMEOUT_MS) {
791            return Err("Failed to acquire lock for database write".to_string());
792        }
793
794        self.write_lino_records(&[]).map_err(|e| e.to_string())?;
795
796        // Clear links database by removing the file
797        if self.use_links && self.links_db_path.exists() {
798            let _ = fs::remove_file(&self.links_db_path);
799        }
800
801        Ok(())
802    }
803
804    /// Verify that both databases have consistent data
805    pub fn verify_consistency(&self) -> ConsistencyResult {
806        let mut result = ConsistencyResult {
807            consistent: true,
808            lino_count: 0,
809            links_count: 0,
810            errors: Vec::new(),
811        };
812
813        // Read lino records
814        let lino_records = self.read_lino_records();
815        result.lino_count = lino_records.len();
816
817        if !self.use_links {
818            result
819                .errors
820                .push("clink not installed - links database not available".to_string());
821            return result;
822        }
823
824        // Query links database for all ExecutionRecord links
825        match self.exec_clink("((($id: ExecutionRecord $uuid)) (($id: ExecutionRecord $uuid)))") {
826            Ok(output) => {
827                // Count unique UUIDs in the output
828                let re = regex::Regex::new(r"ExecutionRecord\s+([a-f0-9-]{36})").unwrap();
829                let uuids: std::collections::HashSet<_> = re
830                    .captures_iter(&output)
831                    .filter_map(|c| c.get(1).map(|m| m.as_str().to_lowercase()))
832                    .collect();
833                result.links_count = uuids.len();
834
835                // Check if counts match
836                if result.lino_count != result.links_count {
837                    result.consistent = false;
838                    result.errors.push(format!(
839                        "Record count mismatch: lino={}, links={}",
840                        result.lino_count, result.links_count
841                    ));
842                }
843
844                // Verify each lino record exists in links
845                for record in &lino_records {
846                    if !uuids.contains(&record.uuid.to_lowercase()) {
847                        result.consistent = false;
848                        result.errors.push(format!(
849                            "Record {} missing from links database",
850                            record.uuid
851                        ));
852                    }
853                }
854            }
855            Err(e) => {
856                result
857                    .errors
858                    .push(format!("Failed to query links database: {}", e));
859                result.consistent = false;
860            }
861        }
862
863        result
864    }
865
866    /// Get database statistics
867    pub fn get_stats(&self) -> ExecutionStats {
868        let records = self.read_lino_records();
869        let executing = records
870            .iter()
871            .filter(|r| r.status == ExecutionStatus::Executing)
872            .count();
873        let executed = records
874            .iter()
875            .filter(|r| r.status == ExecutionStatus::Executed)
876            .count();
877        let successful = records
878            .iter()
879            .filter(|r| r.status == ExecutionStatus::Executed && r.exit_code == Some(0))
880            .count();
881        let failed = records
882            .iter()
883            .filter(|r| {
884                r.status == ExecutionStatus::Executed
885                    && r.exit_code.map(|c| c != 0).unwrap_or(false)
886            })
887            .count();
888
889        ExecutionStats {
890            total: records.len(),
891            executing,
892            executed,
893            successful,
894            failed,
895            clink_available: self.use_links,
896            lino_db_path: self.lino_db_path.to_string_lossy().to_string(),
897            links_db_path: self.links_db_path.to_string_lossy().to_string(),
898        }
899    }
900
901    /// Get the app folder path
902    pub fn app_folder(&self) -> &Path {
903        &self.app_folder
904    }
905}
906
907impl Default for ExecutionStore {
908    fn default() -> Self {
909        Self::new()
910    }
911}
912
913/// Result of consistency verification
914#[derive(Debug)]
915pub struct ConsistencyResult {
916    pub consistent: bool,
917    pub lino_count: usize,
918    pub links_count: usize,
919    pub errors: Vec<String>,
920}
921
922/// Options for cleanup_stale: max_age_ms (default 24h), dry_run
923#[derive(Debug, Default)]
924pub struct CleanupOptions {
925    pub max_age_ms: Option<u64>,
926    pub dry_run: bool,
927}
928
929/// Result of cleanup_stale: cleaned count, records, errors
930#[derive(Debug)]
931pub struct CleanupResult {
932    pub cleaned: usize,
933    pub records: Vec<ExecutionRecord>,
934    pub errors: Vec<String>,
935}
936
937/// Execution statistics
938#[derive(Debug)]
939pub struct ExecutionStats {
940    pub total: usize,
941    pub executing: usize,
942    pub executed: usize,
943    pub successful: usize,
944    pub failed: usize,
945    pub clink_available: bool,
946    pub lino_db_path: String,
947    pub links_db_path: String,
948}
949
950#[cfg(test)]
951#[path = "execution_store_cases.rs"]
952mod tests;