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