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