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(skip_serializing_if = "Option::is_none")]
244 pub state: Option<StepState>,
245}
246
247fn instance_path(path: &[PathFrame]) -> Vec<PathFrame> {
250 path.iter()
251 .filter(|frame| {
252 !matches!(
253 frame,
254 PathFrame::Attempt { .. } | PathFrame::Phase { .. } | PathFrame::Assertion { .. }
255 )
256 })
257 .cloned()
258 .collect()
259}
260
261fn attempt_of(path: &[PathFrame]) -> Option<u64> {
262 path.iter().rev().find_map(|frame| match frame {
263 PathFrame::Attempt { n } => Some(*n),
264 _ => None,
265 })
266}
267
268fn wire<T: Serialize>(value: &T) -> String {
270 serde_json::to_value(value)
271 .ok()
272 .and_then(|v| v.as_str().map(str::to_owned))
273 .unwrap_or_default()
274}
275
276fn entered_instances(events: &[RunLogEvent]) -> Vec<(String, RunPath)> {
279 let mut seen = Vec::new();
280 let mut keys = std::collections::BTreeSet::new();
281 for event in events {
282 if matches!(event.payload, RunLogPayload::StepEntered { .. }) {
283 let instance = instance_path(&event.run_path);
284 let key = render_run_path(&instance);
285 if keys.insert(key.clone()) {
286 seen.push((key, instance));
287 }
288 }
289 }
290 seen
291}
292
293pub fn locate_step(store: &Store, run_id: &str, step: &str) -> Result<RunPath, StoreError> {
300 let events = store.events(run_id)?;
301 let instances = entered_instances(&events);
302
303 if step.trim_start().starts_with('[') {
305 let frames: RunPath = serde_json::from_str(step).map_err(|err| StoreError::BadRunPath {
306 input: step.to_owned(),
307 message: format!("not a JSON PathFrame[] document: {err}"),
308 })?;
309 let wanted = render_run_path(&instance_path(&frames));
310 return instances
311 .into_iter()
312 .find(|(key, _)| *key == wanted)
313 .map(|(_, path)| path)
314 .ok_or_else(|| StoreError::UnknownStepInstance {
315 run_id: run_id.to_owned(),
316 path: wanted,
317 });
318 }
319
320 if step.contains('/') || step.contains('@') {
321 let parsed = parse_run_path(step).map_err(|err| StoreError::BadRunPath {
322 input: step.to_owned(),
323 message: format!("{} at offset {}", err.message, err.offset),
324 })?;
325 let wanted = render_parsed_run_path(&instance_parsed(&parsed));
329 return instances
330 .into_iter()
331 .find(|(key, _)| *key == wanted)
332 .map(|(_, path)| path)
333 .ok_or_else(|| StoreError::UnknownStepInstance {
334 run_id: run_id.to_owned(),
335 path: step.to_owned(),
336 });
337 }
338
339 let matches: Vec<(String, RunPath)> = instances
340 .into_iter()
341 .filter(|(_, path)| {
342 instance_step_id(path)
343 .map(|step_id| step_id.as_ref() == step)
344 .unwrap_or(false)
345 })
346 .collect();
347 match matches.len() {
348 0 => Err(StoreError::UnknownStepInstance {
349 run_id: run_id.to_owned(),
350 path: step.to_owned(),
351 }),
352 1 => Ok(matches.into_iter().next().expect("len checked").1),
353 _ => Err(StoreError::AmbiguousStep {
354 run_id: run_id.to_owned(),
355 step: step.to_owned(),
356 candidates: matches.into_iter().map(|(key, _)| key).collect(),
357 }),
358 }
359}
360
361fn instance_step_id(path: &[PathFrame]) -> Option<&pointlock_ir::StepId> {
367 path.iter().rev().find_map(|frame| match frame {
368 PathFrame::Step { step_id } => Some(step_id),
369 PathFrame::Call {
370 step_id: Some(step_id),
371 ..
372 } => Some(step_id),
373 _ => None,
374 })
375}
376
377fn instance_parsed(path: &[pointlock_ir::ParsedPathFrame]) -> Vec<pointlock_ir::ParsedPathFrame> {
379 use pointlock_ir::ParsedPathFrame as P;
380 path.iter()
381 .filter(|frame| {
382 !matches!(
383 frame,
384 P::Attempt { .. } | P::Phase { .. } | P::Assertion { .. }
385 )
386 })
387 .cloned()
388 .collect()
389}
390
391pub fn step_dossier(
395 store: &Store,
396 run_id: &str,
397 path: &[PathFrame],
398 artifacts: &[FlowIR],
399) -> Result<StepDossierView, StoreError> {
400 let events = store.events(run_id)?;
401 let instance = instance_path(path);
402 let key = render_run_path(&instance);
403
404 let step_id =
405 instance_step_id(&instance)
406 .cloned()
407 .ok_or_else(|| StoreError::UnknownStepInstance {
408 run_id: run_id.to_owned(),
409 path: key.clone(),
410 })?;
411
412 let mut entered: Option<(String, String, Value)> = None; let mut preflight = Vec::new();
415 let mut attempts: Vec<AttemptView> = Vec::new();
416 let mut by_call: BTreeMap<String, usize> = BTreeMap::new();
417 let mut observations = Vec::new();
418 let mut assertion_outcomes = Vec::new();
419 let mut verdict_history = Vec::new();
420 let mut handler_triggers = Vec::new();
421 let mut evidence: Vec<EvidenceItemView> = Vec::new();
422 let mut evidence_gaps: Vec<EvidenceGapView> = Vec::new();
423 let mut output = None;
424 let mut state: Option<StepState> = None;
425 let mut step_summary: Option<pointlock_ir::ProviderStateSummary> = None;
426 let mut touched = false;
427
428 for event in &events {
429 if render_run_path(&instance_path(&event.run_path)) != key {
430 continue;
431 }
432 touched = true;
433 match &event.payload {
434 RunLogPayload::StepEntered {
435 effect_hash,
436 judge_hash,
437 resolved_inputs,
438 ..
439 } => {
440 entered = Some((
441 effect_hash.to_string(),
442 judge_hash.to_string(),
443 resolved_inputs.clone(),
444 ));
445 }
446 RunLogPayload::PreflightProbed { outcomes } => {
447 preflight.extend(outcomes.iter().cloned());
448 }
449 RunLogPayload::ActionIntent {
450 call_id,
451 args_snapshot,
452 chain_index,
453 channel,
454 action_name,
455 } => {
456 by_call.insert(call_id.clone(), attempts.len());
457 attempts.push(AttemptView {
458 n: attempt_of(&event.run_path),
459 chain_index: *chain_index,
460 channel: channel.as_ref().map(wire),
461 action_name: action_name.as_ref().map(|name| name.as_str().to_owned()),
462 call_id: call_id.clone(),
463 outcome: None,
464 error_class: None,
465 execution_mode: None,
466 fallback_reason: None,
467 error: None,
468 args_snapshot: args_snapshot.clone(),
469 started_at_ms: None,
470 finished_at_ms: None,
471 intent_seq: event.seq,
472 settled_seq: None,
473 evidence: Vec::new(),
474 });
475 }
476 RunLogPayload::ActionSettled { call_id, outcome } => {
477 if let Some(&index) = by_call.get(call_id) {
478 let attempt = &mut attempts[index];
479 attempt.outcome = Some(outcome.kind().to_owned());
480 if let ActionOutcome::Succeeded { result } = outcome {
481 attempt.evidence = result.evidence.clone();
482 }
483 attempt.settled_seq = Some(event.seq);
484 match outcome {
485 ActionOutcome::Succeeded { result } => {
486 attempt.started_at_ms = Some(result.started_at_ms);
487 attempt.finished_at_ms = Some(result.finished_at_ms);
488 attempt.execution_mode =
489 result.execution.as_ref().map(|execution| match execution {
490 pointlock_ir::ActionExecution::NativeSemantic { .. } => {
491 "nativeSemantic".to_owned()
492 }
493 pointlock_ir::ActionExecution::WebSemantic { .. } => {
494 "webSemantic".to_owned()
495 }
496 pointlock_ir::ActionExecution::CoordinateFallback {
497 fallback_reason,
498 ..
499 } => {
500 attempt.fallback_reason = Some(wire(fallback_reason));
501 "coordinateFallback".to_owned()
502 }
503 });
504 }
505 ActionOutcome::Failed { error }
506 | ActionOutcome::Cancelled { error }
507 | ActionOutcome::TimedOut { error } => {
508 attempt.error = Some(error.clone());
509 }
510 }
511 }
512 }
513 RunLogPayload::ObservationRecorded { observation } => {
514 if let Some(evidence_ref) = &observation.screenshot {
515 evidence.push(EvidenceItemView {
516 reference: evidence_ref.clone(),
517 source: format!("observation:{}/screenshot", observation.observation_id),
518 });
519 }
520 if let Some(evidence_ref) = &observation.ui_snapshot {
521 evidence.push(EvidenceItemView {
522 reference: evidence_ref.clone(),
523 source: format!("observation:{}/uiSnapshot", observation.observation_id),
524 });
525 }
526 observations.push(observation.clone());
527 }
528 RunLogPayload::AssertionEvaluated { outcome } => {
529 assertion_outcomes.push(outcome.clone());
530 }
531 RunLogPayload::VerdictRecorded {
532 verdict,
533 localized,
534 localization_gaps,
535 remote_archival_error: _,
536 } => {
537 for entry in localized {
541 let duplicate = evidence.iter().any(|existing| {
542 existing.reference.sha256 == entry.sha256
543 && existing.reference.asset.id == entry.asset.id
544 });
545 if !duplicate {
546 evidence.push(EvidenceItemView {
547 reference: entry.clone(),
548 source: format!("verdict@seq:{}", event.seq),
549 });
550 }
551 }
552 for gap in localization_gaps {
553 evidence_gaps.push(EvidenceGapView {
554 asset: gap.asset.clone(),
555 reason: gap.reason.clone(),
556 seq: event.seq,
557 });
558 }
559 verdict_history.push(VerdictRecordView {
560 seq: event.seq,
561 at_ms: event.at_ms,
562 verdict: verdict.clone(),
563 localized: localized.clone(),
564 localization_gaps: localization_gaps.clone(),
565 });
566 }
567 RunLogPayload::HandlerTriggered {
568 hook,
569 trigger,
570 disposition,
571 } => {
572 handler_triggers.push(HandlerTriggerView {
573 hook: wire(hook),
574 trigger: *trigger,
575 disposition: disposition.clone(),
576 seq: event.seq,
577 at_ms: event.at_ms,
578 });
579 }
580 RunLogPayload::StepExited {
581 provider_state_summary,
582 state: exit_state,
583 output: exit_output,
584 localized,
585 localization_gaps,
586 } => {
587 state = Some(*exit_state);
588 if exit_output.is_some() {
589 output = exit_output.clone();
590 }
591 if provider_state_summary.is_some() {
592 step_summary = provider_state_summary.clone();
593 }
594 for entry in localized {
597 let duplicate = evidence.iter().any(|existing| {
598 existing.reference.sha256 == entry.sha256
599 && existing.reference.asset.id == entry.asset.id
600 });
601 if !duplicate {
602 evidence.push(EvidenceItemView {
603 reference: entry.clone(),
604 source: format!("exit@seq:{}", event.seq),
605 });
606 }
607 }
608 for gap in localization_gaps {
609 evidence_gaps.push(EvidenceGapView {
610 asset: gap.asset.clone(),
611 reason: gap.reason.clone(),
612 seq: event.seq,
613 });
614 }
615 }
616 _ => {}
617 }
618 }
619
620 if !touched || entered.is_none() {
621 return Err(StoreError::UnknownStepInstance {
622 run_id: run_id.to_owned(),
623 path: key.clone(),
624 });
625 }
626 let (effect_hash, judge_hash, resolved_inputs) = entered.expect("checked above");
627
628 let checkpoint = store.materialized_checkpoint(run_id)?;
630 if let Some((_, view)) = &checkpoint {
631 if let Some(record) = view
632 .completed
633 .iter()
634 .find(|record: &&StepRecord| render_run_path(&record.run_path) == key)
635 {
636 for recorded in &record.attempts {
637 if let Some(&index) = by_call.get(&recorded.call_id) {
638 let attempt = &mut attempts[index];
639 attempt.error_class = recorded.error_class.as_ref().map(wire);
640 if attempt.execution_mode.is_none() {
641 attempt.execution_mode = recorded.execution_mode.as_ref().map(wire);
642 }
643 if attempt.fallback_reason.is_none() {
644 attempt.fallback_reason = recorded.fallback_reason.as_ref().map(wire);
645 }
646 }
647 }
648 }
649 if state.is_none() && render_run_path(&instance_path(&view.frontier.run_path)) == key {
650 state = Some(view.frontier.state);
651 }
652 }
653
654 let verdict = verdict_history.last().map(|record| StepVerdict {
656 status: record.verdict.status,
657 degraded: record.verdict.degraded,
658 supersedes: record.verdict.supersedes.clone(),
659 });
660
661 let scan = match instance.last() {
667 Some(PathFrame::Call { .. }) => &instance[..instance.len() - 1],
668 _ => instance.as_slice(),
669 };
670 let governing_hash = scan.iter().rev().find_map(|frame| match frame {
671 PathFrame::Flow { ir_hash, .. } => Some(ir_hash.clone()),
672 PathFrame::Call { callee_ir_hash, .. } => Some(callee_ir_hash.clone()),
673 _ => None,
674 });
675 let mut ir_node = None;
676 let mut source = None;
677 if let Some(hash) = governing_hash
678 && let Some(flow) = artifacts.iter().find(|flow| flow.ir_hash == hash)
679 && let Some((node, pointer)) = find_step(&flow.body, "/body", step_id.as_ref())
680 {
681 source = flow
682 .source_map
683 .iter()
684 .find(|entry| entry.ir_path.as_ref() == pointer)
685 .map(|entry| SourceLocation {
686 ir_path: entry.ir_path.clone(),
687 entry: entry.clone(),
688 });
689 ir_node = Some(node.clone());
690 }
691
692 let frame = checkpoint
699 .as_ref()
700 .and_then(|(_, view)| frame_environment(&instance, &view.frames))
701 .map(|mut environment| {
702 environment.provider_state_summary = step_summary.clone();
703 environment
704 });
705
706 Ok(StepDossierView {
707 projection_version: ProjectionVersion,
708 run_id: run_id.to_owned(),
709 run_path: key,
710 run_path_frames: instance,
711 step_id: step_id.to_string(),
712 effect_hash,
713 judge_hash,
714 ir_node,
715 source,
716 resolved_inputs,
717 preflight,
718 attempts,
719 output,
720 observations,
721 assertion_outcomes,
722 verdict,
723 verdict_history,
724 handler_triggers,
725 evidence,
726 evidence_gaps,
727 provider_state_summary: step_summary,
728 frame,
729 state,
730 })
731}
732
733fn frame_environment(
740 instance: &[PathFrame],
741 frames: &[pointlock_ir::CallFrame],
742) -> Option<FrameEnvironment> {
743 let mut level = 0usize;
744 for (index, path_frame) in instance.iter().enumerate() {
745 match path_frame {
746 PathFrame::Flow { ir_hash, flow_id } => {
747 let frame = frames.first()?;
748 if frame.ir_hash != *ir_hash
749 || frame.flow_id != *flow_id
750 || frame.call_step_id.is_some()
751 {
752 return None;
753 }
754 }
755 PathFrame::Call {
756 step_id,
757 callee_flow_id,
758 callee_ir_hash,
759 } => {
760 if index + 1 == instance.len() {
764 break;
765 }
766 level += 1;
767 let frame = frames.get(level)?;
768 if frame.call_step_id != *step_id
769 || frame.flow_id != *callee_flow_id
770 || frame.ir_hash != *callee_ir_hash
771 {
772 return None;
773 }
774 }
775 _ => {}
776 }
777 }
778 frames.get(level).map(|frame| FrameEnvironment {
779 inputs_snapshot: frame.inputs_snapshot.clone(),
780 vars: frame.vars.clone(),
781 provider_state_summary: None,
782 })
783}
784
785fn find_step<'a>(body: &'a [StepIR], prefix: &str, step_id: &str) -> Option<(&'a StepIR, String)> {
788 for (index, step) in body.iter().enumerate() {
789 let pointer = format!("{prefix}/{index}");
790 if step.step_id().as_ref() == step_id {
791 return Some((step, pointer));
792 }
793 match step {
794 StepIR::If(nested) => {
795 if let Some(found) = find_step(&nested.then, &format!("{pointer}/then"), step_id) {
796 return Some(found);
797 }
798 if let Some(else_body) = nested.r#else.as_deref()
799 && let Some(found) = find_step(else_body, &format!("{pointer}/else"), step_id)
800 {
801 return Some(found);
802 }
803 }
804 StepIR::Foreach(nested) => {
805 if let Some(found) = find_step(&nested.body, &format!("{pointer}/body"), step_id) {
806 return Some(found);
807 }
808 }
809 _ => {}
810 }
811 }
812 None
813}