Skip to main content

runx_runtime/
journal.rs

1// rust-style-allow: large-file because the initial journal projection slice
2// keeps history filtering and receipt-backed rows together until CLI wiring
3// decides the permanent module boundary.
4use 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]
309// rust-style-allow: long-function because this projection assembles one sealed
310// receipt into a deterministic row set; splitting it before CLI and
311// paused-run sources land would obscure the ordering invariants.
312pub fn project_receipt_journal(receipt: &Receipt) -> JournalProjection {
313    project_receipt_journal_with_policy(receipt, RuntimeReceiptSignaturePolicy::local_development())
314}
315
316#[must_use]
317// rust-style-allow: long-function because this projection assembles one sealed
318// receipt into a deterministic row set; splitting it before CLI and
319// paused-run sources land would obscure the ordering invariants.
320pub 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    // The decision -> act-id integrity property is checked inline against
496    // `acts[]` by `verify_receipt`; no journal indirection remains.
497    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}