1use std::collections::BTreeMap;
12
13use pointlock_ir::{
14 ActionOutcome, AssertionOutcomeRecord, ErrorInfo, EvidenceRef, FlowIR, JsonPointer,
15 ObservationRecord, PathFrame, RunLogEvent, RunLogPayload, RunPath, SourceMapEntry, StepIR,
16 StepRecord, StepState, StepVerdict, Verdict, parse_run_path, render_parsed_run_path,
17 render_run_path,
18};
19use schemars::JsonSchema;
20use serde::{Deserialize, Serialize};
21use serde_json::Value;
22
23use super::ProjectionVersion;
24use crate::error::StoreError;
25use crate::store::Store;
26
27#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
31#[serde(rename_all = "camelCase", deny_unknown_fields)]
32pub struct AttemptView {
33 #[serde(skip_serializing_if = "Option::is_none")]
35 pub n: Option<u64>,
36 #[serde(skip_serializing_if = "Option::is_none")]
39 pub chain_index: Option<u32>,
40 #[serde(skip_serializing_if = "Option::is_none")]
42 pub channel: Option<String>,
43 #[serde(skip_serializing_if = "Option::is_none")]
45 pub action_name: Option<String>,
46 pub call_id: String,
48 #[serde(skip_serializing_if = "Option::is_none")]
51 pub outcome: Option<String>,
52 #[serde(skip_serializing_if = "Option::is_none")]
54 pub error_class: Option<String>,
55 #[serde(skip_serializing_if = "Option::is_none")]
57 pub execution_mode: Option<String>,
58 #[serde(skip_serializing_if = "Option::is_none")]
60 pub fallback_reason: Option<String>,
61 #[serde(skip_serializing_if = "Option::is_none")]
63 pub error: Option<ErrorInfo>,
64 pub args_snapshot: Value,
66 #[serde(skip_serializing_if = "Option::is_none")]
68 pub started_at_ms: Option<u64>,
69 #[serde(skip_serializing_if = "Option::is_none")]
71 pub finished_at_ms: Option<u64>,
72 pub intent_seq: u64,
74 #[serde(skip_serializing_if = "Option::is_none")]
76 pub settled_seq: Option<u64>,
77 #[serde(default, skip_serializing_if = "Vec::is_empty")]
81 pub evidence: Vec<pointlock_ir::AssetRef>,
82}
83
84#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
87#[serde(rename_all = "camelCase", deny_unknown_fields)]
88pub struct VerdictRecordView {
89 pub seq: u64,
91 pub at_ms: u64,
93 pub verdict: Verdict,
95 #[serde(default, skip_serializing_if = "Vec::is_empty")]
99 pub localized: Vec<EvidenceRef>,
100 #[serde(default, skip_serializing_if = "Vec::is_empty")]
103 pub localization_gaps: Vec<pointlock_ir::EvidenceGap>,
104}
105
106#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
109#[serde(rename_all = "camelCase", deny_unknown_fields)]
110pub struct EvidenceGapView {
111 pub asset: pointlock_ir::AssetRef,
113 pub reason: String,
115 pub seq: u64,
117}
118
119#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
122#[serde(rename_all = "camelCase", deny_unknown_fields)]
123pub struct EvidenceItemView {
124 #[serde(flatten)]
126 pub reference: EvidenceRef,
127 pub source: String,
132}
133
134#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
136#[serde(rename_all = "camelCase", deny_unknown_fields)]
137pub struct HandlerTriggerView {
138 pub hook: String,
140 pub trigger: u64,
142 #[serde(skip_serializing_if = "Option::is_none")]
146 pub disposition: Option<String>,
147 pub seq: u64,
149 pub at_ms: u64,
151}
152
153#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
155#[serde(rename_all = "camelCase", deny_unknown_fields)]
156pub struct SourceLocation {
157 pub ir_path: JsonPointer,
159 pub entry: SourceMapEntry,
161}
162
163#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
165#[serde(rename_all = "camelCase", deny_unknown_fields)]
166pub struct FrameEnvironment {
167 pub inputs_snapshot: Value,
169 pub vars: BTreeMap<String, Value>,
171 #[serde(skip_serializing_if = "Option::is_none")]
175 pub provider_state_summary: Option<pointlock_ir::ProviderStateSummary>,
176}
177
178#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
180#[serde(rename_all = "camelCase", deny_unknown_fields)]
181pub struct StepDossierView {
182 pub projection_version: ProjectionVersion,
184 pub run_id: String,
186 pub run_path: String,
188 pub run_path_frames: RunPath,
190 pub step_id: String,
192 pub effect_hash: String,
194 pub judge_hash: String,
196 #[serde(skip_serializing_if = "Option::is_none")]
199 pub ir_node: Option<StepIR>,
200 #[serde(skip_serializing_if = "Option::is_none")]
202 pub source: Option<SourceLocation>,
203 pub resolved_inputs: Value,
205 pub preflight: Vec<AssertionOutcomeRecord>,
207 pub attempts: Vec<AttemptView>,
209 #[serde(skip_serializing_if = "Option::is_none")]
211 pub output: Option<Value>,
212 pub observations: Vec<ObservationRecord>,
214 pub assertion_outcomes: Vec<AssertionOutcomeRecord>,
216 #[serde(skip_serializing_if = "Option::is_none")]
218 pub verdict: Option<StepVerdict>,
219 pub verdict_history: Vec<VerdictRecordView>,
221 pub handler_triggers: Vec<HandlerTriggerView>,
223 pub evidence: Vec<EvidenceItemView>,
227 #[serde(default, skip_serializing_if = "Vec::is_empty")]
231 pub evidence_gaps: Vec<EvidenceGapView>,
232 #[serde(skip_serializing_if = "Option::is_none")]
238 pub provider_state_summary: Option<pointlock_ir::ProviderStateSummary>,
239 #[serde(skip_serializing_if = "Option::is_none")]
241 pub frame: Option<FrameEnvironment>,
242 #[serde(default, skip_serializing_if = "Option::is_none")]
250 pub execution_life: Option<u32>,
251 #[serde(skip_serializing_if = "Option::is_none")]
253 pub state: Option<StepState>,
254}
255
256fn instance_path(path: &[PathFrame]) -> Vec<PathFrame> {
259 path.iter()
260 .filter(|frame| {
261 !matches!(
262 frame,
263 PathFrame::Attempt { .. } | PathFrame::Phase { .. } | PathFrame::Assertion { .. }
264 )
265 })
266 .cloned()
267 .collect()
268}
269
270fn attempt_of(path: &[PathFrame]) -> Option<u64> {
271 path.iter().rev().find_map(|frame| match frame {
272 PathFrame::Attempt { n } => Some(*n),
273 _ => None,
274 })
275}
276
277fn wire<T: Serialize>(value: &T) -> String {
279 serde_json::to_value(value)
280 .ok()
281 .and_then(|v| v.as_str().map(str::to_owned))
282 .unwrap_or_default()
283}
284
285fn entered_instances(events: &[RunLogEvent]) -> Vec<(String, RunPath)> {
288 let mut seen = Vec::new();
289 let mut keys = std::collections::BTreeSet::new();
290 for event in events {
291 if matches!(event.payload, RunLogPayload::StepEntered { .. }) {
292 let instance = instance_path(&event.run_path);
293 let key = render_run_path(&instance);
294 if keys.insert(key.clone()) {
295 seen.push((key, instance));
296 }
297 }
298 }
299 seen
300}
301
302pub fn locate_step(store: &Store, run_id: &str, step: &str) -> Result<RunPath, StoreError> {
309 let events = store.events(run_id)?;
310 let instances = entered_instances(&events);
311
312 if step.trim_start().starts_with('[') {
314 let frames: RunPath = serde_json::from_str(step).map_err(|err| StoreError::BadRunPath {
315 input: step.to_owned(),
316 message: format!("not a JSON PathFrame[] document: {err}"),
317 })?;
318 let wanted = render_run_path(&instance_path(&frames));
319 return instances
320 .into_iter()
321 .find(|(key, _)| *key == wanted)
322 .map(|(_, path)| path)
323 .ok_or_else(|| StoreError::UnknownStepInstance {
324 run_id: run_id.to_owned(),
325 path: wanted,
326 });
327 }
328
329 if step.contains('/') || step.contains('@') {
330 let parsed = parse_run_path(step).map_err(|err| StoreError::BadRunPath {
331 input: step.to_owned(),
332 message: format!("{} at offset {}", err.message, err.offset),
333 })?;
334 let wanted = render_parsed_run_path(&instance_parsed(&parsed));
338 return instances
339 .into_iter()
340 .find(|(key, _)| *key == wanted)
341 .map(|(_, path)| path)
342 .ok_or_else(|| StoreError::UnknownStepInstance {
343 run_id: run_id.to_owned(),
344 path: step.to_owned(),
345 });
346 }
347
348 let matches: Vec<(String, RunPath)> = instances
349 .into_iter()
350 .filter(|(_, path)| {
351 instance_step_id(path)
352 .map(|step_id| step_id.as_ref() == step)
353 .unwrap_or(false)
354 })
355 .collect();
356 match matches.len() {
357 0 => Err(StoreError::UnknownStepInstance {
358 run_id: run_id.to_owned(),
359 path: step.to_owned(),
360 }),
361 1 => Ok(matches.into_iter().next().expect("len checked").1),
362 _ => Err(StoreError::AmbiguousStep {
363 run_id: run_id.to_owned(),
364 step: step.to_owned(),
365 candidates: matches.into_iter().map(|(key, _)| key).collect(),
366 }),
367 }
368}
369
370fn instance_step_id(path: &[PathFrame]) -> Option<&pointlock_ir::StepId> {
376 path.iter().rev().find_map(|frame| match frame {
377 PathFrame::Step { step_id } => Some(step_id),
378 PathFrame::Call {
379 step_id: Some(step_id),
380 ..
381 } => Some(step_id),
382 _ => None,
383 })
384}
385
386fn instance_parsed(path: &[pointlock_ir::ParsedPathFrame]) -> Vec<pointlock_ir::ParsedPathFrame> {
388 use pointlock_ir::ParsedPathFrame as P;
389 path.iter()
390 .filter(|frame| {
391 !matches!(
392 frame,
393 P::Attempt { .. } | P::Phase { .. } | P::Assertion { .. }
394 )
395 })
396 .cloned()
397 .collect()
398}
399
400pub fn step_dossier(
404 store: &Store,
405 run_id: &str,
406 path: &[PathFrame],
407 artifacts: &[FlowIR],
408) -> Result<StepDossierView, StoreError> {
409 let events = store.events(run_id)?;
410 let instance = instance_path(path);
411 let key = render_run_path(&instance);
412
413 let step_id =
414 instance_step_id(&instance)
415 .cloned()
416 .ok_or_else(|| StoreError::UnknownStepInstance {
417 run_id: run_id.to_owned(),
418 path: key.clone(),
419 })?;
420
421 let mut entered: Option<(String, String, Value)> = None; let mut preflight = Vec::new();
424 let mut attempts: Vec<AttemptView> = Vec::new();
425 let mut by_call: BTreeMap<String, usize> = BTreeMap::new();
426 let mut observations = Vec::new();
427 let mut assertion_outcomes = Vec::new();
428 let mut verdict_history = Vec::new();
429 let mut handler_triggers = Vec::new();
430 let mut evidence: Vec<EvidenceItemView> = Vec::new();
431 let mut evidence_gaps: Vec<EvidenceGapView> = Vec::new();
432 let mut output = None;
433 let mut state: Option<StepState> = None;
434 let mut step_summary: Option<pointlock_ir::ProviderStateSummary> = None;
435 let mut touched = false;
436 let mut lives: u32 = 0;
437
438 for event in &events {
439 if render_run_path(&instance_path(&event.run_path)) != key {
440 continue;
441 }
442 touched = true;
443 match &event.payload {
444 RunLogPayload::StepEntered {
445 effect_hash,
446 judge_hash,
447 resolved_inputs,
448 ..
449 } => {
450 lives += 1;
451 entered = Some((
452 effect_hash.to_string(),
453 judge_hash.to_string(),
454 resolved_inputs.clone(),
455 ));
456 }
457 RunLogPayload::PreflightProbed { outcomes } => {
458 preflight.extend(outcomes.iter().cloned());
459 }
460 RunLogPayload::ActionIntent {
461 call_id,
462 args_snapshot,
463 chain_index,
464 channel,
465 action_name,
466 } => {
467 by_call.insert(call_id.clone(), attempts.len());
468 attempts.push(AttemptView {
469 n: attempt_of(&event.run_path),
470 chain_index: *chain_index,
471 channel: channel.as_ref().map(wire),
472 action_name: action_name.as_ref().map(|name| name.as_str().to_owned()),
473 call_id: call_id.clone(),
474 outcome: None,
475 error_class: None,
476 execution_mode: None,
477 fallback_reason: None,
478 error: None,
479 args_snapshot: args_snapshot.clone(),
480 started_at_ms: None,
481 finished_at_ms: None,
482 intent_seq: event.seq,
483 settled_seq: None,
484 evidence: Vec::new(),
485 });
486 }
487 RunLogPayload::ActionSettled { call_id, outcome } => {
488 if let Some(&index) = by_call.get(call_id) {
489 let attempt = &mut attempts[index];
490 attempt.outcome = Some(outcome.kind().to_owned());
491 if let ActionOutcome::Succeeded { result } = outcome {
492 attempt.evidence = result.evidence.clone();
493 }
494 attempt.settled_seq = Some(event.seq);
495 match outcome {
496 ActionOutcome::Succeeded { result } => {
497 attempt.started_at_ms = Some(result.started_at_ms);
498 attempt.finished_at_ms = Some(result.finished_at_ms);
499 attempt.execution_mode =
500 result.execution.as_ref().map(|execution| match execution {
501 pointlock_ir::ActionExecution::NativeSemantic { .. } => {
502 "nativeSemantic".to_owned()
503 }
504 pointlock_ir::ActionExecution::WebSemantic { .. } => {
505 "webSemantic".to_owned()
506 }
507 pointlock_ir::ActionExecution::CoordinateFallback {
508 fallback_reason,
509 ..
510 } => {
511 attempt.fallback_reason = Some(wire(fallback_reason));
512 "coordinateFallback".to_owned()
513 }
514 });
515 }
516 ActionOutcome::Failed { error }
517 | ActionOutcome::Cancelled { error }
518 | ActionOutcome::TimedOut { error } => {
519 attempt.error = Some(error.clone());
520 }
521 }
522 }
523 }
524 RunLogPayload::ObservationRecorded { observation } => {
525 if let Some(evidence_ref) = &observation.screenshot {
526 evidence.push(EvidenceItemView {
527 reference: evidence_ref.clone(),
528 source: format!("observation:{}/screenshot", observation.observation_id),
529 });
530 }
531 if let Some(evidence_ref) = &observation.ui_snapshot {
532 evidence.push(EvidenceItemView {
533 reference: evidence_ref.clone(),
534 source: format!("observation:{}/uiSnapshot", observation.observation_id),
535 });
536 }
537 observations.push(observation.clone());
538 }
539 RunLogPayload::AssertionEvaluated { outcome } => {
540 assertion_outcomes.push(outcome.clone());
541 }
542 RunLogPayload::VerdictRecorded {
543 verdict,
544 localized,
545 localization_gaps,
546 remote_archival_error: _,
547 } => {
548 for entry in localized {
552 let duplicate = evidence.iter().any(|existing| {
553 existing.reference.sha256 == entry.sha256
554 && existing.reference.asset.id == entry.asset.id
555 });
556 if !duplicate {
557 evidence.push(EvidenceItemView {
558 reference: entry.clone(),
559 source: format!("verdict@seq:{}", event.seq),
560 });
561 }
562 }
563 for gap in localization_gaps {
564 evidence_gaps.push(EvidenceGapView {
565 asset: gap.asset.clone(),
566 reason: gap.reason.clone(),
567 seq: event.seq,
568 });
569 }
570 verdict_history.push(VerdictRecordView {
571 seq: event.seq,
572 at_ms: event.at_ms,
573 verdict: verdict.clone(),
574 localized: localized.clone(),
575 localization_gaps: localization_gaps.clone(),
576 });
577 }
578 RunLogPayload::HandlerTriggered {
579 hook,
580 trigger,
581 disposition,
582 } => {
583 handler_triggers.push(HandlerTriggerView {
584 hook: wire(hook),
585 trigger: *trigger,
586 disposition: disposition.clone(),
587 seq: event.seq,
588 at_ms: event.at_ms,
589 });
590 }
591 RunLogPayload::StepExited {
592 provider_state_summary,
593 state: exit_state,
594 output: exit_output,
595 localized,
596 localization_gaps,
597 } => {
598 state = Some(*exit_state);
599 if exit_output.is_some() {
600 output = exit_output.clone();
601 }
602 if provider_state_summary.is_some() {
603 step_summary = provider_state_summary.clone();
604 }
605 for entry in localized {
608 let duplicate = evidence.iter().any(|existing| {
609 existing.reference.sha256 == entry.sha256
610 && existing.reference.asset.id == entry.asset.id
611 });
612 if !duplicate {
613 evidence.push(EvidenceItemView {
614 reference: entry.clone(),
615 source: format!("exit@seq:{}", event.seq),
616 });
617 }
618 }
619 for gap in localization_gaps {
620 evidence_gaps.push(EvidenceGapView {
621 asset: gap.asset.clone(),
622 reason: gap.reason.clone(),
623 seq: event.seq,
624 });
625 }
626 }
627 _ => {}
628 }
629 }
630
631 if !touched || entered.is_none() {
632 return Err(StoreError::UnknownStepInstance {
633 run_id: run_id.to_owned(),
634 path: key.clone(),
635 });
636 }
637 let (effect_hash, judge_hash, resolved_inputs) = entered.expect("checked above");
638
639 let checkpoint = store.materialized_checkpoint(run_id)?;
641 if let Some((_, view)) = &checkpoint {
642 if let Some(record) = view
643 .completed
644 .iter()
645 .find(|record: &&StepRecord| render_run_path(&record.run_path) == key)
646 {
647 for recorded in &record.attempts {
648 if let Some(&index) = by_call.get(&recorded.call_id) {
649 let attempt = &mut attempts[index];
650 attempt.error_class = recorded.error_class.as_ref().map(wire);
651 if attempt.execution_mode.is_none() {
652 attempt.execution_mode = recorded.execution_mode.as_ref().map(wire);
653 }
654 if attempt.fallback_reason.is_none() {
655 attempt.fallback_reason = recorded.fallback_reason.as_ref().map(wire);
656 }
657 }
658 }
659 }
660 if state.is_none() && render_run_path(&instance_path(&view.frontier.run_path)) == key {
661 state = Some(view.frontier.state);
662 }
663 }
664
665 let verdict = verdict_history.last().map(|record| StepVerdict {
667 status: record.verdict.status,
668 degraded: record.verdict.degraded,
669 supersedes: record.verdict.supersedes.clone(),
670 });
671
672 let scan = match instance.last() {
678 Some(PathFrame::Call { .. }) => &instance[..instance.len() - 1],
679 _ => instance.as_slice(),
680 };
681 let governing_hash = scan.iter().rev().find_map(|frame| match frame {
682 PathFrame::Flow { ir_hash, .. } => Some(ir_hash.clone()),
683 PathFrame::Call { callee_ir_hash, .. } => Some(callee_ir_hash.clone()),
684 _ => None,
685 });
686 let mut ir_node = None;
687 let mut source = None;
688 if let Some(hash) = governing_hash
689 && let Some(flow) = artifacts.iter().find(|flow| flow.ir_hash == hash)
690 && let Some((node, pointer)) = find_step(&flow.body, "/body", step_id.as_ref())
691 {
692 source = flow
693 .source_map
694 .iter()
695 .find(|entry| entry.ir_path.as_ref() == pointer)
696 .map(|entry| SourceLocation {
697 ir_path: entry.ir_path.clone(),
698 entry: entry.clone(),
699 });
700 ir_node = Some(node.clone());
701 }
702
703 let frame = checkpoint
710 .as_ref()
711 .and_then(|(_, view)| frame_environment(&instance, &view.frames))
712 .map(|mut environment| {
713 environment.provider_state_summary = step_summary.clone();
714 environment
715 });
716
717 Ok(StepDossierView {
718 projection_version: ProjectionVersion,
719 run_id: run_id.to_owned(),
720 run_path: key,
721 run_path_frames: instance,
722 step_id: step_id.to_string(),
723 effect_hash,
724 judge_hash,
725 ir_node,
726 source,
727 resolved_inputs,
728 preflight,
729 attempts,
730 output,
731 observations,
732 assertion_outcomes,
733 verdict,
734 verdict_history,
735 handler_triggers,
736 evidence,
737 evidence_gaps,
738 provider_state_summary: step_summary,
739 frame,
740 state,
741 execution_life: (lives > 1).then_some(lives),
742 })
743}
744
745fn frame_environment(
752 instance: &[PathFrame],
753 frames: &[pointlock_ir::CallFrame],
754) -> Option<FrameEnvironment> {
755 let mut level = 0usize;
756 for (index, path_frame) in instance.iter().enumerate() {
757 match path_frame {
758 PathFrame::Flow { ir_hash, flow_id } => {
759 let frame = frames.first()?;
760 if frame.ir_hash != *ir_hash
761 || frame.flow_id != *flow_id
762 || frame.call_step_id.is_some()
763 {
764 return None;
765 }
766 }
767 PathFrame::Call {
768 step_id,
769 callee_flow_id,
770 callee_ir_hash,
771 } => {
772 if index + 1 == instance.len() {
776 break;
777 }
778 level += 1;
779 let frame = frames.get(level)?;
780 if frame.call_step_id != *step_id
781 || frame.flow_id != *callee_flow_id
782 || frame.ir_hash != *callee_ir_hash
783 {
784 return None;
785 }
786 }
787 _ => {}
788 }
789 }
790 frames.get(level).map(|frame| FrameEnvironment {
791 inputs_snapshot: frame.inputs_snapshot.clone(),
792 vars: frame.vars.clone(),
793 provider_state_summary: None,
794 })
795}
796
797fn find_step<'a>(body: &'a [StepIR], prefix: &str, step_id: &str) -> Option<(&'a StepIR, String)> {
800 for (index, step) in body.iter().enumerate() {
801 let pointer = format!("{prefix}/{index}");
802 if step.step_id().as_ref() == step_id {
803 return Some((step, pointer));
804 }
805 match step {
806 StepIR::If(nested) => {
807 if let Some(found) = find_step(&nested.then, &format!("{pointer}/then"), step_id) {
808 return Some(found);
809 }
810 if let Some(else_body) = nested.r#else.as_deref()
811 && let Some(found) = find_step(else_body, &format!("{pointer}/else"), step_id)
812 {
813 return Some(found);
814 }
815 }
816 StepIR::Foreach(nested) => {
817 if let Some(found) = find_step(&nested.body, &format!("{pointer}/body"), step_id) {
818 return Some(found);
819 }
820 }
821 _ => {}
822 }
823 }
824 None
825}