Skip to main content

pointlock_store/projection/
overview.rs

1//! `RunOverview` — the run-level summary projection (spine §10.1):
2//! identity + status + flow verdict + `revision` (= the run's max ledger
3//! seq — the SSE invalidation currency, 08 §5) + the per-step state map
4//! (the graph overlay's data source; keys are canonical RunPath strings,
5//! values the minimal `{state, verdictStatus?, degraded?}` set — dossier
6//! detail stays in `StepDossierView`).
7
8use std::collections::BTreeMap;
9
10use pointlock_ir::{AlignmentClass, PathFrame, RunLogPayload, StepState, render_run_path};
11use schemars::JsonSchema;
12use serde::{Deserialize, Serialize};
13use serde_json::Value;
14
15use super::ProjectionVersion;
16use crate::error::StoreError;
17use crate::store::Store;
18
19/// Per-class alignment counts of the latest resume (08 §2.4 top bar).
20#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
21#[serde(rename_all = "camelCase", deny_unknown_fields)]
22pub struct AlignmentSummary {
23    /// History kept as-is.
24    pub reusable: u32,
25    /// Offline re-judgement, no device redispatch.
26    pub judge_dirty: u32,
27    /// Step + downstream invalidated, re-runs.
28    pub effect_dirty: u32,
29    /// Newly introduced steps.
30    pub new: u32,
31    /// Steps whose history lost its IR node.
32    pub orphaned: u32,
33}
34
35/// The minimal per-step overlay cell (2026-07-17 ruling, additive).
36#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
37#[serde(rename_all = "camelCase", deny_unknown_fields)]
38pub struct StepStateSummary {
39    /// Last recorded step state (closed vocabulary).
40    pub state: StepState,
41    /// Verdict status once judged.
42    #[serde(skip_serializing_if = "Option::is_none")]
43    pub verdict_status: Option<String>,
44    /// Degraded-verification marker of that verdict.
45    #[serde(skip_serializing_if = "Option::is_none")]
46    pub degraded: Option<bool>,
47    /// Act-chain runtime marks of the LATEST acting pass (08 §3.4,
48    /// incorporated 2026-07-18): one entry per SETTLED dispatch, keyed
49    /// by 1-based chain position — chips beyond the highest marked
50    /// index render untried by absence. Absent entirely on
51    /// pre-incorporation ledgers and for undispatched steps (the graph
52    /// never invents runtime state, principle 4).
53    #[serde(skip_serializing_if = "Option::is_none")]
54    pub act_chain_marks: Option<Vec<ActChainMark>>,
55}
56
57/// One settled dispatch of the latest acting pass (08 §3.4). `mark` is
58/// the closed chip vocabulary minus `untried` (absence = untried):
59/// `succeeded` | `crossed`. `executionMode`/`fallbackReason` ride
60/// verbatim so the renderer (which holds the IR) can apply the
61/// `acceptExecutionModes` whitelist check.
62#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
63#[serde(rename_all = "camelCase", deny_unknown_fields)]
64pub struct ActChainMark {
65    /// 1-based `binding.attempts` position.
66    pub chain_index: u32,
67    /// `succeeded` | `crossed`.
68    pub mark: String,
69    /// Daemon-reported execution mode, when any.
70    #[serde(skip_serializing_if = "Option::is_none")]
71    pub execution_mode: Option<String>,
72    /// Daemon-side degradation reason, when any.
73    #[serde(skip_serializing_if = "Option::is_none")]
74    pub fallback_reason: Option<String>,
75}
76
77/// The run summary (spine §10.1).
78#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
79#[serde(rename_all = "camelCase", deny_unknown_fields)]
80pub struct RunOverview {
81    /// Protocol version (spine §10.3).
82    pub projection_version: ProjectionVersion,
83    /// The run.
84    pub run_id: String,
85    /// The run's flow.
86    pub flow_id: String,
87    /// The executed IR (full `sha256:` form).
88    pub ir_hash: String,
89    /// The lockfile digest the run bound against (repair guidance: use
90    /// the SAME lockfile — 08 §2.7; additive, no version bump).
91    pub lockfile_digest: String,
92    /// The bound device.
93    pub device_id: String,
94    /// Session lineage (resume generations — 08 §2.4).
95    pub session_lineage: Vec<String>,
96    /// Ledger status (`running`/`suspended`/`awaitingHuman`/`finished`).
97    pub status: String,
98    /// Snapshot revision = max ledger seq (08 §5: the invalidation
99    /// currency; SSE pushes only `{revision}`).
100    pub revision: u64,
101    /// Run creation wall clock.
102    pub created_at_ms: u64,
103    /// Terminal wall clock, once finished.
104    #[serde(skip_serializing_if = "Option::is_none")]
105    pub finished_at_ms: Option<u64>,
106    /// Flow verdict status; absent = unfinished, finished unverified, or
107    /// aborted (the ledger's `runFinished.verdict` as-is — no folding).
108    #[serde(skip_serializing_if = "Option::is_none")]
109    pub flow_verdict_status: Option<String>,
110    /// Degraded marker of the flow verdict.
111    #[serde(skip_serializing_if = "Option::is_none")]
112    pub flow_verdict_degraded: Option<bool>,
113    /// Supervision policy of the current segment (explicit `null` when
114    /// unsupervised — recorded per segment, never inherited; R13).
115    pub supervise_policy: Option<String>,
116    /// Alignment counts of the latest resume, when the run resumed.
117    #[serde(skip_serializing_if = "Option::is_none")]
118    pub alignment: Option<AlignmentSummary>,
119    /// Whether a human request is pending (inbox red dot — 08 §3.5).
120    pub awaiting_human: bool,
121    /// The most recent suspension's provider profile (07 §2.2,
122    /// incorporated 2026-07-18): present only while the run is actually
123    /// suspended/awaitingHuman — a superseded suspension profile must
124    /// not read as current on a resumed or finished run. Named for its
125    /// timing; the step-anchored captures live in the dossier.
126    #[serde(skip_serializing_if = "Option::is_none")]
127    pub last_suspension_provider_state_summary: Option<pointlock_ir::ProviderStateSummary>,
128    /// The per-step overlay map, keyed by canonical RunPath string
129    /// (iteration frames included; strip `[i]`/`[i:key]` to join onto
130    /// `FlowGraphView` node anchors — 08 §3.2 aggregate rule).
131    pub steps: BTreeMap<String, StepStateSummary>,
132}
133
134/// Serializes a unit-enum value to its wire literal.
135fn wire<T: Serialize>(value: &T) -> String {
136    serde_json::to_value(value)
137        .ok()
138        .and_then(|v| v.as_str().map(str::to_owned))
139        .unwrap_or_default()
140}
141
142/// Strips attempt/phase/assertion frames — the instance identity.
143fn instance_path(path: &[PathFrame]) -> Vec<PathFrame> {
144    path.iter()
145        .filter(|frame| {
146            !matches!(
147                frame,
148                PathFrame::Attempt { .. } | PathFrame::Phase { .. } | PathFrame::Assertion { .. }
149            )
150        })
151        .cloned()
152        .collect()
153}
154
155/// Projects one run's overview from its ledger + metadata.
156pub fn run_overview(store: &Store, run_id: &str) -> Result<RunOverview, StoreError> {
157    let meta = store.run_meta(run_id)?;
158    let status = store.run_status(run_id)?;
159    let events = store.events(run_id)?;
160    let revision = events.last().map(|event| event.seq).unwrap_or(0);
161
162    let mut steps: BTreeMap<String, StepStateSummary> = BTreeMap::new();
163    let mut finished_at_ms = None;
164    let mut flow_verdict: Option<(String, bool)> = None;
165    let mut supervise_policy: Option<String> = None;
166    let mut alignment = None;
167    let mut awaiting: Option<(String, pointlock_ir::RunPath)> = None;
168    let mut last_suspension_summary: Option<pointlock_ir::ProviderStateSummary> = None;
169    let mut session_lineage = meta.binding.session_lineage.clone();
170    let mut chain_marks: BTreeMap<String, Vec<ActChainMark>> = BTreeMap::new();
171    let mut intent_index: BTreeMap<String, (String, u32)> = BTreeMap::new();
172    let mut boundary_pending: std::collections::BTreeSet<String> =
173        std::collections::BTreeSet::new();
174
175    for event in &events {
176        let key = || render_run_path(&instance_path(&event.run_path));
177        match &event.payload {
178            RunLogPayload::RunStarted {
179                supervise_policy: policy,
180                ..
181            } => {
182                supervise_policy = policy.as_ref().map(wire);
183            }
184            RunLogPayload::RunResumed {
185                alignment_report,
186                supervise_policy: policy,
187                event_cursor,
188            } => {
189                // 07 §4.5: a cursor-bearing resume is a generation — the
190                // display lineage extends here (the run row keeps the
191                // bind-time binding verbatim).
192                if let Some(cursor) = event_cursor
193                    && session_lineage.last() != Some(&cursor.session_id)
194                {
195                    session_lineage.push(cursor.session_id.clone());
196                }
197                // Per-segment recording, never inherited (R13); a resume
198                // also supersedes the prior suspension profile.
199                supervise_policy = policy.as_ref().map(wire);
200                last_suspension_summary = None;
201                let count = |class: AlignmentClass| {
202                    alignment_report
203                        .entries
204                        .iter()
205                        .filter(|entry| entry.class == class)
206                        .count() as u32
207                };
208                alignment = Some(AlignmentSummary {
209                    reusable: count(AlignmentClass::Reusable),
210                    judge_dirty: count(AlignmentClass::JudgeDirty),
211                    effect_dirty: count(AlignmentClass::EffectDirty),
212                    new: count(AlignmentClass::New),
213                    orphaned: count(AlignmentClass::Orphaned),
214                });
215            }
216            RunLogPayload::StepEntered { .. } => {
217                // A fresh span invalidates any previous pass's marks.
218                chain_marks.remove(&key());
219                steps.insert(
220                    key(),
221                    StepStateSummary {
222                        state: StepState::Ready,
223                        verdict_status: None,
224                        degraded: None,
225                        act_chain_marks: None,
226                    },
227                );
228            }
229            RunLogPayload::ActionIntent {
230                call_id,
231                chain_index: Some(index),
232                ..
233            } => {
234                let step_key = key();
235                if boundary_pending.remove(&step_key) {
236                    // The deferred pass boundary: the new pass starts.
237                    chain_marks.remove(&step_key);
238                } else if let Some(marks) = chain_marks.get_mut(&step_key) {
239                    let max_marked = marks.iter().map(|mark| mark.chain_index).max();
240                    if max_marked.is_some_and(|max| *index < max) {
241                        // A restart below the pass's frontier (fresh
242                        // re-execution after an effect-dirty resume):
243                        // the crashed pass's marks are superseded.
244                        marks.clear();
245                    } else {
246                        // A same-position re-dispatch (in-attempt retry):
247                        // the latest settle owns the chip.
248                        marks.retain(|mark| mark.chain_index != *index);
249                    }
250                }
251                intent_index.insert(call_id.clone(), (step_key, *index));
252            }
253            RunLogPayload::ActionSettled { call_id, outcome } => {
254                if let Some((step_key, index)) = intent_index.remove(call_id) {
255                    let (mark, execution_mode, fallback_reason) = match outcome {
256                        pointlock_ir::ActionOutcome::Succeeded { result } => {
257                            let (mode, reason) = match &result.execution {
258                                Some(pointlock_ir::ActionExecution::NativeSemantic { .. }) => {
259                                    (Some("nativeSemantic".to_owned()), None)
260                                }
261                                Some(pointlock_ir::ActionExecution::WebSemantic { .. }) => {
262                                    (Some("webSemantic".to_owned()), None)
263                                }
264                                Some(pointlock_ir::ActionExecution::CoordinateFallback {
265                                    fallback_reason,
266                                    ..
267                                }) => (
268                                    Some("coordinateFallback".to_owned()),
269                                    Some(wire(fallback_reason)),
270                                ),
271                                None => (None, None),
272                            };
273                            ("succeeded", mode, reason)
274                        }
275                        _ => ("crossed", None, None),
276                    };
277                    chain_marks.entry(step_key).or_default().push(ActChainMark {
278                        chain_index: index,
279                        mark: mark.to_owned(),
280                        execution_mode,
281                        fallback_reason,
282                    });
283                }
284            }
285            RunLogPayload::HandlerTriggered { hook, .. } => {
286                // A hook firing delimits the acting pass (item ② ruling)
287                // — but LAZILY: the old pass's marks are invalidated only
288                // when a new pass actually STARTS (its first intent). A
289                // continue/abort/escalate disposition never starts one,
290                // and the settled marks it leaves ARE the latest pass
291                // (Wave D review). onResumeDrift/onTimeout triggers do
292                // not delimit: a crash-resume continuation is the same
293                // pass (07 §1.4).
294                if matches!(
295                    hook,
296                    pointlock_ir::HandlerHook::OnFail
297                        | pointlock_ir::HandlerHook::OnError
298                        | pointlock_ir::HandlerHook::OnUnknown
299                ) {
300                    boundary_pending.insert(key());
301                }
302            }
303            RunLogPayload::StepExited { state, .. } => {
304                if let Some(cell) = steps.get_mut(&key()) {
305                    cell.state = *state;
306                }
307                // A terminal exit of the awaiting step settles its
308                // request without a response (lazy timeout settlement /
309                // aborted disposition — mirrors the checkpoint fold).
310                if awaiting.as_ref().is_some_and(|(_, path)| {
311                    crate::fold::exit_settles_pending(&event.run_path, path)
312                }) {
313                    awaiting = None;
314                }
315            }
316            RunLogPayload::VerdictRecorded { verdict, .. } => {
317                if let Some(cell) = steps.get_mut(&key()) {
318                    cell.verdict_status = Some(wire(&verdict.status));
319                    cell.degraded = Some(verdict.degraded);
320                }
321            }
322            RunLogPayload::HumanRequested { request_id, .. } => {
323                awaiting = Some((request_id.clone(), event.run_path.clone()));
324                if let Some(cell) = steps.get_mut(&key()) {
325                    cell.state = StepState::AwaitingHuman;
326                }
327            }
328            RunLogPayload::HumanResponded {
329                request_id,
330                purpose,
331                response,
332                ..
333            } => {
334                let non_final = *purpose == pointlock_ir::HumanPurpose::Supervision
335                    && response.get("decision").and_then(Value::as_str) == Some("suspend");
336                if !non_final && awaiting.as_ref().is_some_and(|(id, _)| id == request_id) {
337                    awaiting = None;
338                }
339            }
340            RunLogPayload::RunSuspended {
341                provider_state_summary,
342                ..
343            } => {
344                last_suspension_summary = provider_state_summary.clone();
345            }
346            RunLogPayload::RunFinished {
347                verdict,
348                remote_archival_error: _,
349            } => {
350                finished_at_ms = Some(event.at_ms);
351                flow_verdict = verdict
352                    .as_ref()
353                    .map(|verdict| (wire(&verdict.status), verdict.degraded));
354            }
355            _ => {}
356        }
357    }
358
359    // Attach the latest-pass marks to their cells.
360    for (step_key, marks) in chain_marks {
361        if let Some(cell) = steps.get_mut(&step_key) {
362            cell.act_chain_marks = Some(marks);
363        }
364    }
365
366    // The live frontier state overrides the seed for the in-flight step.
367    if let Some((_, view)) = store.materialized_checkpoint(run_id)? {
368        let key = render_run_path(&instance_path(&view.frontier.run_path));
369        if let Some(cell) = steps.get_mut(&key) {
370            cell.state = view.frontier.state;
371        }
372    }
373
374    let (flow_verdict_status, flow_verdict_degraded) = match flow_verdict {
375        Some((status, degraded)) => (Some(status), Some(degraded)),
376        None => (None, None),
377    };
378
379    Ok(RunOverview {
380        projection_version: ProjectionVersion,
381        run_id: run_id.to_owned(),
382        flow_id: meta.flow_id.to_string(),
383        ir_hash: meta.ir_hash.to_string(),
384        lockfile_digest: meta.lockfile_digest.to_string(),
385        device_id: meta.binding.device_id.clone(),
386        session_lineage,
387        status: status.as_str().to_owned(),
388        revision,
389        created_at_ms: meta.created_at_ms,
390        finished_at_ms,
391        flow_verdict_status,
392        flow_verdict_degraded,
393        supervise_policy,
394        alignment,
395        awaiting_human: awaiting.is_some(),
396        last_suspension_provider_state_summary: match status {
397            crate::RunStatus::Suspended | crate::RunStatus::AwaitingHuman => {
398                last_suspension_summary
399            }
400            _ => None,
401        },
402        steps,
403    })
404}