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