1use 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
16const DEFAULT_APP_FOLDER_NAME: &str = ".start-command";
18const LINO_DB_FILE: &str = "executions.lino";
20const LINKS_DB_FILE: &str = "executions.links";
22const LOCK_FILE: &str = "executions.lock";
24pub const DECODED_COPY_FILE: &str = "executions.decoded.rust.json";
26const LOCK_TIMEOUT_MS: u64 = 30000;
28
29#[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#[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 #[serde(default, skip_serializing_if = "Option::is_none")]
70 pub continues: Option<String>,
71 #[serde(default, skip_serializing_if = "Option::is_none")]
76 pub end_time_source: Option<String>,
77 #[serde(default, skip_serializing_if = "Option::is_none")]
80 pub observed_at: Option<String>,
81 #[serde(default, skip_serializing_if = "Option::is_none")]
84 pub stale_detected_at: Option<String>,
85 #[serde(default, skip_serializing_if = "Option::is_none")]
87 pub container_started_at: Option<String>,
88 #[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 #[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 #[serde(default, skip_serializing_if = "Option::is_none")]
110 pub exit_reason: Option<String>,
111 #[serde(default, skip_serializing_if = "Option::is_none")]
114 pub memory_exhausted: Option<bool>,
115 #[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 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 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 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 pub fn to_json(&self) -> Value {
213 serde_json::to_value(self).unwrap_or(Value::Null)
214 }
215
216 pub fn from_json(value: &Value) -> Option<Self> {
218 serde_json::from_value(value.clone()).ok()
219 }
220}
221
222#[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
242pub fn clink_reference(text: &str) -> String {
245 format!("\"{}\"", text.replace('"', "\"\""))
246}
247
248pub 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
259pub 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#[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#[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 pub fn new() -> Self {
293 Self::with_options(ExecutionStoreOptions::default())
294 }
295
296 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 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 fn log(&self, message: &str) {
319 if self.verbose {
320 println!("[ExecutionStore] {}", message);
321 }
322 }
323
324 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 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 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 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 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 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 fn write_links_record(&self, record: &ExecutionRecord, is_new: bool) -> bool {
460 if !self.use_links {
461 return false;
462 }
463
464 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 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 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 let mut records = self.read_lino_records();
516
517 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 records[idx] = record.clone();
534 } else {
535 records.push(record.clone());
537 }
538
539 self.write_lino_records(&records)
541 .map_err(|e| e.to_string())?;
542
543 if self.use_links {
545 self.write_links_record(record, existing_index.is_none());
546 }
547
548 Ok(())
549 }
550
551 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 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 pub fn get(&self, identifier: &str) -> Option<ExecutionRecord> {
620 crate::execution_identity::find_record(&self.read_lino_records(), identifier).cloned()
621 }
622
623 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 pub fn get_all(&self) -> Vec<ExecutionRecord> {
647 self.read_lino_records()
648 }
649 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 pub fn get_executing(&self) -> Vec<ExecutionRecord> {
658 self.get_by_status(ExecutionStatus::Executing)
659 }
660 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 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 #[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 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 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(¤t) {
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 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); }
770
771 self.write_lino_records(&filtered)
772 .map_err(|e| e.to_string())?;
773
774 if self.use_links {
776 self.delete_links_record(uuid);
777 }
778
779 Ok(true)
780 }
781
782 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 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 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 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 match self.exec_clink(
823 "((($id: ExecutionRecord $uuid)) (($id: ExecutionRecord $uuid)))",
824 &["--changes"],
825 ) {
826 Ok(output) => {
827 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 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 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 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 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#[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#[derive(Debug, Default)]
924pub struct CleanupOptions {
925 pub max_age_ms: Option<u64>,
926 pub dry_run: bool,
927}
928
929#[derive(Debug)]
931pub struct CleanupResult {
932 pub cleaned: usize,
933 pub records: Vec<ExecutionRecord>,
934 pub errors: Vec<String>,
935}
936
937#[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;