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    ChangeId, ContentHash, NativeToolCallRefV1, 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<ChangeId>,
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<ChangeId>,
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<ChangeId>,
60    pub after_state: Option<ChangeId>,
61    pub capture_state: Option<ChangeId>,
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<ChangeId>,
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: ChangeId,
93    pub to_state: ChangeId,
94    pub reason: TimelineCursorMoveReason,
95    pub moved_at_ms: i64,
96    pub checkout_state: Option<ChangeId>,
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) -> ChangeId {
317        fs::write(root.join(path), content).unwrap();
318        repo.snapshot(Some(path.to_string()), None)
319            .unwrap()
320            .change_id
321    }
322
323    fn write_finished_step(
324        store: &TimelineStore,
325        step_id: &str,
326        branch_id: &str,
327        native_id: &str,
328        before_state: ChangeId,
329        after_state: ChangeId,
330        finished_at_ms: i64,
331    ) {
332        store
333            .write_operation(&TimelineOperationEnvelope::new(
334                TimelineOperationBodyV1::ToolCallFinished(ToolCallFinishedV1 {
335                    thread: "main".to_string(),
336                    step_id: step(step_id),
337                    branch_id: branch(branch_id),
338                    native: native(native_id),
339                    status: objects::object::TimelineToolCallStatus::Succeeded,
340                    before_state,
341                    after_state,
342                    capture_state: Some(after_state),
343                    capture_oplog_batch_id: Some(finished_at_ms as u64),
344                    changed: true,
345                    touched_paths: vec!["tracked.txt".to_string()],
346                    payload: Some(TimelineToolPayloadMetadata {
347                        summary: Some(format!("finished {native_id}")),
348                        hash: Some(ContentHash::compute_typed(
349                            "timeline-tool-payload",
350                            native_id.as_bytes(),
351                        )),
352                    }),
353                    finished_at_ms,
354                }),
355                vec![TimelineLabel::RepoReversible],
356            ))
357            .unwrap();
358    }
359
360    #[test]
361    fn navigation_snapshot_marks_cursor_actions_and_active_path() {
362        let (temp, repo, store) = create_repo();
363        let state0 = repo.head().unwrap().unwrap();
364        let state1 = write_state(&repo, temp.path(), "tracked.txt", "one\n");
365        let state2 = write_state(&repo, temp.path(), "tracked.txt", "two\n");
366
367        store
368            .write_operation(&TimelineOperationEnvelope::new(
369                TimelineOperationBodyV1::BranchCreated(BranchCreatedV1 {
370                    thread: "main".to_string(),
371                    branch_id: branch("tlb-main"),
372                    parent_branch_id: None,
373                    from_step_id: None,
374                    from_state: state0,
375                    reason: TimelineBranchReason::ExplicitFork,
376                    created_at_ms: 1,
377                }),
378                Vec::new(),
379            ))
380            .unwrap();
381        write_finished_step(&store, "tls-one", "tlb-main", "call-1", state0, state1, 2);
382        store
383            .write_operation(&TimelineOperationEnvelope::new(
384                TimelineOperationBodyV1::BranchCreated(BranchCreatedV1 {
385                    thread: "main".to_string(),
386                    branch_id: branch("tlb-child"),
387                    parent_branch_id: Some(branch("tlb-main")),
388                    from_step_id: Some(step("tls-one")),
389                    from_state: state1,
390                    reason: TimelineBranchReason::ExplicitFork,
391                    created_at_ms: 3,
392                }),
393                Vec::new(),
394            ))
395            .unwrap();
396        write_finished_step(
397            &store,
398            "tls-child",
399            "tlb-child",
400            "call-2",
401            state1,
402            state2,
403            4,
404        );
405
406        let snapshot = repo.timeline_navigation_snapshot(&store, "main").unwrap();
407
408        assert_eq!(snapshot.cursor.branch_id, Some(branch("tlb-child")));
409        assert_eq!(snapshot.cursor.step_id, Some(step("tls-child")));
410        assert!(snapshot.actions.can_undo);
411        assert!(!snapshot.actions.can_redo);
412        assert_eq!(
413            snapshot.active_branch_path,
414            vec![branch("tlb-main"), branch("tlb-child")]
415        );
416        assert_eq!(snapshot.branches.len(), 2);
417        assert!(snapshot.branches.iter().any(|branch| branch.is_active));
418        let current = snapshot
419            .steps
420            .iter()
421            .find(|step| step.is_current)
422            .expect("current step");
423        assert_eq!(current.step_id, step("tls-child"));
424        assert_eq!(
425            current
426                .native
427                .as_ref()
428                .map(|native| native.tool_call_id.as_str()),
429            Some("call-2")
430        );
431    }
432
433    #[test]
434    fn navigation_snapshot_surfaces_pending_recovery() {
435        let (temp, repo, store) = create_repo();
436        let state0 = repo.head().unwrap().unwrap();
437        let state1 = write_state(&repo, temp.path(), "tracked.txt", "one\n");
438        let state2 = write_state(&repo, temp.path(), "tracked.txt", "two\n");
439        write_finished_step(&store, "tls-one", "tlb-main", "call-1", state0, state1, 1);
440        write_finished_step(&store, "tls-two", "tlb-main", "call-2", state1, state2, 2);
441
442        store
443            .record_cursor_move(TimelineCursorMoveRecord {
444                thread: "main".to_string(),
445                branch_id: branch("tlb-main"),
446                from_step_id: Some(step("tls-two")),
447                to_step_id: Some(step("tls-one")),
448                from_state: state2,
449                to_state: state1,
450                reason: TimelineCursorMoveReason::Undo,
451                moved_at_ms: 3,
452                labels: Vec::new(),
453            })
454            .unwrap();
455        store
456            .stage_materialization_recovery(&TimelineMaterializationRecoveryRecord::new(
457                "main",
458                branch("tlb-main"),
459                Some(step("tls-one")),
460                Some(step("tls-two")),
461                state1,
462                state2,
463                TimelineCursorMoveReason::Redo,
464                4,
465            ))
466            .unwrap();
467        repo.goto(&state2).unwrap();
468
469        let snapshot = repo.timeline_navigation_snapshot(&store, "main").unwrap();
470        let recovery = snapshot.recovery.expect("pending recovery");
471
472        assert_eq!(
473            recovery.status,
474            TimelineNavigationRecoveryStatus::PendingCursorRecord
475        );
476        assert_eq!(recovery.to_step_id, Some(step("tls-two")));
477        assert_eq!(recovery.checkout_state, Some(state2));
478
479        let outcome = repo
480            .materialize_timeline_cursor(
481                &store,
482                "main",
483                &crate::TimelineSeekSelector::CurrentCursor,
484                TimelineMaterializeMode::FailIfDirty,
485                5,
486            )
487            .unwrap();
488        assert_eq!(
489            outcome.recovery.status,
490            crate::TimelineMaterializationRecoveryStatus::CursorRecorded
491        );
492    }
493
494    #[test]
495    fn navigation_boundary_warning_ignores_external_unknown_only() {
496        let (temp, repo, store) = create_repo();
497        let state0 = repo.head().unwrap().unwrap();
498        let state1 = write_state(&repo, temp.path(), "tracked.txt", "one\n");
499
500        store
501            .write_operation(&TimelineOperationEnvelope::new(
502                TimelineOperationBodyV1::ToolCallFinished(ToolCallFinishedV1 {
503                    thread: "main".to_string(),
504                    step_id: step("tls-external"),
505                    branch_id: branch("tlb-main"),
506                    native: native("call-external"),
507                    status: objects::object::TimelineToolCallStatus::Succeeded,
508                    before_state: state0,
509                    after_state: state1,
510                    capture_state: Some(state1),
511                    capture_oplog_batch_id: None,
512                    changed: true,
513                    touched_paths: Vec::new(),
514                    payload: None,
515                    finished_at_ms: 1,
516                }),
517                vec![
518                    TimelineLabel::RepoReversible,
519                    TimelineLabel::ExternalSideEffectsUnknown,
520                ],
521            ))
522            .unwrap();
523        store
524            .write_operation(&TimelineOperationEnvelope::new(
525                TimelineOperationBodyV1::ToolCallFinished(ToolCallFinishedV1 {
526                    thread: "main".to_string(),
527                    step_id: step("tls-ignored"),
528                    branch_id: branch("tlb-main"),
529                    native: native("call-ignored"),
530                    status: objects::object::TimelineToolCallStatus::Succeeded,
531                    before_state: state1,
532                    after_state: state1,
533                    capture_state: Some(state1),
534                    capture_oplog_batch_id: None,
535                    changed: true,
536                    touched_paths: vec!["ignored.log".to_string()],
537                    payload: None,
538                    finished_at_ms: 2,
539                }),
540                vec![
541                    TimelineLabel::RepoReversible,
542                    TimelineLabel::IgnoredPathTouched,
543                ],
544            ))
545            .unwrap();
546
547        let snapshot = repo.timeline_navigation_snapshot(&store, "main").unwrap();
548        let external_id = step("tls-external");
549        let ignored_id = step("tls-ignored");
550        let external = snapshot
551            .steps
552            .iter()
553            .find(|candidate| candidate.step_id == external_id)
554            .expect("external step");
555        let ignored = snapshot
556            .steps
557            .iter()
558            .find(|candidate| candidate.step_id == ignored_id)
559            .expect("ignored step");
560
561        assert!(!external.has_boundary_warning);
562        assert!(ignored.has_boundary_warning);
563    }
564}