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 is_clink_installed() -> bool {
244 Command::new("clink")
245 .arg("--version")
246 .stdout(Stdio::null())
247 .stderr(Stdio::null())
248 .status()
249 .map(|s| s.success())
250 .unwrap_or(false)
251}
252
253pub fn get_default_app_folder() -> PathBuf {
255 if let Ok(custom) = env::var("START_APP_FOLDER") {
256 return PathBuf::from(custom);
257 }
258
259 let home = env::var("HOME")
260 .or_else(|_| env::var("USERPROFILE"))
261 .unwrap_or_else(|_| ".".to_string());
262 PathBuf::from(home).join(DEFAULT_APP_FOLDER_NAME)
263}
264
265#[derive(Clone)]
267pub struct ExecutionStore {
268 app_folder: PathBuf,
269 lino_db_path: PathBuf,
270 links_db_path: PathBuf,
271 lock_file_path: PathBuf,
272 use_links: bool,
273 verbose: bool,
274}
275
276#[derive(Debug, Default)]
278pub struct ExecutionStoreOptions {
279 pub app_folder: Option<PathBuf>,
280 pub use_links: Option<bool>,
281 pub verbose: bool,
282}
283
284impl ExecutionStore {
285 pub fn new() -> Self {
287 Self::with_options(ExecutionStoreOptions::default())
288 }
289
290 pub fn with_options(options: ExecutionStoreOptions) -> Self {
292 let app_folder = options.app_folder.unwrap_or_else(get_default_app_folder);
293 let lino_db_path = app_folder.join(LINO_DB_FILE);
294 let links_db_path = app_folder.join(LINKS_DB_FILE);
295 let lock_file_path = app_folder.join(LOCK_FILE);
296 let use_links = options.use_links.unwrap_or_else(is_clink_installed);
297
298 let _ = fs::create_dir_all(&app_folder);
300
301 ExecutionStore {
302 app_folder,
303 lino_db_path,
304 links_db_path,
305 lock_file_path,
306 use_links,
307 verbose: options.verbose,
308 }
309 }
310
311 fn log(&self, message: &str) {
313 if self.verbose {
314 println!("[ExecutionStore] {}", message);
315 }
316 }
317
318 pub fn read_lino_records(&self) -> Vec<ExecutionRecord> {
320 if !self.lino_db_path.exists() {
321 return Vec::new();
322 }
323
324 match fs::read_to_string(&self.lino_db_path) {
325 Ok(content) => {
326 if content.trim().is_empty() {
327 return Vec::new();
328 }
329
330 let decoded = decode_store_content(
332 &self.lino_db_path,
333 &content,
334 |content| {
335 decode(content).map(|data| match data {
336 LinoValue::Array(arr) => arr.iter().map(lino_value_to_json).collect(),
337 _ => Vec::new(),
338 })
339 },
340 Some(&self.app_folder.join(DECODED_COPY_FILE)),
341 );
342 match decoded {
343 Ok((records, source)) => {
344 self.log(&match source {
345 DecodeSource::Memory => {
346 "Reused the decoded store: the file is unchanged".to_string()
347 }
348 DecodeSource::Copy => {
349 "Read the decoded copy of the store: the file is unchanged"
350 .to_string()
351 }
352 DecodeSource::Decode => {
353 format!("Decoded the store ({} characters)", content.len())
354 }
355 });
356 records
357 }
358 Err(e) => {
359 self.log(&format!("Error decoding lino records: {}", e));
360 Vec::new()
361 }
362 }
363 }
364 Err(e) => {
365 self.log(&format!("Error reading lino records: {}", e));
366 Vec::new()
367 }
368 }
369 }
370
371 fn write_lino_records(&self, records: &[ExecutionRecord]) -> std::io::Result<()> {
373 let data: Vec<LinoValue> = records
374 .iter()
375 .map(|record| json_to_lino_value(&record.to_json()))
376 .collect();
377 let content = encode(&LinoValue::Array(data));
378 crate::atomic_write::atomic_write(&self.lino_db_path, content.as_bytes())?;
379 self.log(&format!("Wrote {} records to lino file", records.len()));
380 Ok(())
381 }
382
383 fn build_clink_create_query(&self, record: &ExecutionRecord) -> String {
385 let obj = record.to_json();
386 let mut links = Vec::new();
387
388 links.push(format!(
390 "({}: ExecutionRecord {})",
391 record.uuid, record.uuid
392 ));
393
394 if let Value::Object(map) = obj {
396 for (key, value) in map {
397 let escaped_value = match value {
398 Value::Object(_) | Value::Array(_) => {
399 serde_json::to_string(&value).unwrap_or_default()
400 }
401 Value::String(s) => s,
402 Value::Null => "null".to_string(),
403 other => other.to_string(),
404 };
405 links.push(format!(
406 "({}.{}: {} \"{}\")",
407 record.uuid,
408 key,
409 key,
410 escaped_value.replace('"', "\\\"")
411 ));
412 }
413 }
414
415 format!("() (({})))", links.join(") ("))
416 }
417
418 fn exec_clink(&self, query: &str) -> Result<String, String> {
420 match Command::new("clink")
421 .arg(query)
422 .arg("--db")
423 .arg(&self.links_db_path)
424 .output()
425 {
426 Ok(output) => {
427 if output.status.success() {
428 Ok(String::from_utf8_lossy(&output.stdout).to_string())
429 } else {
430 let stderr = String::from_utf8_lossy(&output.stderr).to_string();
431 self.log(&format!("Clink error: {}", stderr));
432 Err(stderr)
433 }
434 }
435 Err(e) => {
436 self.log(&format!("Clink execution error: {}", e));
437 Err(e.to_string())
438 }
439 }
440 }
441
442 fn write_links_record(&self, record: &ExecutionRecord) -> bool {
444 if !self.use_links {
445 return false;
446 }
447
448 let query = self.build_clink_create_query(record);
449 match self.exec_clink(&query) {
450 Ok(_) => {
451 self.log(&format!("Wrote record {} to links database", record.uuid));
452 true
453 }
454 Err(_) => false,
455 }
456 }
457
458 fn delete_links_record(&self, uuid: &str) -> bool {
460 if !self.use_links {
461 return false;
462 }
463
464 let query = format!("(($id: {} $any)) ()", uuid);
465 self.exec_clink(&query).is_ok()
466 }
467
468 pub fn save(&self, record: &ExecutionRecord) -> Result<(), String> {
470 self.save_reserved(record, None)
471 }
472
473 pub fn reserve_launch(
474 &self,
475 record: &ExecutionRecord,
476 previous: &ExecutionRecord,
477 ) -> Result<(), String> {
478 self.save_reserved(record, Some(previous))
479 }
480
481 fn save_reserved(
482 &self,
483 record: &ExecutionRecord,
484 previous: Option<&ExecutionRecord>,
485 ) -> Result<(), String> {
486 let mut lock = LockManager::new(self.lock_file_path.clone());
487
488 if !lock.acquire(LOCK_TIMEOUT_MS) {
489 return Err("Failed to acquire lock for database write".to_string());
490 }
491
492 let mut records = self.read_lino_records();
494
495 let existing_index = records.iter().position(|r| r.uuid == record.uuid);
497 if let Some(previous) = previous {
498 let current = existing_index
499 .and_then(|i| records.get(i))
500 .ok_or("Execution disappeared before launch reservation")?;
501 if crate::launch_owner::has_active_launch(current)
502 || current.attempt.as_ref().map(|a| a.number)
503 != previous.attempt.as_ref().map(|a| a.number)
504 {
505 return Err("A different resume already reserved this execution".into());
506 }
507 }
508
509 if let Some(idx) = existing_index {
510 records[idx] = record.clone();
512 } else {
513 records.push(record.clone());
515 }
516
517 self.write_lino_records(&records)
519 .map_err(|e| e.to_string())?;
520
521 if self.use_links {
523 self.write_links_record(record);
524 }
525
526 Ok(())
527 }
528
529 pub fn patch_attempt(
531 &self,
532 uuid: &str,
533 number: u64,
534 fields: Value,
535 ) -> Result<Option<ExecutionRecord>, String> {
536 let mut lock = LockManager::new(self.lock_file_path.clone());
537 if !lock.acquire(LOCK_TIMEOUT_MS) {
538 return Err("Failed to acquire lock for attempt metadata".into());
539 }
540 let mut records = self.read_lino_records();
541 let Some(current) = records
542 .iter_mut()
543 .find(|r| r.uuid == uuid && r.attempt.as_ref().is_some_and(|a| a.number == number))
544 else {
545 return Ok(None);
546 };
547 let mut value =
548 serde_json::to_value(current.attempt.as_ref().unwrap()).map_err(|e| e.to_string())?;
549 if let Some(fields) = fields.as_object() {
550 for (key, field) in fields {
551 value[key] = field.clone();
552 }
553 }
554 current.attempt = Some(serde_json::from_value(value).map_err(|e| e.to_string())?);
555 let updated = current.clone();
556 self.write_lino_records(&records)
557 .map_err(|e| e.to_string())?;
558 if self.use_links {
559 self.write_links_record(&updated);
560 }
561 Ok(Some(updated))
562 }
563
564 pub fn mark_container_removed(
568 &self,
569 uuid: &str,
570 observed_at: &str,
571 ) -> Result<Option<ExecutionRecord>, String> {
572 let mut lock = LockManager::new(self.lock_file_path.clone());
573 if !lock.acquire(LOCK_TIMEOUT_MS) {
574 return Err("Failed to acquire lock for container state".into());
575 }
576 let mut records = self.read_lino_records();
577 let Some(current) = records.iter_mut().find(|r| {
578 r.uuid == uuid
579 && r.status == ExecutionStatus::Executed
580 && r.container_state.as_deref() != Some("removed")
581 }) else {
582 return Ok(None);
583 };
584 current.container_state = Some("removed".to_string());
585 current.container_removed_observed_at = Some(observed_at.to_string());
586 let updated = current.clone();
587 self.write_lino_records(&records)
588 .map_err(|e| e.to_string())?;
589 if self.use_links {
590 self.write_links_record(&updated);
591 }
592 Ok(Some(updated))
593 }
594
595 pub fn get(&self, identifier: &str) -> Option<ExecutionRecord> {
598 crate::execution_identity::find_record(&self.read_lino_records(), identifier).cloned()
599 }
600
601 pub fn with_lock<F>(&self, mutate: F) -> Result<Option<ExecutionRecord>, String>
604 where
605 F: FnOnce(&mut Vec<ExecutionRecord>) -> Result<Option<ExecutionRecord>, String>,
606 {
607 let mut lock = LockManager::new(self.lock_file_path.clone());
608 if !lock.acquire(LOCK_TIMEOUT_MS) {
609 return Err("Failed to acquire lock for database write".into());
610 }
611 let mut records = self.read_lino_records();
612 let changed = mutate(&mut records)?;
613 if let Some(record) = &changed {
614 self.write_lino_records(&records)
615 .map_err(|e| e.to_string())?;
616 if self.use_links {
617 self.write_links_record(record);
618 }
619 }
620 Ok(changed)
621 }
622
623 pub fn get_all(&self) -> Vec<ExecutionRecord> {
625 self.read_lino_records()
626 }
627 pub fn get_by_status(&self, status: ExecutionStatus) -> Vec<ExecutionRecord> {
629 self.read_lino_records()
630 .into_iter()
631 .filter(|r| r.status == status)
632 .collect()
633 }
634 pub fn get_executing(&self) -> Vec<ExecutionRecord> {
636 self.get_by_status(ExecutionStatus::Executing)
637 }
638 pub fn get_recent(&self, limit: usize) -> Vec<ExecutionRecord> {
640 let mut records = self.read_lino_records();
641 records.sort_by(|a, b| b.start_time.cmp(&a.start_time));
642 records.truncate(limit);
643 records
644 }
645 pub fn cleanup_stale(&self, options: CleanupOptions) -> CleanupResult {
647 let max_age_ms = options.max_age_ms.unwrap_or(24 * 60 * 60 * 1000);
648 let dry_run = options.dry_run;
649 let mut result = CleanupResult {
650 cleaned: 0,
651 records: Vec::new(),
652 errors: Vec::new(),
653 };
654
655 let records = self.read_lino_records();
656 let mut stale_records: Vec<ExecutionRecord> = Vec::new();
657
658 for record in records
659 .iter()
660 .filter(|r| r.status == ExecutionStatus::Executing)
661 {
662 if crate::launch_owner::has_active_launch(record) {
663 continue;
664 }
665 let mut is_stale = false;
666
667 #[cfg(unix)]
669 if let Some(pid) = record.pid {
670 if record.platform == std::env::consts::OS
671 && unsafe { libc::kill(pid as i32, 0) } != 0
672 {
673 is_stale = true;
674 self.log(&format!("Stale: {} (PID {} gone)", record.uuid, pid));
675 }
676 }
677
678 if !is_stale {
680 if let Ok(st) = chrono::DateTime::parse_from_rfc3339(&record.start_time) {
681 let age_ms =
682 (chrono::Utc::now() - st.with_timezone(&chrono::Utc)).num_milliseconds();
683 if age_ms > max_age_ms as i64 {
684 is_stale = true;
685 self.log(&format!(
686 "Stale: {} ({}min old)",
687 record.uuid,
688 age_ms / 60000
689 ));
690 }
691 }
692 }
693
694 if is_stale {
695 stale_records.push(record.clone());
696 }
697 }
698
699 result.records = stale_records.clone();
700
701 if !dry_run && !stale_records.is_empty() {
702 let mut lock = LockManager::new(self.lock_file_path.clone());
703 if !lock.acquire(LOCK_TIMEOUT_MS) {
704 result.errors.push("Failed to acquire lock".to_string());
705 return result;
706 }
707
708 let mut current = self.read_lino_records();
709 for stale in &stale_records {
710 if let Some(i) = current.iter().position(|r| r.uuid == stale.uuid) {
711 current[i].status = ExecutionStatus::Executed;
712 current[i].exit_code = Some(-1);
713 current[i].end_time = None;
717 current[i].stale_detected_at = Some(chrono::Utc::now().to_rfc3339());
718 result.cleaned += 1;
719 }
720 }
721
722 if let Err(e) = self.write_lino_records(¤t) {
723 result.errors.push(format!("Cleanup error: {}", e));
724 } else {
725 self.log(&format!("Cleaned {} stale records", result.cleaned));
726 }
727 } else if dry_run {
728 result.cleaned = stale_records.len();
729 }
730
731 result
732 }
733
734 pub fn delete(&self, uuid: &str) -> Result<bool, String> {
736 let mut lock = LockManager::new(self.lock_file_path.clone());
737
738 if !lock.acquire(LOCK_TIMEOUT_MS) {
739 return Err("Failed to acquire lock for database write".to_string());
740 }
741
742 let records = self.read_lino_records();
743 let filtered: Vec<_> = records.iter().filter(|r| r.uuid != uuid).cloned().collect();
744
745 if filtered.len() == records.len() {
746 return Ok(false); }
748
749 self.write_lino_records(&filtered)
750 .map_err(|e| e.to_string())?;
751
752 if self.use_links {
754 self.delete_links_record(uuid);
755 }
756
757 Ok(true)
758 }
759
760 pub fn clear(&self) -> Result<(), 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 self.write_lino_records(&[]).map_err(|e| e.to_string())?;
769
770 if self.use_links && self.links_db_path.exists() {
772 let _ = fs::remove_file(&self.links_db_path);
773 }
774
775 Ok(())
776 }
777
778 pub fn verify_consistency(&self) -> ConsistencyResult {
780 let mut result = ConsistencyResult {
781 consistent: true,
782 lino_count: 0,
783 links_count: 0,
784 errors: Vec::new(),
785 };
786
787 let lino_records = self.read_lino_records();
789 result.lino_count = lino_records.len();
790
791 if !self.use_links {
792 result
793 .errors
794 .push("clink not installed - links database not available".to_string());
795 return result;
796 }
797
798 match self.exec_clink("((($id: ExecutionRecord $uuid)) (($id: ExecutionRecord $uuid)))") {
800 Ok(output) => {
801 let re = regex::Regex::new(r"ExecutionRecord\s+([a-f0-9-]{36})").unwrap();
803 let uuids: std::collections::HashSet<_> = re
804 .captures_iter(&output)
805 .filter_map(|c| c.get(1).map(|m| m.as_str().to_lowercase()))
806 .collect();
807 result.links_count = uuids.len();
808
809 if result.lino_count != result.links_count {
811 result.consistent = false;
812 result.errors.push(format!(
813 "Record count mismatch: lino={}, links={}",
814 result.lino_count, result.links_count
815 ));
816 }
817
818 for record in &lino_records {
820 if !uuids.contains(&record.uuid.to_lowercase()) {
821 result.consistent = false;
822 result.errors.push(format!(
823 "Record {} missing from links database",
824 record.uuid
825 ));
826 }
827 }
828 }
829 Err(e) => {
830 result
831 .errors
832 .push(format!("Failed to query links database: {}", e));
833 result.consistent = false;
834 }
835 }
836
837 result
838 }
839
840 pub fn get_stats(&self) -> ExecutionStats {
842 let records = self.read_lino_records();
843 let executing = records
844 .iter()
845 .filter(|r| r.status == ExecutionStatus::Executing)
846 .count();
847 let executed = records
848 .iter()
849 .filter(|r| r.status == ExecutionStatus::Executed)
850 .count();
851 let successful = records
852 .iter()
853 .filter(|r| r.status == ExecutionStatus::Executed && r.exit_code == Some(0))
854 .count();
855 let failed = records
856 .iter()
857 .filter(|r| {
858 r.status == ExecutionStatus::Executed
859 && r.exit_code.map(|c| c != 0).unwrap_or(false)
860 })
861 .count();
862
863 ExecutionStats {
864 total: records.len(),
865 executing,
866 executed,
867 successful,
868 failed,
869 clink_available: self.use_links,
870 lino_db_path: self.lino_db_path.to_string_lossy().to_string(),
871 links_db_path: self.links_db_path.to_string_lossy().to_string(),
872 }
873 }
874
875 pub fn app_folder(&self) -> &Path {
877 &self.app_folder
878 }
879}
880
881impl Default for ExecutionStore {
882 fn default() -> Self {
883 Self::new()
884 }
885}
886
887#[derive(Debug)]
889pub struct ConsistencyResult {
890 pub consistent: bool,
891 pub lino_count: usize,
892 pub links_count: usize,
893 pub errors: Vec<String>,
894}
895
896#[derive(Debug, Default)]
898pub struct CleanupOptions {
899 pub max_age_ms: Option<u64>,
900 pub dry_run: bool,
901}
902
903#[derive(Debug)]
905pub struct CleanupResult {
906 pub cleaned: usize,
907 pub records: Vec<ExecutionRecord>,
908 pub errors: Vec<String>,
909}
910
911#[derive(Debug)]
913pub struct ExecutionStats {
914 pub total: usize,
915 pub executing: usize,
916 pub executed: usize,
917 pub successful: usize,
918 pub failed: usize,
919 pub clink_available: bool,
920 pub lino_db_path: String,
921 pub links_db_path: String,
922}
923
924#[cfg(test)]
925#[path = "execution_store_cases.rs"]
926mod tests;