1use crate::lino_value_json::{json_to_lino_value, lino_value_to_json};
4use crate::local_hostname;
5use chrono::Utc;
6use lino_objects_codec::{decode, encode, LinoValue};
7use serde::{Deserialize, Serialize};
8use serde_json::{json, Value};
9use std::collections::HashMap;
10use std::env;
11use std::fs::{self, OpenOptions};
12use std::io::Write;
13use std::path::{Path, PathBuf};
14use std::process::{Command, Stdio};
15use std::thread;
16use std::time::Duration;
17use uuid::Uuid;
18
19const DEFAULT_APP_FOLDER_NAME: &str = ".start-command";
21const LINO_DB_FILE: &str = "executions.lino";
23const LINKS_DB_FILE: &str = "executions.links";
25const LOCK_FILE: &str = "executions.lock";
27const LOCK_TIMEOUT_MS: u64 = 30000;
29const LOCK_STALE_MS: u64 = 60000;
31
32#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
34#[serde(rename_all = "lowercase")]
35pub enum ExecutionStatus {
36 Executing,
37 Executed,
38}
39
40impl ExecutionStatus {
41 pub fn as_str(&self) -> &'static str {
42 match self {
43 ExecutionStatus::Executing => "executing",
44 ExecutionStatus::Executed => "executed",
45 }
46 }
47}
48
49impl std::fmt::Display for ExecutionStatus {
50 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
51 write!(f, "{}", self.as_str())
52 }
53}
54
55#[derive(Debug, Clone, Serialize, Deserialize)]
57#[serde(rename_all = "camelCase")]
58pub struct ExecutionRecord {
59 pub uuid: String,
60 pub pid: Option<u32>,
61 pub status: ExecutionStatus,
62 pub exit_code: Option<i32>,
63 pub command: String,
64 pub log_path: String,
65 pub start_time: String,
66 pub end_time: Option<String>,
67 #[serde(default, skip_serializing_if = "Option::is_none")]
68 pub attempt: Option<crate::execution_attempt::ExecutionAttempt>,
69 #[serde(default, skip_serializing_if = "Vec::is_empty")]
70 pub attempt_history: Vec<Value>,
71 #[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")]
89 pub oom_killed: Option<bool>,
90 #[serde(
94 default,
95 skip_serializing_if = "Option::is_none",
96 deserialize_with = "crate::cgroup_memory::deserialize_cgroup_memory"
97 )]
98 pub cgroup_memory: Option<crate::cgroup_memory::CgroupMemory>,
99 #[serde(default, skip_serializing_if = "Option::is_none")]
102 pub exit_reason: Option<String>,
103 #[serde(default, skip_serializing_if = "Option::is_none")]
106 pub memory_exhausted: Option<bool>,
107 #[serde(default, skip_serializing_if = "Option::is_none")]
109 pub memory_exhausted_reason: Option<String>,
110 pub working_directory: String,
111 pub shell: String,
112 pub platform: String,
113 #[serde(default)]
114 pub options: HashMap<String, Value>,
115}
116
117impl ExecutionRecord {
118 pub fn new(command: &str) -> Self {
120 let now = Utc::now();
121 ExecutionRecord {
122 uuid: Uuid::new_v4().to_string(),
123 pid: None,
124 status: ExecutionStatus::Executing,
125 exit_code: None,
126 command: command.to_string(),
127 log_path: String::new(),
128 start_time: now.to_rfc3339(),
129 end_time: None,
130 attempt: None,
131 attempt_history: Vec::new(),
132 end_time_source: None,
133 observed_at: None,
134 stale_detected_at: None,
135 container_started_at: None,
136 oom_killed: None,
137 cgroup_memory: None,
138 exit_reason: None,
139 memory_exhausted: None,
140 memory_exhausted_reason: None,
141 working_directory: env::current_dir()
142 .map(|p| p.to_string_lossy().to_string())
143 .unwrap_or_default(),
144 shell: env::var("SHELL").unwrap_or_else(|_| "/bin/sh".to_string()),
145 platform: std::env::consts::OS.to_string(),
146 options: HashMap::new(),
147 }
148 }
149
150 pub fn with_options(options: ExecutionRecordOptions) -> Self {
152 let mut record = Self::new(&options.command);
153 if let Some(uuid) = options.uuid {
154 record.uuid = uuid;
155 }
156 if let Some(pid) = options.pid {
157 record.pid = Some(pid);
158 }
159 if let Some(status) = options.status {
160 record.status = status;
161 }
162 if let Some(exit_code) = options.exit_code {
163 record.exit_code = Some(exit_code);
164 }
165 if let Some(log_path) = options.log_path {
166 record.log_path = log_path;
167 }
168 if let Some(start_time) = options.start_time {
169 record.start_time = start_time;
170 }
171 if let Some(end_time) = options.end_time {
172 record.end_time = Some(end_time);
173 }
174 if let Some(oom_killed) = options.oom_killed {
175 record.oom_killed = Some(oom_killed);
176 }
177 if let Some(working_directory) = options.working_directory {
178 record.working_directory = working_directory;
179 }
180 if let Some(shell) = options.shell {
181 record.shell = shell;
182 }
183 if let Some(platform) = options.platform {
184 record.platform = platform;
185 }
186 if let Some(opts) = options.options {
187 record.options = opts;
188 }
189 record
190 }
191
192 pub fn complete(&mut self, exit_code: i32) {
194 self.status = ExecutionStatus::Executed;
195 self.exit_code = Some(exit_code);
196 self.end_time = Some(Utc::now().to_rfc3339());
197 }
198
199 pub fn to_json(&self) -> Value {
201 serde_json::to_value(self).unwrap_or(Value::Null)
202 }
203
204 pub fn from_json(value: &Value) -> Option<Self> {
206 serde_json::from_value(value.clone()).ok()
207 }
208}
209
210#[derive(Debug, Default)]
212pub struct ExecutionRecordOptions {
213 pub uuid: Option<String>,
214 pub command: String,
215 pub pid: Option<u32>,
216 pub status: Option<ExecutionStatus>,
217 pub exit_code: Option<i32>,
218 pub log_path: Option<String>,
219 pub start_time: Option<String>,
220 pub end_time: Option<String>,
221 pub oom_killed: Option<bool>,
222 pub working_directory: Option<String>,
223 pub shell: Option<String>,
224 pub platform: Option<String>,
225 pub options: Option<HashMap<String, Value>>,
226}
227
228pub struct LockManager {
230 lock_file_path: PathBuf,
231 lock_acquired: bool,
232}
233
234impl LockManager {
235 pub fn new(lock_file_path: PathBuf) -> Self {
237 LockManager {
238 lock_file_path,
239 lock_acquired: false,
240 }
241 }
242
243 pub fn acquire(&mut self, timeout_ms: u64) -> bool {
245 let start = std::time::Instant::now();
246 let timeout = Duration::from_millis(timeout_ms);
247
248 while start.elapsed() < timeout {
249 if self.lock_file_path.exists() {
251 if let Some(lock_data) = self.read_lock_file() {
252 if self.is_lock_stale(&lock_data) {
253 let _ = fs::remove_file(&self.lock_file_path);
254 }
255 }
256 }
257
258 match OpenOptions::new()
260 .write(true)
261 .create_new(true)
262 .open(&self.lock_file_path)
263 {
264 Ok(mut file) => {
265 let lock_data = json!({
266 "pid": std::process::id(),
267 "timestamp": std::time::SystemTime::now()
268 .duration_since(std::time::UNIX_EPOCH)
269 .map(|d| d.as_millis())
270 .unwrap_or(0),
271 "hostname": local_hostname::get()
272 .map(|h| h.to_string_lossy().to_string())
273 .unwrap_or_default()
274 });
275 let _ = file.write_all(lock_data.to_string().as_bytes());
276 self.lock_acquired = true;
277 return true;
278 }
279 Err(_) => {
280 thread::sleep(Duration::from_millis(100));
282 continue;
283 }
284 }
285 }
286
287 false
288 }
289
290 pub fn release(&mut self) {
292 if self.lock_acquired {
293 let _ = fs::remove_file(&self.lock_file_path);
294 self.lock_acquired = false;
295 }
296 }
297
298 fn read_lock_file(&self) -> Option<Value> {
300 let content = fs::read_to_string(&self.lock_file_path).ok()?;
301 serde_json::from_str(&content).ok()
302 }
303
304 fn is_lock_stale(&self, lock_data: &Value) -> bool {
306 let timestamp = lock_data.get("timestamp").and_then(|t| t.as_u64());
307
308 if let Some(ts) = timestamp {
310 let now = std::time::SystemTime::now()
311 .duration_since(std::time::UNIX_EPOCH)
312 .map(|d| d.as_millis() as u64)
313 .unwrap_or(0);
314 if now - ts > LOCK_STALE_MS {
315 return true;
316 }
317 } else {
318 return true;
319 }
320
321 #[cfg(unix)]
323 {
324 let pid = lock_data.get("pid").and_then(|p| p.as_u64());
325 if let Some(p) = pid {
326 let result = unsafe { libc::kill(p as i32, 0) };
328 if result != 0 {
329 return true; }
331 }
332 }
333
334 false
335 }
336}
337
338impl Drop for LockManager {
339 fn drop(&mut self) {
340 self.release();
341 }
342}
343
344pub fn is_clink_installed() -> bool {
346 Command::new("clink")
347 .arg("--version")
348 .stdout(Stdio::null())
349 .stderr(Stdio::null())
350 .status()
351 .map(|s| s.success())
352 .unwrap_or(false)
353}
354
355pub fn get_default_app_folder() -> PathBuf {
357 if let Ok(custom) = env::var("START_APP_FOLDER") {
358 return PathBuf::from(custom);
359 }
360
361 let home = env::var("HOME")
362 .or_else(|_| env::var("USERPROFILE"))
363 .unwrap_or_else(|_| ".".to_string());
364 PathBuf::from(home).join(DEFAULT_APP_FOLDER_NAME)
365}
366
367#[derive(Clone)]
369pub struct ExecutionStore {
370 app_folder: PathBuf,
371 lino_db_path: PathBuf,
372 links_db_path: PathBuf,
373 lock_file_path: PathBuf,
374 use_links: bool,
375 verbose: bool,
376}
377
378#[derive(Debug, Default)]
380pub struct ExecutionStoreOptions {
381 pub app_folder: Option<PathBuf>,
382 pub use_links: Option<bool>,
383 pub verbose: bool,
384}
385
386impl ExecutionStore {
387 pub fn new() -> Self {
389 Self::with_options(ExecutionStoreOptions::default())
390 }
391
392 pub fn with_options(options: ExecutionStoreOptions) -> Self {
394 let app_folder = options.app_folder.unwrap_or_else(get_default_app_folder);
395 let lino_db_path = app_folder.join(LINO_DB_FILE);
396 let links_db_path = app_folder.join(LINKS_DB_FILE);
397 let lock_file_path = app_folder.join(LOCK_FILE);
398 let use_links = options.use_links.unwrap_or_else(is_clink_installed);
399
400 let _ = fs::create_dir_all(&app_folder);
402
403 ExecutionStore {
404 app_folder,
405 lino_db_path,
406 links_db_path,
407 lock_file_path,
408 use_links,
409 verbose: options.verbose,
410 }
411 }
412
413 fn log(&self, message: &str) {
415 if self.verbose {
416 println!("[ExecutionStore] {}", message);
417 }
418 }
419
420 pub fn read_lino_records(&self) -> Vec<ExecutionRecord> {
422 if !self.lino_db_path.exists() {
423 return Vec::new();
424 }
425
426 match fs::read_to_string(&self.lino_db_path) {
427 Ok(content) => {
428 if content.trim().is_empty() {
429 return Vec::new();
430 }
431
432 match decode(&content) {
433 Ok(data) => {
434 if let LinoValue::Array(arr) = data {
435 arr.iter()
436 .map(lino_value_to_json)
437 .filter_map(|value| ExecutionRecord::from_json(&value))
438 .collect()
439 } else {
440 Vec::new()
441 }
442 }
443 Err(e) => {
444 self.log(&format!("Error decoding lino records: {}", e));
445 Vec::new()
446 }
447 }
448 }
449 Err(e) => {
450 self.log(&format!("Error reading lino records: {}", e));
451 Vec::new()
452 }
453 }
454 }
455
456 fn write_lino_records(&self, records: &[ExecutionRecord]) -> std::io::Result<()> {
458 let data: Vec<LinoValue> = records
459 .iter()
460 .map(|record| json_to_lino_value(&record.to_json()))
461 .collect();
462 let content = encode(&LinoValue::Array(data));
463 fs::write(&self.lino_db_path, content)?;
464 self.log(&format!("Wrote {} records to lino file", records.len()));
465 Ok(())
466 }
467
468 fn build_clink_create_query(&self, record: &ExecutionRecord) -> String {
470 let obj = record.to_json();
471 let mut links = Vec::new();
472
473 links.push(format!(
475 "({}: ExecutionRecord {})",
476 record.uuid, record.uuid
477 ));
478
479 if let Value::Object(map) = obj {
481 for (key, value) in map {
482 let escaped_value = match value {
483 Value::Object(_) | Value::Array(_) => {
484 serde_json::to_string(&value).unwrap_or_default()
485 }
486 Value::String(s) => s,
487 Value::Null => "null".to_string(),
488 other => other.to_string(),
489 };
490 links.push(format!(
491 "({}.{}: {} \"{}\")",
492 record.uuid,
493 key,
494 key,
495 escaped_value.replace('"', "\\\"")
496 ));
497 }
498 }
499
500 format!("() (({})))", links.join(") ("))
501 }
502
503 fn exec_clink(&self, query: &str) -> Result<String, String> {
505 match Command::new("clink")
506 .arg(query)
507 .arg("--db")
508 .arg(&self.links_db_path)
509 .output()
510 {
511 Ok(output) => {
512 if output.status.success() {
513 Ok(String::from_utf8_lossy(&output.stdout).to_string())
514 } else {
515 let stderr = String::from_utf8_lossy(&output.stderr).to_string();
516 self.log(&format!("Clink error: {}", stderr));
517 Err(stderr)
518 }
519 }
520 Err(e) => {
521 self.log(&format!("Clink execution error: {}", e));
522 Err(e.to_string())
523 }
524 }
525 }
526
527 fn write_links_record(&self, record: &ExecutionRecord) -> bool {
529 if !self.use_links {
530 return false;
531 }
532
533 let query = self.build_clink_create_query(record);
534 match self.exec_clink(&query) {
535 Ok(_) => {
536 self.log(&format!("Wrote record {} to links database", record.uuid));
537 true
538 }
539 Err(_) => false,
540 }
541 }
542
543 fn delete_links_record(&self, uuid: &str) -> bool {
545 if !self.use_links {
546 return false;
547 }
548
549 let query = format!("(($id: {} $any)) ()", uuid);
550 self.exec_clink(&query).is_ok()
551 }
552
553 pub fn save(&self, record: &ExecutionRecord) -> Result<(), String> {
555 let mut lock = LockManager::new(self.lock_file_path.clone());
556
557 if !lock.acquire(LOCK_TIMEOUT_MS) {
558 return Err("Failed to acquire lock for database write".to_string());
559 }
560
561 let mut records = self.read_lino_records();
563
564 let existing_index = records.iter().position(|r| r.uuid == record.uuid);
566
567 if let Some(idx) = existing_index {
568 records[idx] = record.clone();
570 } else {
571 records.push(record.clone());
573 }
574
575 self.write_lino_records(&records)
577 .map_err(|e| e.to_string())?;
578
579 if self.use_links {
581 self.write_links_record(record);
582 }
583
584 Ok(())
585 }
586
587 pub fn patch_attempt(
589 &self,
590 uuid: &str,
591 number: u64,
592 fields: Value,
593 ) -> Result<Option<ExecutionRecord>, String> {
594 let mut lock = LockManager::new(self.lock_file_path.clone());
595 if !lock.acquire(LOCK_TIMEOUT_MS) {
596 return Err("Failed to acquire lock for attempt metadata".into());
597 }
598 let mut records = self.read_lino_records();
599 let Some(current) = records
600 .iter_mut()
601 .find(|r| r.uuid == uuid && r.attempt.as_ref().is_some_and(|a| a.number == number))
602 else {
603 return Ok(None);
604 };
605 let mut value =
606 serde_json::to_value(current.attempt.as_ref().unwrap()).map_err(|e| e.to_string())?;
607 if let Some(fields) = fields.as_object() {
608 for (key, field) in fields {
609 value[key] = field.clone();
610 }
611 }
612 current.attempt = Some(serde_json::from_value(value).map_err(|e| e.to_string())?);
613 let updated = current.clone();
614 self.write_lino_records(&records)
615 .map_err(|e| e.to_string())?;
616 if self.use_links {
617 self.write_links_record(&updated);
618 }
619 Ok(Some(updated))
620 }
621
622 pub fn get(&self, identifier: &str) -> Option<ExecutionRecord> {
624 let records = self.read_lino_records();
625 records
626 .iter()
627 .find(|r| r.uuid == identifier)
628 .cloned()
629 .or_else(|| {
630 records
631 .iter()
632 .find(|r| {
633 r.options.get("sessionName").and_then(|v| v.as_str()) == Some(identifier)
634 })
635 .cloned()
636 })
637 .or_else(|| {
638 records.into_iter().find(|r| {
642 r.options
643 .get("sessionNameHistory")
644 .and_then(|v| v.as_array())
645 .is_some_and(|history| {
646 history.iter().any(|name| name.as_str() == Some(identifier))
647 })
648 })
649 })
650 }
651
652 pub fn get_all(&self) -> Vec<ExecutionRecord> {
654 self.read_lino_records()
655 }
656 pub fn get_by_status(&self, status: ExecutionStatus) -> Vec<ExecutionRecord> {
658 self.read_lino_records()
659 .into_iter()
660 .filter(|r| r.status == status)
661 .collect()
662 }
663 pub fn get_executing(&self) -> Vec<ExecutionRecord> {
665 self.get_by_status(ExecutionStatus::Executing)
666 }
667 pub fn get_recent(&self, limit: usize) -> Vec<ExecutionRecord> {
669 let mut records = self.read_lino_records();
670 records.sort_by(|a, b| b.start_time.cmp(&a.start_time));
671 records.truncate(limit);
672 records
673 }
674 pub fn cleanup_stale(&self, options: CleanupOptions) -> CleanupResult {
676 let max_age_ms = options.max_age_ms.unwrap_or(24 * 60 * 60 * 1000);
677 let dry_run = options.dry_run;
678 let mut result = CleanupResult {
679 cleaned: 0,
680 records: Vec::new(),
681 errors: Vec::new(),
682 };
683
684 let records = self.read_lino_records();
685 let mut stale_records: Vec<ExecutionRecord> = Vec::new();
686
687 for record in records
688 .iter()
689 .filter(|r| r.status == ExecutionStatus::Executing)
690 {
691 let mut is_stale = false;
692
693 #[cfg(unix)]
695 if let Some(pid) = record.pid {
696 if record.platform == std::env::consts::OS
697 && unsafe { libc::kill(pid as i32, 0) } != 0
698 {
699 is_stale = true;
700 self.log(&format!("Stale: {} (PID {} gone)", record.uuid, pid));
701 }
702 }
703
704 if !is_stale {
706 if let Ok(st) = chrono::DateTime::parse_from_rfc3339(&record.start_time) {
707 let age_ms =
708 (chrono::Utc::now() - st.with_timezone(&chrono::Utc)).num_milliseconds();
709 if age_ms > max_age_ms as i64 {
710 is_stale = true;
711 self.log(&format!(
712 "Stale: {} ({}min old)",
713 record.uuid,
714 age_ms / 60000
715 ));
716 }
717 }
718 }
719
720 if is_stale {
721 stale_records.push(record.clone());
722 }
723 }
724
725 result.records = stale_records.clone();
726
727 if !dry_run && !stale_records.is_empty() {
728 let mut lock = LockManager::new(self.lock_file_path.clone());
729 if !lock.acquire(LOCK_TIMEOUT_MS) {
730 result.errors.push("Failed to acquire lock".to_string());
731 return result;
732 }
733
734 let mut current = self.read_lino_records();
735 for stale in &stale_records {
736 if let Some(i) = current.iter().position(|r| r.uuid == stale.uuid) {
737 current[i].status = ExecutionStatus::Executed;
738 current[i].exit_code = Some(-1);
739 current[i].end_time = None;
743 current[i].stale_detected_at = Some(chrono::Utc::now().to_rfc3339());
744 result.cleaned += 1;
745 }
746 }
747
748 if let Err(e) = self.write_lino_records(¤t) {
749 result.errors.push(format!("Cleanup error: {}", e));
750 } else {
751 self.log(&format!("Cleaned {} stale records", result.cleaned));
752 }
753 } else if dry_run {
754 result.cleaned = stale_records.len();
755 }
756
757 result
758 }
759
760 pub fn delete(&self, uuid: &str) -> Result<bool, String> {
762 let mut lock = LockManager::new(self.lock_file_path.clone());
763
764 if !lock.acquire(LOCK_TIMEOUT_MS) {
765 return Err("Failed to acquire lock for database write".to_string());
766 }
767
768 let records = self.read_lino_records();
769 let filtered: Vec<_> = records.iter().filter(|r| r.uuid != uuid).cloned().collect();
770
771 if filtered.len() == records.len() {
772 return Ok(false); }
774
775 self.write_lino_records(&filtered)
776 .map_err(|e| e.to_string())?;
777
778 if self.use_links {
780 self.delete_links_record(uuid);
781 }
782
783 Ok(true)
784 }
785
786 pub fn clear(&self) -> Result<(), String> {
788 let mut lock = LockManager::new(self.lock_file_path.clone());
789
790 if !lock.acquire(LOCK_TIMEOUT_MS) {
791 return Err("Failed to acquire lock for database write".to_string());
792 }
793
794 self.write_lino_records(&[]).map_err(|e| e.to_string())?;
795
796 if self.use_links && self.links_db_path.exists() {
798 let _ = fs::remove_file(&self.links_db_path);
799 }
800
801 Ok(())
802 }
803
804 pub fn verify_consistency(&self) -> ConsistencyResult {
806 let mut result = ConsistencyResult {
807 consistent: true,
808 lino_count: 0,
809 links_count: 0,
810 errors: Vec::new(),
811 };
812
813 let lino_records = self.read_lino_records();
815 result.lino_count = lino_records.len();
816
817 if !self.use_links {
818 result
819 .errors
820 .push("clink not installed - links database not available".to_string());
821 return result;
822 }
823
824 match self.exec_clink("((($id: ExecutionRecord $uuid)) (($id: ExecutionRecord $uuid)))") {
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;