Skip to main content

repo/
timeline_navigation.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Render-ready timeline navigation snapshots.
3
4use std::collections::BTreeSet;
5
6use objects::object::{
7    ContentHash, NativeToolCallRefV1, StateId, TimelineBranchId, TimelineBranchReason,
8    TimelineCursorMoveReason, TimelineLabel, TimelineOperationId, TimelineStepId,
9    TimelineToolCallStatus,
10};
11
12use crate::{
13    Repository, Result, TimelineMaterializationRecoveryRecord, TimelineStore, TimelineView,
14};
15
16#[derive(Clone, Debug)]
17pub struct TimelineNavigationSnapshot {
18    pub thread: String,
19    pub cursor: TimelineNavigationCursor,
20    pub branches: Vec<TimelineNavigationBranch>,
21    pub steps: Vec<TimelineNavigationStep>,
22    pub active_branch_path: Vec<TimelineBranchId>,
23    pub actions: TimelineNavigationActionAvailability,
24    pub recovery: Option<TimelineNavigationRecovery>,
25}
26
27#[derive(Clone, Debug)]
28pub struct TimelineNavigationCursor {
29    pub branch_id: Option<TimelineBranchId>,
30    pub step_id: Option<TimelineStepId>,
31    pub state: Option<StateId>,
32}
33
34#[derive(Clone, Debug)]
35pub struct TimelineNavigationBranch {
36    pub branch_id: TimelineBranchId,
37    pub parent_branch_id: Option<TimelineBranchId>,
38    pub forked_from_step_id: Option<TimelineStepId>,
39    pub forked_from_state: Option<StateId>,
40    pub reason: Option<TimelineBranchReason>,
41    pub created_at_ms: Option<i64>,
42    pub operation_ids: Vec<TimelineOperationId>,
43    pub step_ids: Vec<TimelineStepId>,
44    pub is_active: bool,
45    pub is_on_active_path: bool,
46}
47
48#[derive(Clone, Debug)]
49pub struct TimelineNavigationStep {
50    pub thread: String,
51    pub step_id: TimelineStepId,
52    pub branch_id: TimelineBranchId,
53    pub parent_step_id: Option<TimelineStepId>,
54    pub native: Option<NativeToolCallRefV1>,
55    pub tool_name: Option<String>,
56    pub status: Option<TimelineToolCallStatus>,
57    pub changed: Option<bool>,
58    pub touched_paths: Vec<String>,
59    pub before_state: Option<StateId>,
60    pub after_state: Option<StateId>,
61    pub capture_state: Option<StateId>,
62    pub capture_oplog_batch_id: Option<u64>,
63    pub labels: Vec<TimelineLabel>,
64    pub payload_summary: Option<String>,
65    pub payload_hash: Option<ContentHash>,
66    pub operation_ids: Vec<TimelineOperationId>,
67    pub started_at_ms: Option<i64>,
68    pub finished_at_ms: Option<i64>,
69    pub cursor_state: Option<StateId>,
70    pub is_current: bool,
71    pub is_on_active_branch_path: bool,
72    pub can_seek: bool,
73    pub can_fork: bool,
74    pub can_reset: bool,
75    pub can_materialize: bool,
76    pub has_boundary_warning: bool,
77}
78
79#[derive(Clone, Debug)]
80pub struct TimelineNavigationActionAvailability {
81    pub can_undo: bool,
82    pub can_redo: bool,
83}
84
85#[derive(Clone, Debug)]
86pub struct TimelineNavigationRecovery {
87    pub status: TimelineNavigationRecoveryStatus,
88    pub thread: String,
89    pub branch_id: TimelineBranchId,
90    pub from_step_id: Option<TimelineStepId>,
91    pub to_step_id: Option<TimelineStepId>,
92    pub from_state: StateId,
93    pub to_state: StateId,
94    pub reason: TimelineCursorMoveReason,
95    pub moved_at_ms: i64,
96    pub checkout_state: Option<StateId>,
97}
98
99#[derive(Clone, Copy, Debug, PartialEq, Eq)]
100pub enum TimelineNavigationRecoveryStatus {
101    PendingCursorRecord,
102    Blocked,
103    AlreadyApplied,
104}
105
106impl Repository {
107    pub fn timeline_navigation_snapshot(
108        &self,
109        store: &TimelineStore,
110        thread: &str,
111    ) -> Result<TimelineNavigationSnapshot> {
112        let view = TimelineView::rebuild(store)?;
113        let status = view.status(thread);
114        let active_branch_path = active_branch_path(
115            &view,
116            thread,
117            status.and_then(|s| s.current_branch_id.as_ref()),
118        );
119        let active_branch_set = active_branch_path.iter().cloned().collect::<BTreeSet<_>>();
120        let active_branch_id = status.and_then(|status| status.current_branch_id.as_ref());
121        let current_step_id = status.and_then(|status| status.current_step_id.as_ref());
122
123        let branches = view
124            .branches_for_thread(thread)
125            .into_iter()
126            .map(|branch| TimelineNavigationBranch {
127                branch_id: branch.branch_id.clone(),
128                parent_branch_id: branch.parent_branch_id.clone(),
129                forked_from_step_id: branch.forked_from_step_id.clone(),
130                forked_from_state: branch.forked_from_state,
131                reason: branch.reason.clone(),
132                created_at_ms: branch.created_at_ms,
133                operation_ids: branch.operation_ids.clone(),
134                step_ids: branch.steps.clone(),
135                is_active: active_branch_id == Some(&branch.branch_id),
136                is_on_active_path: active_branch_set.contains(&branch.branch_id),
137            })
138            .collect();
139
140        let steps = view
141            .steps_for_thread(thread)
142            .into_iter()
143            .map(|step| {
144                let cursor_state = step
145                    .after_state
146                    .or(step.capture_state)
147                    .or(step.before_state);
148                let can_target = cursor_state.is_some();
149                TimelineNavigationStep {
150                    thread: step.thread.clone(),
151                    step_id: step.step_id.clone(),
152                    branch_id: step.branch_id.clone(),
153                    parent_step_id: step.parent_step_id.clone(),
154                    native: step.native.clone(),
155                    tool_name: step.tool_name.clone(),
156                    status: step.status.clone(),
157                    changed: step.changed,
158                    touched_paths: step.touched_paths.clone(),
159                    before_state: step.before_state,
160                    after_state: step.after_state,
161                    capture_state: step.capture_state,
162                    capture_oplog_batch_id: step.capture_oplog_batch_id,
163                    labels: step.labels.clone(),
164                    payload_summary: step.payload_summary.clone(),
165                    payload_hash: step.payload_hash,
166                    operation_ids: step.operation_ids.clone(),
167                    started_at_ms: step.started_at_ms,
168                    finished_at_ms: step.finished_at_ms,
169                    cursor_state,
170                    is_current: current_step_id == Some(&step.step_id),
171                    is_on_active_branch_path: active_branch_set.contains(&step.branch_id),
172                    can_seek: can_target,
173                    can_fork: can_target,
174                    can_reset: can_target,
175                    can_materialize: can_target,
176                    has_boundary_warning: step.labels.iter().any(label_has_boundary_warning),
177                }
178            })
179            .collect();
180
181        let recovery = match store.read_materialization_recovery(thread)? {
182            Some(record) => Some(self.navigation_recovery_status(&view, &record)?),
183            None => None,
184        };
185
186        Ok(TimelineNavigationSnapshot {
187            thread: thread.to_string(),
188            cursor: TimelineNavigationCursor {
189                branch_id: status.and_then(|status| status.current_branch_id.clone()),
190                step_id: status.and_then(|status| status.current_step_id.clone()),
191                state: status.and_then(|status| status.current_state),
192            },
193            branches,
194            steps,
195            active_branch_path,
196            actions: TimelineNavigationActionAvailability {
197                can_undo: view.resolve_undo_target(thread).is_some(),
198                can_redo: view.resolve_redo_target(thread).is_some(),
199            },
200            recovery,
201        })
202    }
203
204    fn navigation_recovery_status(
205        &self,
206        view: &TimelineView,
207        record: &TimelineMaterializationRecoveryRecord,
208    ) -> Result<TimelineNavigationRecovery> {
209        let checkout_state = self.head()?;
210        let status = if timeline_cursor_matches_recovery(view, record) {
211            TimelineNavigationRecoveryStatus::AlreadyApplied
212        } else if checkout_state == Some(record.to_state) {
213            TimelineNavigationRecoveryStatus::PendingCursorRecord
214        } else {
215            TimelineNavigationRecoveryStatus::Blocked
216        };
217
218        Ok(TimelineNavigationRecovery {
219            status,
220            thread: record.thread.clone(),
221            branch_id: record.branch_id.clone(),
222            from_step_id: record.from_step_id.clone(),
223            to_step_id: record.to_step_id.clone(),
224            from_state: record.from_state,
225            to_state: record.to_state,
226            reason: record.reason.clone(),
227            moved_at_ms: record.moved_at_ms,
228            checkout_state,
229        })
230    }
231}
232
233fn active_branch_path(
234    view: &TimelineView,
235    thread: &str,
236    current_branch_id: Option<&TimelineBranchId>,
237) -> Vec<TimelineBranchId> {
238    let mut path = Vec::new();
239    let mut seen = BTreeSet::new();
240    let mut next = current_branch_id.cloned();
241
242    while let Some(branch_id) = next {
243        if !seen.insert(branch_id.clone()) {
244            break;
245        }
246        let parent = view
247            .branch(thread, &branch_id)
248            .and_then(|branch| branch.parent_branch_id.clone());
249        path.push(branch_id);
250        next = parent;
251    }
252
253    path.reverse();
254    path
255}
256
257fn timeline_cursor_matches_recovery(
258    view: &TimelineView,
259    record: &TimelineMaterializationRecoveryRecord,
260) -> bool {
261    view.status(&record.thread).is_some_and(|status| {
262        status.current_branch_id.as_ref() == Some(&record.branch_id)
263            && status.current_step_id == record.to_step_id
264            && status.current_state == Some(record.to_state)
265    })
266}
267
268fn label_has_boundary_warning(label: &TimelineLabel) -> bool {
269    matches!(
270        label,
271        TimelineLabel::IgnoredPathTouched
272            | TimelineLabel::OutsideRepoTouched
273            | TimelineLabel::PurgeBoundary
274            | TimelineLabel::CaptureFailed
275    )
276}
277
278#[cfg(test)]
279mod tests {
280    use std::{fs, path::Path};
281
282    use objects::object::{
283        BranchCreatedV1, ContentHash, NativeToolCallRefV1, TimelineBranchReason,
284        TimelineOperationBodyV1, TimelineOperationEnvelope, TimelineToolPayloadMetadata,
285        ToolCallFinishedV1,
286    };
287    use tempfile::TempDir;
288
289    use super::*;
290    use crate::{TimelineCursorMoveRecord, TimelineLabel, TimelineMaterializeMode};
291
292    fn create_repo() -> (TempDir, Repository, TimelineStore) {
293        let temp = TempDir::new().unwrap();
294        let repo = Repository::init_default(temp.path()).unwrap();
295        let store = TimelineStore::open(repo.heddle_dir()).unwrap();
296        (temp, repo, store)
297    }
298
299    fn step(id: &str) -> TimelineStepId {
300        TimelineStepId::new(id)
301    }
302
303    fn branch(id: &str) -> TimelineBranchId {
304        TimelineBranchId::new(id)
305    }
306
307    fn native(call: &str) -> NativeToolCallRefV1 {
308        NativeToolCallRefV1 {
309            harness: "opencode".to_string(),
310            session_id: Some("session-1".to_string()),
311            message_id: Some("message-1".to_string()),
312            tool_call_id: call.to_string(),
313        }
314    }
315
316    fn write_state(repo: &Repository, root: &Path, path: &str, content: &str) -> StateId {
317        fs::write(root.join(path), content).unwrap();
318        repo.snapshot(Some(path.to_string()), None).unwrap().id()
319    }
320
321    fn write_finished_step(
322        store: &TimelineStore,
323        step_id: &str,
324        branch_id: &str,
325        native_id: &str,
326        before_state: StateId,
327        after_state: StateId,
328        finished_at_ms: i64,
329    ) {
330        store
331            .write_operation(&TimelineOperationEnvelope::new(
332                TimelineOperationBodyV1::ToolCallFinished(ToolCallFinishedV1 {
333                    thread: "main".to_string(),
334                    step_id: step(step_id),
335                    branch_id: branch(branch_id),
336                    native: native(native_id),
337                    status: objects::object::TimelineToolCallStatus::Succeeded,
338                    before_state,
339                    after_state,
340                    capture_state: Some(after_state),
341                    capture_oplog_batch_id: Some(finished_at_ms as u64),
342                    changed: true,
343                    touched_paths: vec!["tracked.txt".to_string()],
344                    payload: Some(TimelineToolPayloadMetadata {
345                        summary: Some(format!("finished {native_id}")),
346                        hash: Some(ContentHash::compute_typed(
347                            "timeline-tool-payload",
348                            native_id.as_bytes(),
349                        )),
350                    }),
351                    finished_at_ms,
352                }),
353                vec![TimelineLabel::RepoReversible],
354            ))
355            .unwrap();
356    }
357
358    #[test]
359    fn navigation_snapshot_marks_cursor_actions_and_active_path() {
360        let (temp, repo, store) = create_repo();
361        let state0 = repo.head().unwrap().unwrap();
362        let state1 = write_state(&repo, temp.path(), "tracked.txt", "one\n");
363        let state2 = write_state(&repo, temp.path(), "tracked.txt", "two\n");
364
365        store
366            .write_operation(&TimelineOperationEnvelope::new(
367                TimelineOperationBodyV1::BranchCreated(BranchCreatedV1 {
368                    thread: "main".to_string(),
369                    branch_id: branch("tlb-main"),
370                    parent_branch_id: None,
371                    from_step_id: None,
372                    from_state: state0,
373                    reason: TimelineBranchReason::ExplicitFork,
374                    created_at_ms: 1,
375                }),
376                Vec::new(),
377            ))
378            .unwrap();
379        write_finished_step(&store, "tls-one", "tlb-main", "call-1", state0, state1, 2);
380        store
381            .write_operation(&TimelineOperationEnvelope::new(
382                TimelineOperationBodyV1::BranchCreated(BranchCreatedV1 {
383                    thread: "main".to_string(),
384                    branch_id: branch("tlb-child"),
385                    parent_branch_id: Some(branch("tlb-main")),
386                    from_step_id: Some(step("tls-one")),
387                    from_state: state1,
388                    reason: TimelineBranchReason::ExplicitFork,
389                    created_at_ms: 3,
390                }),
391                Vec::new(),
392            ))
393            .unwrap();
394        write_finished_step(
395            &store,
396            "tls-child",
397            "tlb-child",
398            "call-2",
399            state1,
400            state2,
401            4,
402        );
403
404        let snapshot = repo.timeline_navigation_snapshot(&store, "main").unwrap();
405
406        assert_eq!(snapshot.cursor.branch_id, Some(branch("tlb-child")));
407        assert_eq!(snapshot.cursor.step_id, Some(step("tls-child")));
408        assert!(snapshot.actions.can_undo);
409        assert!(!snapshot.actions.can_redo);
410        assert_eq!(
411            snapshot.active_branch_path,
412            vec![branch("tlb-main"), branch("tlb-child")]
413        );
414        assert_eq!(snapshot.branches.len(), 2);
415        assert!(snapshot.branches.iter().any(|branch| branch.is_active));
416        let current = snapshot
417            .steps
418            .iter()
419            .find(|step| step.is_current)
420            .expect("current step");
421        assert_eq!(current.step_id, step("tls-child"));
422        assert_eq!(
423            current
424                .native
425                .as_ref()
426                .map(|native| native.tool_call_id.as_str()),
427            Some("call-2")
428        );
429    }
430
431    #[test]
432    fn navigation_snapshot_surfaces_pending_recovery() {
433        let (temp, repo, store) = create_repo();
434        let state0 = repo.head().unwrap().unwrap();
435        let state1 = write_state(&repo, temp.path(), "tracked.txt", "one\n");
436        let state2 = write_state(&repo, temp.path(), "tracked.txt", "two\n");
437        write_finished_step(&store, "tls-one", "tlb-main", "call-1", state0, state1, 1);
438        write_finished_step(&store, "tls-two", "tlb-main", "call-2", state1, state2, 2);
439
440        store
441            .record_cursor_move(TimelineCursorMoveRecord {
442                thread: "main".to_string(),
443                branch_id: branch("tlb-main"),
444                from_step_id: Some(step("tls-two")),
445                to_step_id: Some(step("tls-one")),
446                from_state: state2,
447                to_state: state1,
448                reason: TimelineCursorMoveReason::Undo,
449                moved_at_ms: 3,
450                labels: Vec::new(),
451            })
452            .unwrap();
453        store
454            .stage_materialization_recovery(&TimelineMaterializationRecoveryRecord::new(
455                "main",
456                branch("tlb-main"),
457                Some(step("tls-one")),
458                Some(step("tls-two")),
459                state1,
460                state2,
461                TimelineCursorMoveReason::Redo,
462                4,
463            ))
464            .unwrap();
465        repo.goto(&state2).unwrap();
466
467        let snapshot = repo.timeline_navigation_snapshot(&store, "main").unwrap();
468        let recovery = snapshot.recovery.expect("pending recovery");
469
470        assert_eq!(
471            recovery.status,
472            TimelineNavigationRecoveryStatus::PendingCursorRecord
473        );
474        assert_eq!(recovery.to_step_id, Some(step("tls-two")));
475        assert_eq!(recovery.checkout_state, Some(state2));
476
477        let outcome = repo
478            .materialize_timeline_cursor(
479                &store,
480                "main",
481                &crate::TimelineSeekSelector::CurrentCursor,
482                TimelineMaterializeMode::FailIfDirty,
483                5,
484            )
485            .unwrap();
486        assert_eq!(
487            outcome.recovery.status,
488            crate::TimelineMaterializationRecoveryStatus::CursorRecorded
489        );
490    }
491
492    #[test]
493    fn navigation_boundary_warning_ignores_external_unknown_only() {
494        let (temp, repo, store) = create_repo();
495        let state0 = repo.head().unwrap().unwrap();
496        let state1 = write_state(&repo, temp.path(), "tracked.txt", "one\n");
497
498        store
499            .write_operation(&TimelineOperationEnvelope::new(
500                TimelineOperationBodyV1::ToolCallFinished(ToolCallFinishedV1 {
501                    thread: "main".to_string(),
502                    step_id: step("tls-external"),
503                    branch_id: branch("tlb-main"),
504                    native: native("call-external"),
505                    status: objects::object::TimelineToolCallStatus::Succeeded,
506                    before_state: state0,
507                    after_state: state1,
508                    capture_state: Some(state1),
509                    capture_oplog_batch_id: None,
510                    changed: true,
511                    touched_paths: Vec::new(),
512                    payload: None,
513                    finished_at_ms: 1,
514                }),
515                vec![
516                    TimelineLabel::RepoReversible,
517                    TimelineLabel::ExternalSideEffectsUnknown,
518                ],
519            ))
520            .unwrap();
521        store
522            .write_operation(&TimelineOperationEnvelope::new(
523                TimelineOperationBodyV1::ToolCallFinished(ToolCallFinishedV1 {
524                    thread: "main".to_string(),
525                    step_id: step("tls-ignored"),
526                    branch_id: branch("tlb-main"),
527                    native: native("call-ignored"),
528                    status: objects::object::TimelineToolCallStatus::Succeeded,
529                    before_state: state1,
530                    after_state: state1,
531                    capture_state: Some(state1),
532                    capture_oplog_batch_id: None,
533                    changed: true,
534                    touched_paths: vec!["ignored.log".to_string()],
535                    payload: None,
536                    finished_at_ms: 2,
537                }),
538                vec![
539                    TimelineLabel::RepoReversible,
540                    TimelineLabel::IgnoredPathTouched,
541                ],
542            ))
543            .unwrap();
544
545        let snapshot = repo.timeline_navigation_snapshot(&store, "main").unwrap();
546        let external_id = step("tls-external");
547        let ignored_id = step("tls-ignored");
548        let external = snapshot
549            .steps
550            .iter()
551            .find(|candidate| candidate.step_id == external_id)
552            .expect("external step");
553        let ignored = snapshot
554            .steps
555            .iter()
556            .find(|candidate| candidate.step_id == ignored_id)
557            .expect("ignored step");
558
559        assert!(!external.has_boundary_warning);
560        assert!(ignored.has_boundary_warning);
561    }
562}