1use 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}