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