1use std::collections::BTreeSet;
5use std::fs;
6use std::io::ErrorKind;
7use std::io::Write;
8use std::path::Path;
9
10use runx_contracts::schema::NonEmptyString;
11use runx_contracts::{ClosureDisposition, ExecutionEvent, Receipt, ReferenceType};
12use runx_receipts::{
13 ReceiptFindingCode, ReceiptProofContextProvider, signed_display_identity, verify_receipt_proof,
14};
15use serde::{Deserialize, Serialize};
16use thiserror::Error;
17
18use crate::lifecycle::receipt_lifecycle_records;
19use crate::receipts::paths::safe_receipt_store_label;
20use crate::receipts::store::{LocalReceiptStore, ReceiptStoreError};
21use crate::receipts::{RuntimeReceiptProofContextProvider, RuntimeReceiptSignaturePolicy};
22
23pub const JOURNAL_PROJECTION_SCHEMA: &str = "runx.journal_projection.v1";
24pub const JOURNAL_PROJECTOR_ID: &str = "runx-runtime.local-journal.v1";
25pub const HISTORY_PROJECTOR_ID: &str = "runx-runtime.local-history.v1";
26pub const RECEIPT_REF_PREFIX: &str = "runx:receipt:";
27
28#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
29pub struct JournalEntry {
30 pub event: ExecutionEvent,
31}
32
33#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
34pub struct ExecutionJournal {
35 entries: Vec<JournalEntry>,
36}
37
38impl ExecutionJournal {
39 pub fn push(&mut self, event: ExecutionEvent) {
40 self.entries.push(JournalEntry { event });
41 }
42
43 #[must_use]
44 pub fn entries(&self) -> &[JournalEntry] {
45 &self.entries
46 }
47}
48
49#[derive(Clone, Debug, Default, PartialEq, Eq)]
50pub struct HistoryFilter {
51 pub query: Option<String>,
52 pub skill: Option<String>,
53 pub status: Option<String>,
54 pub source: Option<String>,
55 pub actor: Option<String>,
56 pub artifact_type: Option<String>,
57 pub since: Option<String>,
58 pub until: Option<String>,
59 pub limit: Option<usize>,
60}
61
62#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
63pub struct LocalHistoryProjection {
64 pub projector_id: String,
65 pub store_label: String,
66 pub receipts: Vec<LocalHistoryReceipt>,
67 #[serde(rename = "pendingRuns")]
68 pub pending_runs: Vec<PausedRunSummary>,
69}
70
71#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
72pub struct LocalHistoryReceipt {
73 pub id: String,
74 pub receipt_ref: String,
75 pub name: String,
76 pub status: String,
77 pub created_at: String,
78 pub harness_id: String,
79 pub harness_state: String,
80 pub summary: String,
81 pub source_type: Option<String>,
82 pub actors: Vec<String>,
83 pub artifact_types: Vec<String>,
84 pub verification: ReceiptVerificationProjection,
85}
86
87#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
88pub struct ReceiptVerificationProjection {
89 pub status: String,
90}
91
92#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
93#[serde(rename_all = "camelCase")]
94pub struct PausedRunSummary {
95 pub id: String,
96 pub name: String,
97 pub kind: String,
98 pub status: String,
99 pub started_at: Option<String>,
100 #[serde(skip_serializing_if = "Option::is_none")]
101 pub resume_skill_ref: Option<String>,
102 pub selected_runner: Option<String>,
103 pub step_ids: Vec<String>,
104 pub step_labels: Vec<String>,
105 pub ledger_verification: Option<LedgerVerificationProjection>,
106}
107
108#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
109pub struct LedgerVerificationProjection {
110 pub status: String,
111 #[serde(skip_serializing_if = "Option::is_none")]
112 pub reason: Option<String>,
113}
114
115#[derive(Clone, Debug, PartialEq, Eq)]
116pub struct PausedRunCheckpoint {
117 pub id: String,
118 pub name: String,
119 pub kind: String,
120 pub started_at: Option<String>,
121 pub resume_skill_ref: Option<String>,
122 pub selected_runner: Option<String>,
123 pub step_ids: Vec<String>,
124 pub step_labels: Vec<String>,
125}
126
127pub fn append_paused_run_checkpoint(
128 receipt_dir: &Path,
129 checkpoint: &PausedRunCheckpoint,
130) -> Result<(), std::io::Error> {
131 let ledgers_dir = receipt_dir.join("ledgers");
132 fs::create_dir_all(&ledgers_dir)?;
133 let ledger_path = ledgers_dir.join(format!("{}.jsonl", checkpoint.id));
134 let mut file = fs::OpenOptions::new()
135 .create(true)
136 .append(true)
137 .open(ledger_path)?;
138 let entry = LedgerEntry {
139 entry_type: "run_event".to_owned(),
140 data: LedgerEventData {
141 kind: "resolution_requested".to_owned(),
142 detail: LedgerEventDetail {
143 resume_skill_ref: checkpoint.resume_skill_ref.clone(),
144 selected_runner: checkpoint.selected_runner.clone(),
145 step_ids: checkpoint.step_ids.clone(),
146 step_labels: checkpoint.step_labels.clone(),
147 },
148 },
149 meta: LedgerEventMeta {
150 created_at: checkpoint.started_at.clone(),
151 producer: Some(LedgerEventProducer {
152 skill: Some(checkpoint.name.clone()),
153 runner: checkpoint.selected_runner.clone(),
154 }),
155 },
156 };
157 serde_json::to_writer(&mut file, &entry)?;
158 file.write_all(b"\n")
159}
160
161#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
162pub struct JournalProjection {
163 pub schema: String,
164 pub projector_id: String,
165 pub receipt_ref: String,
166 pub watermark: String,
167 pub rows: Vec<JournalProjectionRow>,
168}
169
170#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
171pub struct JournalProjectionRow {
172 pub schema: String,
173 pub entry_id: String,
174 pub recorded_at: String,
175 pub projector_id: String,
176 pub source_refs: Vec<String>,
177 pub watermark: String,
178 pub event_kind: String,
179 pub summary: String,
180 pub receipt_ref: Option<String>,
181 pub harness_ref: Option<String>,
182 pub act_ref: Option<String>,
183 pub decision_ref: Option<String>,
184 pub artifact_refs: Vec<String>,
185 pub status: Option<String>,
186 pub verification: Option<ReceiptVerificationProjection>,
187}
188
189#[derive(Debug, Error)]
190pub enum JournalProjectionError {
191 #[error(transparent)]
192 ReceiptStore(#[from] ReceiptStoreError),
193 #[error("invalid {field} timestamp '{value}': expected RFC 3339 timestamp")]
194 InvalidTimestamp { field: &'static str, value: String },
195 #[error("failed to read local run ledgers")]
196 LedgerStoreUnreadable,
197}
198
199pub fn list_local_history(
200 store: &LocalReceiptStore,
201 workspace_base: &Path,
202 project_runx_dir: &Path,
203 filter: &HistoryFilter,
204) -> Result<LocalHistoryProjection, JournalProjectionError> {
205 list_local_history_with_policy(
206 store,
207 workspace_base,
208 project_runx_dir,
209 filter,
210 RuntimeReceiptSignaturePolicy::local_development(),
211 )
212}
213
214pub fn list_local_history_with_policy(
215 store: &LocalReceiptStore,
216 workspace_base: &Path,
217 project_runx_dir: &Path,
218 filter: &HistoryFilter,
219 signature_policy: RuntimeReceiptSignaturePolicy<'_>,
220) -> Result<LocalHistoryProjection, JournalProjectionError> {
221 list_local_history_with_checkpoints_and_policy(
222 store,
223 workspace_base,
224 project_runx_dir,
225 filter,
226 &[],
227 signature_policy,
228 )
229}
230
231pub fn list_local_history_with_checkpoints(
232 store: &LocalReceiptStore,
233 workspace_base: &Path,
234 project_runx_dir: &Path,
235 filter: &HistoryFilter,
236 checkpoints: &[PausedRunCheckpoint],
237) -> Result<LocalHistoryProjection, JournalProjectionError> {
238 list_local_history_with_checkpoints_and_policy(
239 store,
240 workspace_base,
241 project_runx_dir,
242 filter,
243 checkpoints,
244 RuntimeReceiptSignaturePolicy::local_development(),
245 )
246}
247
248pub fn list_local_history_with_checkpoints_and_policy(
249 store: &LocalReceiptStore,
250 workspace_base: &Path,
251 project_runx_dir: &Path,
252 filter: &HistoryFilter,
253 checkpoints: &[PausedRunCheckpoint],
254 signature_policy: RuntimeReceiptSignaturePolicy<'_>,
255) -> Result<LocalHistoryProjection, JournalProjectionError> {
256 let label = safe_receipt_store_label(store.root(), workspace_base, project_runx_dir);
257 let filter = ResolvedHistoryFilter::parse(filter)?;
258 let all_rows = match store.list_without_proof_for_history() {
259 Ok(receipts) => receipts
260 .iter()
261 .map(|receipt| history_row_with_policy(receipt, signature_policy))
262 .collect::<Vec<_>>(),
263 Err(ReceiptStoreError::MissingStore { .. }) => Vec::new(),
264 Err(error) => return Err(error.into()),
265 };
266 let terminal_ids = all_rows
267 .iter()
268 .flat_map(|row| [row.id.clone(), row.harness_id.clone()])
269 .collect::<BTreeSet<_>>();
270 let mut rows = all_rows
271 .into_iter()
272 .filter(|row| matches_history_filter(row, &filter))
273 .collect::<Vec<_>>();
274 let mut pending_runs = list_paused_runs(store.root(), &terminal_ids, checkpoints)?
275 .into_iter()
276 .filter(|row| matches_paused_history_filter(row, &filter))
277 .collect::<Vec<_>>();
278 rows.sort_by(|left, right| {
279 right
280 .created_at
281 .cmp(&left.created_at)
282 .then_with(|| left.id.cmp(&right.id))
283 });
284 pending_runs.sort_by(|left, right| {
285 compare_optional_timestamp_desc(&left.started_at, &right.started_at)
286 .then_with(|| left.id.cmp(&right.id))
287 });
288 if let Some(limit) = filter.limit {
289 rows.truncate(limit);
290 }
291 Ok(LocalHistoryProjection {
292 projector_id: HISTORY_PROJECTOR_ID.to_owned(),
293 store_label: label.as_str().to_owned(),
294 receipts: rows,
295 pending_runs,
296 })
297}
298
299pub fn project_journal_for_receipt(
300 store: &LocalReceiptStore,
301 receipt_reference: &str,
302) -> Result<JournalProjection, JournalProjectionError> {
303 let receipt_id = exact_receipt_id(receipt_reference);
304 let receipt = store.read_exact(&receipt_id)?;
305 Ok(project_receipt_journal(&receipt))
306}
307
308#[must_use]
309pub fn project_receipt_journal(receipt: &Receipt) -> JournalProjection {
313 project_receipt_journal_with_policy(receipt, RuntimeReceiptSignaturePolicy::local_development())
314}
315
316#[must_use]
317pub fn project_receipt_journal_with_policy(
321 receipt: &Receipt,
322 signature_policy: RuntimeReceiptSignaturePolicy<'_>,
323) -> JournalProjection {
324 let watermark = receipt_watermark(receipt);
325 let receipt_ref = receipt_uri(&receipt.id);
326 let harness_ref = receipt.subject.reference.uri.clone().into_string();
327 let verification = ReceiptVerificationProjection {
328 status: verification_status(receipt, signature_policy),
329 };
330 let mut rows = receipt_lifecycle_records(
331 receipt,
332 &receipt_ref,
333 &harness_ref,
334 closure_status(&receipt.seal.disposition),
335 )
336 .into_iter()
337 .map(|record| JournalProjectionRow {
338 schema: JOURNAL_PROJECTION_SCHEMA.to_owned(),
339 entry_id: format!("journal:{}:{}", receipt.id, record.entry_key),
340 recorded_at: receipt.created_at.to_string(),
341 projector_id: JOURNAL_PROJECTOR_ID.to_owned(),
342 source_refs: record.source_refs,
343 watermark: watermark.clone(),
344 event_kind: record.event_kind.to_owned(),
345 summary: record.summary,
346 receipt_ref: Some(receipt_ref.clone()),
347 harness_ref: record.harness_ref,
348 act_ref: record.act_ref,
349 decision_ref: record.decision_ref,
350 artifact_refs: record.artifact_refs,
351 status: record.status,
352 verification: record.include_verification.then_some(verification.clone()),
353 })
354 .collect::<Vec<_>>();
355
356 rows.sort_by(|left, right| {
357 left.recorded_at
358 .cmp(&right.recorded_at)
359 .then_with(|| left.entry_id.cmp(&right.entry_id))
360 });
361 JournalProjection {
362 schema: JOURNAL_PROJECTION_SCHEMA.to_owned(),
363 projector_id: JOURNAL_PROJECTOR_ID.to_owned(),
364 receipt_ref,
365 watermark,
366 rows,
367 }
368}
369
370#[must_use]
371pub fn receipt_uri(receipt_id: &str) -> String {
372 format!("{RECEIPT_REF_PREFIX}{receipt_id}")
373}
374
375#[must_use]
376pub fn exact_receipt_id(reference: &str) -> String {
377 reference
378 .strip_prefix(RECEIPT_REF_PREFIX)
379 .unwrap_or(reference)
380 .to_owned()
381}
382
383fn history_row_with_policy(
384 receipt: &Receipt,
385 signature_policy: RuntimeReceiptSignaturePolicy<'_>,
386) -> LocalHistoryReceipt {
387 let identity = signed_display_identity(receipt);
388 LocalHistoryReceipt {
389 id: receipt.id.to_string(),
390 receipt_ref: receipt_uri(&receipt.id),
391 name: identity.subject_ref.clone(),
392 status: closure_status(&receipt.seal.disposition),
393 created_at: receipt.created_at.to_string(),
394 harness_id: identity.subject_ref,
395 harness_state: subject_state(&receipt.subject.kind, &receipt.seal.disposition),
396 summary: receipt.seal.summary.to_string(),
397 source_type: Some(identity.source_type),
398 actors: identity.actors,
399 artifact_types: artifact_types(receipt),
400 verification: ReceiptVerificationProjection {
401 status: verification_status(receipt, signature_policy),
402 },
403 }
404}
405
406fn matches_history_filter(row: &LocalHistoryReceipt, filter: &ResolvedHistoryFilter) -> bool {
407 filter.query.as_ref().is_none_or(|query| {
408 row.name.to_lowercase().contains(query)
409 || row.id.to_lowercase().contains(query)
410 || row
411 .source_type
412 .as_ref()
413 .is_some_and(|source| source.to_lowercase().contains(query))
414 || row
415 .actors
416 .iter()
417 .any(|actor| actor.to_lowercase().contains(query))
418 || row
419 .artifact_types
420 .iter()
421 .any(|artifact_type| artifact_type.to_lowercase().contains(query))
422 }) && filter
423 .skill
424 .as_ref()
425 .is_none_or(|skill| row.name.to_lowercase().contains(skill))
426 && filter
427 .status
428 .as_ref()
429 .is_none_or(|status| row.status.to_lowercase() == *status)
430 && filter.source.as_ref().is_none_or(|source| {
431 row.source_type
432 .as_ref()
433 .is_some_and(|candidate| candidate.to_lowercase() == *source)
434 })
435 && filter.actor.as_ref().is_none_or(|actor| {
436 row.actors
437 .iter()
438 .any(|candidate| candidate.to_lowercase() == *actor)
439 })
440 && filter.artifact_type.as_ref().is_none_or(|artifact_type| {
441 row.artifact_types
442 .iter()
443 .any(|candidate| candidate.to_lowercase() == *artifact_type)
444 })
445 && matches_timestamp_filter(row.created_at.as_str(), filter)
446}
447
448fn matches_paused_history_filter(row: &PausedRunSummary, filter: &ResolvedHistoryFilter) -> bool {
449 filter.query.as_ref().is_none_or(|query| {
450 row.name.to_lowercase().contains(query)
451 || row.id.to_lowercase().contains(query)
452 || row
453 .selected_runner
454 .as_ref()
455 .is_some_and(|runner| runner.to_lowercase().contains(query))
456 }) && filter
457 .skill
458 .as_ref()
459 .is_none_or(|skill| row.name.to_lowercase().contains(skill))
460 && filter
461 .status
462 .as_ref()
463 .is_none_or(|status| row.status.to_lowercase() == *status)
464 && filter.source.is_none()
465 && filter.actor.is_none()
466 && filter.artifact_type.is_none()
467 && row.started_at.as_deref().map_or(
468 filter.since.is_none() && filter.until.is_none(),
469 |started_at| matches_timestamp_filter(started_at, filter),
470 )
471}
472
473fn matches_timestamp_filter(timestamp: &str, filter: &ResolvedHistoryFilter) -> bool {
474 let Some(parsed) = Timestamp::parse(timestamp) else {
475 return filter.since.is_none() && filter.until.is_none();
476 };
477 filter.since.is_none_or(|since| parsed >= since)
478 && filter.until.is_none_or(|until| parsed <= until)
479}
480
481fn normalized(value: &Option<String>) -> Option<String> {
482 value
483 .as_ref()
484 .map(|entry| entry.trim().to_lowercase())
485 .filter(|entry| !entry.is_empty())
486}
487
488fn verification_status(
489 receipt: &Receipt,
490 signature_policy: RuntimeReceiptSignaturePolicy<'_>,
491) -> String {
492 let proof_contexts = RuntimeReceiptProofContextProvider::new(signature_policy);
493 let context = proof_contexts.proof_context(receipt);
494 let verification = verify_receipt_proof(receipt, &context);
495 if verification.findings.is_empty() {
498 if signature_policy.can_report_production_verified() {
499 "verified".to_owned()
500 } else {
501 "unverified".to_owned()
502 }
503 } else if verification
504 .findings
505 .iter()
506 .all(|finding| matches!(finding.code, ReceiptFindingCode::SignatureVerifierMissing))
507 {
508 "unverified".to_owned()
509 } else {
510 "invalid".to_owned()
511 }
512}
513
514fn receipt_watermark(receipt: &Receipt) -> String {
515 format!("{}@{}", receipt_uri(&receipt.id), receipt.created_at)
516}
517
518fn artifact_types(receipt: &Receipt) -> Vec<String> {
519 let mut types = BTreeSet::new();
520 for reference in receipt.acts.iter().flat_map(|act| act.artifact_refs.iter()) {
521 if reference.reference_type == ReferenceType::Artifact {
522 if let Some(label) = reference.label.as_ref().filter(|label| !label.is_empty()) {
523 types.insert(label.clone());
524 } else {
525 types.insert("artifact".to_owned().into());
526 }
527 }
528 }
529 types.into_iter().map(|label| label.into_string()).collect()
530}
531
532#[derive(Clone, Debug, Default, PartialEq, Eq)]
533struct ResolvedHistoryFilter {
534 query: Option<String>,
535 skill: Option<String>,
536 status: Option<String>,
537 source: Option<String>,
538 actor: Option<String>,
539 artifact_type: Option<String>,
540 since: Option<Timestamp>,
541 until: Option<Timestamp>,
542 limit: Option<usize>,
543}
544
545impl ResolvedHistoryFilter {
546 fn parse(filter: &HistoryFilter) -> Result<Self, JournalProjectionError> {
547 Ok(Self {
548 query: normalized(&filter.query),
549 skill: normalized(&filter.skill),
550 status: normalized(&filter.status),
551 source: normalized(&filter.source),
552 actor: normalized(&filter.actor),
553 artifact_type: normalized(&filter.artifact_type),
554 since: parse_date_filter("since", &filter.since)?,
555 until: parse_date_filter("until", &filter.until)?,
556 limit: filter.limit,
557 })
558 }
559}
560
561fn parse_date_filter(
562 field: &'static str,
563 value: &Option<String>,
564) -> Result<Option<Timestamp>, JournalProjectionError> {
565 let Some(value) = value
566 .as_ref()
567 .map(|entry| entry.trim())
568 .filter(|entry| !entry.is_empty())
569 else {
570 return Ok(None);
571 };
572 Timestamp::parse(value)
573 .map(Some)
574 .ok_or_else(|| JournalProjectionError::InvalidTimestamp {
575 field,
576 value: value.to_owned(),
577 })
578}
579
580#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
581struct Timestamp {
582 epoch_seconds: i64,
583 nanos: u32,
584}
585
586impl Timestamp {
587 fn parse(value: &str) -> Option<Self> {
588 let (date, time_and_zone) = value.split_once('T')?;
589 let (year, month, day) = parse_date(date)?;
590 let (time, offset_seconds) = parse_time_and_offset(time_and_zone)?;
591 let (hour, minute, second, nanos) = parse_time(time)?;
592 let days = days_from_civil(year, month, day)?;
593 let local_seconds = days
594 .checked_mul(86_400)?
595 .checked_add(i64::from(hour) * 3_600)?
596 .checked_add(i64::from(minute) * 60)?
597 .checked_add(i64::from(second))?;
598 Some(Self {
599 epoch_seconds: local_seconds.checked_sub(i64::from(offset_seconds))?,
600 nanos,
601 })
602 }
603}
604
605fn parse_date(value: &str) -> Option<(i32, u32, u32)> {
606 let mut parts = value.split('-');
607 let year = parse_i32(parts.next()?)?;
608 let month = parse_u32(parts.next()?)?;
609 let day = parse_u32(parts.next()?)?;
610 if parts.next().is_some()
611 || !(1..=12).contains(&month)
612 || day == 0
613 || day > days_in_month(year, month)
614 {
615 return None;
616 }
617 Some((year, month, day))
618}
619
620fn parse_time_and_offset(value: &str) -> Option<(&str, i32)> {
621 if let Some(time) = value.strip_suffix('Z') {
622 return Some((time, 0));
623 }
624 let offset_index = value
625 .char_indices()
626 .skip(1)
627 .find_map(|(index, character)| matches!(character, '+' | '-').then_some(index))?;
628 let time = &value[..offset_index];
629 let offset = &value[offset_index..];
630 let sign = if offset.starts_with('+') { 1 } else { -1 };
631 let mut parts = offset[1..].split(':');
632 let hours = parse_i32(parts.next()?)?;
633 let minutes = parse_i32(parts.next()?)?;
634 if parts.next().is_some() || !(0..=23).contains(&hours) || !(0..=59).contains(&minutes) {
635 return None;
636 }
637 Some((time, sign * ((hours * 3_600) + (minutes * 60))))
638}
639
640fn parse_time(value: &str) -> Option<(u32, u32, u32, u32)> {
641 let mut parts = value.split(':');
642 let hour = parse_u32(parts.next()?)?;
643 let minute = parse_u32(parts.next()?)?;
644 let seconds = parts.next()?;
645 if parts.next().is_some() {
646 return None;
647 }
648 let (second_text, fraction) = seconds.split_once('.').unwrap_or((seconds, ""));
649 let second = parse_u32(second_text)?;
650 if hour > 23 || minute > 59 || second > 60 {
651 return None;
652 }
653 Some((hour, minute, second, parse_nanos(fraction)?))
654}
655
656fn parse_nanos(value: &str) -> Option<u32> {
657 if value.is_empty() {
658 return Some(0);
659 }
660 if value.len() > 9 || !value.chars().all(|character| character.is_ascii_digit()) {
661 return None;
662 }
663 let mut nanos = parse_u32(value)?;
664 for _ in value.len()..9 {
665 nanos = nanos.checked_mul(10)?;
666 }
667 Some(nanos)
668}
669
670fn parse_i32(value: &str) -> Option<i32> {
671 if value.is_empty() {
672 return None;
673 }
674 value.parse().ok()
675}
676
677fn parse_u32(value: &str) -> Option<u32> {
678 if value.is_empty() || !value.chars().all(|character| character.is_ascii_digit()) {
679 return None;
680 }
681 value.parse().ok()
682}
683
684fn days_in_month(year: i32, month: u32) -> u32 {
685 match month {
686 1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
687 4 | 6 | 9 | 11 => 30,
688 2 if is_leap_year(year) => 29,
689 2 => 28,
690 _ => 0,
691 }
692}
693
694fn is_leap_year(year: i32) -> bool {
695 (year % 4 == 0 && year % 100 != 0) || year % 400 == 0
696}
697
698fn days_from_civil(year: i32, month: u32, day: u32) -> Option<i64> {
699 let year = i64::from(year) - i64::from((month <= 2) as i32);
700 let era = if year >= 0 { year } else { year - 399 } / 400;
701 let year_of_era = year - era * 400;
702 let month = i64::from(month);
703 let day = i64::from(day);
704 let day_of_year = (153 * (month + if month > 2 { -3 } else { 9 }) + 2) / 5 + day - 1;
705 let day_of_era = year_of_era * 365 + year_of_era / 4 - year_of_era / 100 + day_of_year;
706 era.checked_mul(146_097)?
707 .checked_add(day_of_era)?
708 .checked_sub(719_468)
709}
710
711fn list_paused_runs(
712 receipt_dir: &Path,
713 terminal_ids: &BTreeSet<String>,
714 checkpoints: &[PausedRunCheckpoint],
715) -> Result<Vec<PausedRunSummary>, JournalProjectionError> {
716 let mut summaries = Vec::new();
717 summaries.extend(
718 checkpoints
719 .iter()
720 .filter(|checkpoint| !terminal_ids.contains(checkpoint.id.as_str()))
721 .map(paused_run_from_checkpoint),
722 );
723 let ledgers_dir = receipt_dir.join("ledgers");
724 let entries = match fs::read_dir(&ledgers_dir) {
725 Ok(entries) => entries,
726 Err(error) if error.kind() == ErrorKind::NotFound => return Ok(summaries),
727 Err(_) => return Err(JournalProjectionError::LedgerStoreUnreadable),
728 };
729 for entry in entries {
730 let entry = entry.map_err(|_| JournalProjectionError::LedgerStoreUnreadable)?;
731 let path = entry.path();
732 let Some(run_id) = ledger_run_id(&path) else {
733 continue;
734 };
735 if terminal_ids.contains(run_id.as_str())
736 || summaries.iter().any(|summary| summary.id == run_id)
737 {
738 continue;
739 }
740 if let Some(summary) = paused_run_from_ledger(&run_id, &path)? {
741 summaries.push(summary);
742 }
743 }
744 Ok(summaries)
745}
746
747fn paused_run_from_checkpoint(checkpoint: &PausedRunCheckpoint) -> PausedRunSummary {
748 PausedRunSummary {
749 id: checkpoint.id.clone(),
750 name: checkpoint.name.clone(),
751 kind: checkpoint.kind.clone(),
752 status: "paused".to_owned(),
753 started_at: checkpoint.started_at.clone(),
754 resume_skill_ref: checkpoint.resume_skill_ref.clone(),
755 selected_runner: checkpoint.selected_runner.clone(),
756 step_ids: checkpoint.step_ids.clone(),
757 step_labels: checkpoint.step_labels.clone(),
758 ledger_verification: None,
759 }
760}
761
762fn ledger_run_id(path: &Path) -> Option<String> {
763 if path.extension().and_then(|value| value.to_str()) != Some("jsonl") {
764 return None;
765 }
766 let run_id = path.file_stem()?.to_str()?;
767 if !(run_id.starts_with("rx_") || run_id.starts_with("gx_") || run_id.starts_with("run_"))
768 || !run_id
769 .chars()
770 .all(|character| character.is_ascii_alphanumeric() || matches!(character, '_' | '-'))
771 {
772 return None;
773 }
774 Some(run_id.to_owned())
775}
776
777fn paused_run_from_ledger(
778 run_id: &str,
779 path: &Path,
780) -> Result<Option<PausedRunSummary>, JournalProjectionError> {
781 let contents =
782 fs::read_to_string(path).map_err(|_| JournalProjectionError::LedgerStoreUnreadable)?;
783 let mut events = Vec::new();
784 for (index, line) in contents.lines().enumerate() {
785 let line = line.trim();
786 if line.is_empty() {
787 continue;
788 }
789 let value = match serde_json::from_str::<LedgerLine>(line) {
790 Ok(value) => value,
791 Err(error) => {
792 return Ok(Some(invalid_paused_run(
793 run_id,
794 format!("line {} is not valid JSON: {error}", index + 1),
795 )));
796 }
797 };
798 if let Some(event) = ledger_event(value) {
799 events.push(event);
800 }
801 }
802 Ok(paused_run_from_events(run_id, &events))
803}
804
805#[derive(Clone, Debug, Deserialize)]
806#[serde(untagged)]
807enum LedgerLine {
808 Wrapped { entry: LedgerEntry },
809 Entry(LedgerEntry),
810}
811
812#[derive(Clone, Debug, Deserialize, Serialize)]
813struct LedgerEntry {
814 #[serde(rename = "type")]
815 entry_type: String,
816 data: LedgerEventData,
817 meta: LedgerEventMeta,
818}
819
820#[derive(Clone, Debug, Deserialize, Serialize)]
821struct LedgerEventData {
822 kind: String,
823 #[serde(default)]
824 detail: LedgerEventDetail,
825}
826
827#[derive(Clone, Debug, Default, Deserialize, Serialize)]
828struct LedgerEventDetail {
829 #[serde(default)]
830 resume_skill_ref: Option<String>,
831 #[serde(default)]
832 selected_runner: Option<String>,
833 #[serde(default)]
834 step_ids: Vec<String>,
835 #[serde(default)]
836 step_labels: Vec<String>,
837}
838
839#[derive(Clone, Debug, Deserialize, Serialize)]
840struct LedgerEventMeta {
841 #[serde(default)]
842 created_at: Option<String>,
843 #[serde(default)]
844 producer: Option<LedgerEventProducer>,
845}
846
847#[derive(Clone, Debug, Deserialize, Serialize)]
848struct LedgerEventProducer {
849 #[serde(default)]
850 skill: Option<String>,
851 #[serde(default)]
852 runner: Option<String>,
853}
854
855#[derive(Clone, Debug, PartialEq, Eq)]
856struct LedgerRunEvent {
857 kind: String,
858 created_at: Option<String>,
859 skill_name: Option<String>,
860 runner: Option<String>,
861 resume_skill_ref: Option<String>,
862 selected_runner: Option<String>,
863 step_ids: Vec<String>,
864 step_labels: Vec<String>,
865}
866
867fn ledger_event(value: LedgerLine) -> Option<LedgerRunEvent> {
868 let entry = match value {
869 LedgerLine::Wrapped { entry } | LedgerLine::Entry(entry) => entry,
870 };
871 if entry.entry_type != "run_event" {
872 return None;
873 }
874 let producer = entry.meta.producer;
875 Some(LedgerRunEvent {
876 kind: entry.data.kind,
877 created_at: entry.meta.created_at,
878 skill_name: producer.as_ref().and_then(|value| value.skill.clone()),
879 runner: producer.and_then(|value| value.runner),
880 resume_skill_ref: entry.data.detail.resume_skill_ref,
881 selected_runner: entry.data.detail.selected_runner,
882 step_ids: clean_string_array(entry.data.detail.step_ids),
883 step_labels: clean_string_array(entry.data.detail.step_labels),
884 })
885}
886
887fn paused_run_from_events(run_id: &str, events: &[LedgerRunEvent]) -> Option<PausedRunSummary> {
888 let mut started_at = None;
889 for event in events {
890 if event.kind == "run_started" {
891 started_at = event.created_at.clone();
892 }
893 }
894 for event in events.iter().rev() {
895 if matches!(
896 event.kind.as_str(),
897 "run_completed" | "run_failed" | "graph_completed"
898 ) {
899 return None;
900 }
901 if matches!(
902 event.kind.as_str(),
903 "resolution_requested" | "step_waiting_resolution"
904 ) {
905 return Some(PausedRunSummary {
906 id: run_id.to_owned(),
907 name: event
908 .skill_name
909 .clone()
910 .unwrap_or_else(|| run_id.to_owned()),
911 kind: RUN_KIND.to_owned(),
912 status: "paused".to_owned(),
913 started_at: started_at.or_else(|| event.created_at.clone()),
914 resume_skill_ref: event.resume_skill_ref.clone(),
915 selected_runner: event
916 .selected_runner
917 .clone()
918 .or_else(|| event.runner.clone()),
919 step_ids: event.step_ids.clone(),
920 step_labels: event.step_labels.clone(),
921 ledger_verification: Some(LedgerVerificationProjection {
922 status: "valid".to_owned(),
923 reason: None,
924 }),
925 });
926 }
927 }
928 None
929}
930
931fn invalid_paused_run(run_id: &str, reason: String) -> PausedRunSummary {
932 PausedRunSummary {
933 id: run_id.to_owned(),
934 name: run_id.to_owned(),
935 kind: RUN_KIND.to_owned(),
936 status: "paused".to_owned(),
937 started_at: None,
938 resume_skill_ref: None,
939 selected_runner: None,
940 step_ids: Vec::new(),
941 step_labels: Vec::new(),
942 ledger_verification: Some(LedgerVerificationProjection {
943 status: "invalid".to_owned(),
944 reason: Some(reason),
945 }),
946 }
947}
948
949const RUN_KIND: &str = "runx.receipt.v1";
950
951fn clean_string_array(items: Vec<String>) -> Vec<String> {
952 items
953 .into_iter()
954 .filter(|item| !item.trim().is_empty())
955 .collect()
956}
957
958fn compare_optional_timestamp_desc(
959 left: &Option<String>,
960 right: &Option<String>,
961) -> std::cmp::Ordering {
962 match (
963 left.as_deref().and_then(Timestamp::parse),
964 right.as_deref().and_then(Timestamp::parse),
965 ) {
966 (Some(left), Some(right)) => right.cmp(&left),
967 (Some(_), None) => std::cmp::Ordering::Less,
968 (None, Some(_)) => std::cmp::Ordering::Greater,
969 (None, None) => std::cmp::Ordering::Equal,
970 }
971}
972
973fn subject_state(_kind: &NonEmptyString, disposition: &ClosureDisposition) -> String {
974 if matches!(disposition, ClosureDisposition::Closed) {
975 return "sealed".to_owned();
976 }
977 closure_status(disposition)
978}
979
980fn closure_status(disposition: &ClosureDisposition) -> String {
981 disposition.label().to_owned()
982}