1use pointlock_ir::{
10 ActionOutcome, HandlerHook, RunLogEvent, RunLogPayload, StepState, render_run_path,
11};
12use schemars::JsonSchema;
13use serde::{Deserialize, Serialize};
14use serde_json::Value;
15
16use super::ProjectionVersion;
17use crate::error::StoreError;
18use crate::store::Store;
19
20pub const TIMELINE_MAX_PAGE_SIZE: u32 = 50;
22pub const TIMELINE_TEXT_MAX_BYTES: usize = 4 * 1024;
24pub const TIMELINE_JSON_MAX_BYTES: usize = 16 * 1024;
26pub const TIMELINE_JSON_MAX_DEPTH: usize = 12;
28pub const TIMELINE_EVIDENCE_MAX: usize = 32;
30
31#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
34#[serde(rename_all = "camelCase")]
35pub enum RunTimelineFilter {
36 All,
38 Observations,
40 Actions,
42 Errors,
44 Verdicts,
46}
47
48impl RunTimelineFilter {
49 pub fn admits(self, payload: &RunLogPayload) -> bool {
52 match self {
53 RunTimelineFilter::All => true,
54 RunTimelineFilter::Observations => {
55 matches!(payload, RunLogPayload::ObservationRecorded { .. })
56 }
57 RunTimelineFilter::Actions => matches!(
58 payload,
59 RunLogPayload::ActionIntent { .. } | RunLogPayload::ActionSettled { .. }
60 ),
61 RunTimelineFilter::Errors => match payload {
62 RunLogPayload::ActionSettled { outcome, .. } => {
63 !matches!(outcome, ActionOutcome::Succeeded { .. })
64 }
65 RunLogPayload::HandlerTriggered { hook, .. } => *hook == HandlerHook::OnError,
66 _ => false,
67 },
68 RunTimelineFilter::Verdicts => matches!(
69 payload,
70 RunLogPayload::PreflightProbed { .. }
71 | RunLogPayload::AssertionEvaluated { .. }
72 | RunLogPayload::VerdictRecorded { .. }
73 ),
74 }
75 }
76}
77
78#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
81#[serde(rename_all = "camelCase", deny_unknown_fields)]
82pub struct TimelineEvidenceRef {
83 pub id: String,
85 pub media_type: String,
87 pub sha256: String,
89}
90
91#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
94#[serde(rename_all = "camelCase", deny_unknown_fields)]
95pub struct TimelineErrorView {
96 #[serde(skip_serializing_if = "Option::is_none")]
98 pub error_class: Option<String>,
99 pub code: String,
101 pub message: String,
103 pub retryable: bool,
105}
106
107#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
111#[serde(tag = "type", rename_all = "camelCase")]
112pub enum TimelineDetail {
113 #[serde(rename_all = "camelCase")]
115 RunStarted {
116 supervise_policy: Option<String>,
118 },
119 #[serde(rename_all = "camelCase")]
121 StepEntered {
122 step_id: String,
124 },
125 #[serde(rename_all = "camelCase")]
127 PreflightProbed {
128 pass: u32,
130 fail: u32,
132 unknown: u32,
134 #[serde(default, skip_serializing_if = "core::ops::Not::not")]
140 unprobed: bool,
141 },
142 #[serde(rename_all = "camelCase")]
144 ActionIntent {
145 call_id: String,
147 args: BoundedValue,
149 },
150 #[serde(rename_all = "camelCase")]
152 ActionSettled {
153 call_id: String,
155 outcome: String,
157 #[serde(skip_serializing_if = "Option::is_none")]
160 execution_mode: Option<String>,
161 #[serde(skip_serializing_if = "Option::is_none")]
163 fallback_reason: Option<String>,
164 #[serde(skip_serializing_if = "Option::is_none")]
166 error: Option<TimelineErrorView>,
167 },
168 #[serde(rename_all = "camelCase")]
170 ObservationRecorded {
171 observation_id: String,
173 captured_at_ms: u64,
175 #[serde(skip_serializing_if = "Option::is_none")]
177 screenshot_omission: Option<String>,
178 #[serde(skip_serializing_if = "Option::is_none")]
180 ui_snapshot_omission: Option<String>,
181 },
182 #[serde(rename_all = "camelCase")]
184 AssertionEvaluated {
185 assert_id: String,
187 result: String,
189 #[serde(skip_serializing_if = "Option::is_none")]
191 channel: Option<String>,
192 reason: String,
194 },
195 #[serde(rename_all = "camelCase")]
197 VerdictRecorded {
198 status: String,
200 degraded: bool,
202 #[serde(skip_serializing_if = "Option::is_none")]
204 supersedes: Option<String>,
205 summary: String,
207 #[serde(skip_serializing_if = "Option::is_none")]
210 remote_archival_error: Option<String>,
211 },
212 #[serde(rename_all = "camelCase")]
214 StepExited {
215 state: StepState,
217 },
218 #[serde(rename_all = "camelCase")]
220 CallFramePushed {
221 callee: String,
223 #[serde(default, skip_serializing_if = "core::ops::Not::not")]
227 rebase: bool,
228 },
229 #[serde(rename_all = "camelCase")]
231 CallFramePopped {
232 has_outputs: bool,
234 },
235 #[serde(rename_all = "camelCase")]
237 HandlerTriggered {
238 hook: String,
240 trigger: u64,
242 #[serde(skip_serializing_if = "Option::is_none")]
244 disposition: Option<String>,
245 },
246 #[serde(rename_all = "camelCase")]
248 HumanRequested {
249 request_id: String,
251 purpose: String,
253 #[serde(skip_serializing_if = "Option::is_none")]
255 mode: Option<String>,
256 prompt: String,
258 },
259 #[serde(rename_all = "camelCase")]
261 HumanResponded {
262 request_id: String,
264 purpose: String,
266 actor: String,
268 response: BoundedValue,
270 },
271 #[serde(rename_all = "camelCase")]
273 RunSuspended {
274 reason: Option<String>,
276 },
277 #[serde(rename_all = "camelCase")]
279 RunResumed {
280 alignment: BoundedValue,
282 supervise_policy: Option<String>,
285 },
286 #[serde(rename_all = "camelCase")]
288 RunFinished {
289 #[serde(skip_serializing_if = "Option::is_none")]
291 status: Option<String>,
292 #[serde(skip_serializing_if = "Option::is_none")]
294 degraded: Option<bool>,
295 #[serde(skip_serializing_if = "Option::is_none")]
298 remote_archival_error: Option<String>,
299 },
300 #[serde(rename_all = "camelCase")]
304 OverLimit {
305 event_type: String,
307 },
308}
309
310#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
312#[serde(rename_all = "camelCase", deny_unknown_fields)]
313pub struct BoundedValue {
314 pub value: Value,
316 pub truncated: bool,
318}
319
320#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
322#[serde(rename_all = "camelCase", deny_unknown_fields)]
323pub struct RunTimelineEntry {
324 pub seq: u64,
326 pub at_ms: u64,
328 pub run_path: String,
330 pub detail: TimelineDetail,
332 pub evidence: Vec<TimelineEvidenceRef>,
334 pub evidence_omitted: u32,
336 pub truncated: bool,
338}
339
340#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
343#[serde(rename_all = "camelCase", deny_unknown_fields)]
344pub struct TimelinePage {
345 pub projection_version: ProjectionVersion,
347 pub run_id: String,
349 pub filter: RunTimelineFilter,
351 pub page: u32,
353 pub page_size: u32,
355 pub revision: u64,
357 pub total: u64,
359 pub entries: Vec<RunTimelineEntry>,
361}
362
363pub fn timeline_page(
366 store: &Store,
367 run_id: &str,
368 filter: RunTimelineFilter,
369 page: u32,
370 page_size: u32,
371) -> Result<TimelinePage, StoreError> {
372 let events = store.events(run_id)?;
373 let revision = events.last().map(|event| event.seq).unwrap_or(0);
374
375 let mut error_classes: std::collections::BTreeMap<String, String> =
379 std::collections::BTreeMap::new();
380 if let Some((_, view)) = store.materialized_checkpoint(run_id)? {
381 for record in &view.completed {
382 for attempt in &record.attempts {
383 if let Some(class) = &attempt.error_class {
384 error_classes.insert(attempt.call_id.clone(), wire(class));
385 }
386 }
387 }
388 }
389
390 let admitted: Vec<&RunLogEvent> = events
391 .iter()
392 .filter(|event| filter.admits(&event.payload))
393 .collect();
394 let page = page.max(1);
395 let page_size = page_size.clamp(1, TIMELINE_MAX_PAGE_SIZE);
396 let start = (page as usize - 1).saturating_mul(page_size as usize);
397 let entries = admitted
398 .iter()
399 .skip(start)
400 .take(page_size as usize)
401 .map(|event| entry_of(event, &error_classes))
402 .collect();
403 Ok(TimelinePage {
404 projection_version: ProjectionVersion,
405 run_id: run_id.to_owned(),
406 filter,
407 page,
408 page_size,
409 revision,
410 total: admitted.len() as u64,
411 entries,
412 })
413}
414
415fn wire<T: Serialize>(value: &T) -> String {
417 serde_json::to_value(value)
418 .ok()
419 .and_then(|v| v.as_str().map(str::to_owned))
420 .unwrap_or_default()
421}
422
423fn bound_text(text: &str) -> (String, bool) {
425 if text.len() <= TIMELINE_TEXT_MAX_BYTES {
426 return (text.to_owned(), false);
427 }
428 let mut cut = TIMELINE_TEXT_MAX_BYTES;
429 while !text.is_char_boundary(cut) {
430 cut -= 1;
431 }
432 (text[..cut].to_owned(), true)
433}
434
435fn bound_depth(value: &Value, depth: usize, truncated: &mut bool) -> Value {
438 if depth == 0 {
439 *truncated = true;
440 return Value::String("…depth truncated".to_owned());
441 }
442 match value {
443 Value::Object(map) => Value::Object(
444 map.iter()
445 .map(|(k, v)| (k.clone(), bound_depth(v, depth - 1, truncated)))
446 .collect(),
447 ),
448 Value::Array(items) => Value::Array(
449 items
450 .iter()
451 .map(|v| bound_depth(v, depth - 1, truncated))
452 .collect(),
453 ),
454 Value::String(text) => {
455 let (bounded, cut) = bound_text(text);
456 if cut {
457 *truncated = true;
458 }
459 Value::String(bounded)
460 }
461 other => other.clone(),
462 }
463}
464
465fn bound_value(value: &Value) -> BoundedValue {
469 let mut truncated = false;
470 let bounded = bound_depth(value, TIMELINE_JSON_MAX_DEPTH, &mut truncated);
471 let size = serde_json::to_string(&bounded)
472 .map(|s| s.len())
473 .unwrap_or(0);
474 if size > TIMELINE_JSON_MAX_BYTES {
475 return BoundedValue {
476 value: Value::String("…over 16 KiB, see the step dossier".to_owned()),
477 truncated: true,
478 };
479 }
480 BoundedValue {
481 value: bounded,
482 truncated,
483 }
484}
485
486fn entry_of(
489 event: &RunLogEvent,
490 error_classes: &std::collections::BTreeMap<String, String>,
491) -> RunTimelineEntry {
492 let mut truncated = false;
493 let mut evidence: Vec<TimelineEvidenceRef> = Vec::new();
494 let mut evidence_omitted = 0u32;
495
496 let detail = match &event.payload {
497 RunLogPayload::RunStarted {
498 supervise_policy, ..
499 } => TimelineDetail::RunStarted {
500 supervise_policy: supervise_policy.as_ref().map(wire),
501 },
502 RunLogPayload::StepEntered { step_id, .. } => TimelineDetail::StepEntered {
503 step_id: step_id.to_string(),
504 },
505 RunLogPayload::PreflightProbed { outcomes } => {
506 let count = |status: pointlock_ir::VerdictStatus| {
507 outcomes.iter().filter(|o| o.result == status).count() as u32
508 };
509 TimelineDetail::PreflightProbed {
510 pass: count(pointlock_ir::VerdictStatus::Pass),
511 fail: count(pointlock_ir::VerdictStatus::Fail),
512 unknown: count(pointlock_ir::VerdictStatus::Unknown),
513 unprobed: outcomes.is_empty(),
517 }
518 }
519 RunLogPayload::ActionIntent {
520 call_id,
521 args_snapshot,
522 ..
523 } => {
524 let args = bound_value(args_snapshot);
525 truncated |= args.truncated;
526 TimelineDetail::ActionIntent {
527 call_id: call_id.clone(),
528 args,
529 }
530 }
531 RunLogPayload::ActionSettled { call_id, outcome } => {
532 let (execution_mode, fallback_reason) = match outcome {
533 ActionOutcome::Succeeded { result } => {
534 for asset in &result.evidence {
535 push_evidence(asset, &mut evidence, &mut evidence_omitted);
536 }
537 match result.execution.as_ref() {
538 Some(pointlock_ir::ActionExecution::NativeSemantic { .. }) => {
539 (Some("nativeSemantic".to_owned()), None)
540 }
541 Some(pointlock_ir::ActionExecution::WebSemantic { .. }) => {
542 (Some("webSemantic".to_owned()), None)
543 }
544 Some(pointlock_ir::ActionExecution::CoordinateFallback {
545 fallback_reason,
546 ..
547 }) => (
548 Some("coordinateFallback".to_owned()),
549 Some(wire(fallback_reason)),
550 ),
551 None => (None, None),
552 }
553 }
554 _ => (None, None),
555 };
556 let error = match outcome {
557 ActionOutcome::Succeeded { .. } => None,
558 ActionOutcome::Failed { error }
559 | ActionOutcome::Cancelled { error }
560 | ActionOutcome::TimedOut { error } => {
561 let (message, cut) = bound_text(&error.message);
562 truncated |= cut;
563 Some(TimelineErrorView {
564 error_class: error_classes.get(call_id).cloned(),
565 code: error.code.clone(),
566 message,
567 retryable: error.retryable,
568 })
569 }
570 };
571 TimelineDetail::ActionSettled {
572 call_id: call_id.clone(),
573 outcome: outcome.kind().to_owned(),
574 execution_mode,
575 fallback_reason,
576 error,
577 }
578 }
579 RunLogPayload::ObservationRecorded { observation } => {
580 if let Some(evidence_ref) = &observation.screenshot {
581 push_evidence(&evidence_ref.asset, &mut evidence, &mut evidence_omitted);
582 }
583 if let Some(evidence_ref) = &observation.ui_snapshot {
584 push_evidence(&evidence_ref.asset, &mut evidence, &mut evidence_omitted);
585 }
586 TimelineDetail::ObservationRecorded {
587 observation_id: observation.observation_id.clone(),
588 captured_at_ms: observation.captured_at_ms,
589 screenshot_omission: observation.screenshot_omission.as_ref().map(wire),
590 ui_snapshot_omission: observation.ui_snapshot_omission.as_ref().map(wire),
591 }
592 }
593 RunLogPayload::AssertionEvaluated { outcome } => {
594 let (reason, cut) = bound_text(&outcome.reason);
595 truncated |= cut;
596 TimelineDetail::AssertionEvaluated {
597 assert_id: outcome.assert_id.to_string(),
598 result: wire(&outcome.result),
599 channel: outcome.channel.as_ref().map(wire),
600 reason,
601 }
602 }
603 RunLogPayload::VerdictRecorded {
604 verdict,
605 remote_archival_error,
606 ..
607 } => {
608 let (summary, cut) = bound_text(&verdict.summary);
609 truncated |= cut;
610 for asset in &verdict.evidence {
611 push_evidence(asset, &mut evidence, &mut evidence_omitted);
612 }
613 let remote_archival_error = remote_archival_error.as_deref().map(|error| {
614 let (bounded, cut) = bound_text(error);
615 truncated |= cut;
616 bounded
617 });
618 TimelineDetail::VerdictRecorded {
619 status: wire(&verdict.status),
620 degraded: verdict.degraded,
621 supersedes: verdict.supersedes.clone(),
622 summary,
623 remote_archival_error,
624 }
625 }
626 RunLogPayload::StepExited { state, .. } => TimelineDetail::StepExited { state: *state },
627 RunLogPayload::CallFramePushed { frame, rebase } => TimelineDetail::CallFramePushed {
628 callee: format!("{}@{}", frame.flow_id, frame.ir_hash),
629 rebase: *rebase,
630 },
631 RunLogPayload::CallFramePopped { outputs } => TimelineDetail::CallFramePopped {
632 has_outputs: outputs.is_some(),
633 },
634 RunLogPayload::HandlerTriggered {
635 hook,
636 trigger,
637 disposition,
638 } => TimelineDetail::HandlerTriggered {
639 hook: wire(hook),
640 trigger: *trigger,
641 disposition: disposition.clone(),
642 },
643 RunLogPayload::HumanRequested {
644 request_id,
645 purpose,
646 mode,
647 prompt,
648 ..
649 } => {
650 let (prompt, cut) = bound_text(prompt);
651 truncated |= cut;
652 TimelineDetail::HumanRequested {
653 request_id: request_id.clone(),
654 purpose: wire(purpose),
655 mode: mode.as_ref().map(wire),
656 prompt,
657 }
658 }
659 RunLogPayload::HumanResponded {
660 request_id,
661 purpose,
662 response,
663 actor,
664 } => {
665 let response = bound_value(response);
666 truncated |= response.truncated;
667 TimelineDetail::HumanResponded {
668 request_id: request_id.clone(),
669 purpose: wire(purpose),
670 actor: actor.clone(),
671 response,
672 }
673 }
674 RunLogPayload::RunSuspended { reason, .. } => TimelineDetail::RunSuspended {
675 reason: reason.clone(),
676 },
677 RunLogPayload::RunResumed {
678 alignment_report,
679 supervise_policy,
680 ..
681 } => {
682 let counts = serde_json::json!({
683 "reusable": count_class(alignment_report, pointlock_ir::AlignmentClass::Reusable),
684 "judgeDirty": count_class(alignment_report, pointlock_ir::AlignmentClass::JudgeDirty),
685 "effectDirty": count_class(alignment_report, pointlock_ir::AlignmentClass::EffectDirty),
686 "new": count_class(alignment_report, pointlock_ir::AlignmentClass::New),
687 "orphaned": count_class(alignment_report, pointlock_ir::AlignmentClass::Orphaned),
688 });
689 TimelineDetail::RunResumed {
690 alignment: bound_value(&counts),
691 supervise_policy: supervise_policy.as_ref().map(wire),
692 }
693 }
694 RunLogPayload::RunFinished {
695 verdict,
696 remote_archival_error,
697 } => {
698 let remote_archival_error = remote_archival_error.as_deref().map(|error| {
699 let (bounded, cut) = bound_text(error);
700 truncated |= cut;
701 bounded
702 });
703 TimelineDetail::RunFinished {
704 status: verdict.as_ref().map(|v| wire(&v.status)),
705 degraded: verdict.as_ref().map(|v| v.degraded),
706 remote_archival_error,
707 }
708 }
709 };
710
711 let serialized = serde_json::to_string(&detail).map(|s| s.len()).unwrap_or(0);
714 let detail = if serialized > TIMELINE_JSON_MAX_BYTES {
715 truncated = true;
716 TimelineDetail::OverLimit {
717 event_type: event.payload.event_type().to_owned(),
718 }
719 } else {
720 detail
721 };
722
723 RunTimelineEntry {
724 seq: event.seq,
725 at_ms: event.at_ms,
726 run_path: render_run_path(&event.run_path),
727 detail,
728 evidence,
729 evidence_omitted,
730 truncated,
731 }
732}
733
734fn count_class(
735 report: &pointlock_ir::AlignmentReport,
736 class: pointlock_ir::AlignmentClass,
737) -> usize {
738 report
739 .entries
740 .iter()
741 .filter(|entry| entry.class == class)
742 .count()
743}
744
745fn push_evidence(
746 asset: &pointlock_ir::AssetRef,
747 evidence: &mut Vec<TimelineEvidenceRef>,
748 omitted: &mut u32,
749) {
750 if evidence.len() >= TIMELINE_EVIDENCE_MAX {
751 *omitted += 1;
752 return;
753 }
754 evidence.push(TimelineEvidenceRef {
755 id: asset.id.clone(),
756 media_type: asset.media_type.clone(),
757 sha256: asset.sha256.clone().unwrap_or_default(),
758 });
759}