use anyhow::{Result, anyhow};
use objects::object::{ChangeId, ContentHash};
use repo::{
NativeToolCallRefV1, Repository, TimelineBranchId, TimelineBranchReason,
TimelineCursorMoveReason, TimelineLabel, TimelineMaterializationRecoveryStatus,
TimelineMaterializeMode, TimelineMaterializeStatus, TimelineNativeToolKey,
TimelineNavigationRecoveryStatus, TimelineOperationBodyV1, TimelineOperationEnvelope,
TimelineSeekBranchConstraint, TimelineSeekSelector, TimelineStepId, TimelineStore,
TimelineToolCallStatus, TimelineToolPayloadMetadata, TimelineView, ToolCallFinishedV1,
ToolCallStartedV1,
};
use serde::Serialize;
use super::advice::RecoveryAdvice;
use crate::cli::{
Cli, TimelineArgs, TimelineCommands, TimelineForkArgs, TimelineRecordFinishArgs,
TimelineRecordStartArgs, TimelineRecordToolArgs, TimelineRecoverArgs, TimelineResetArgs,
TimelineStatusArgs, TimelineTargetArgs, should_output_json, style,
};
const TIMELINE_RESET_CURRENT_COMMAND: &str = "heddle timeline reset --thread <thread> --current";
const TIMELINE_TOOL_CALL_COMMAND: &str =
"heddle timeline reset --thread <thread> --tool-call <tool-call-id> --harness opencode";
pub fn cmd_timeline(cli: &Cli, args: TimelineArgs) -> Result<()> {
let start = cli
.repo
.clone()
.unwrap_or(std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from(".")));
let repo = Repository::open(start)?;
let store = TimelineStore::open(repo.heddle_dir())?;
match args.command {
TimelineCommands::Status(args) => cmd_timeline_status(cli, &repo, &store, args),
TimelineCommands::RecordStart(args) => cmd_timeline_record_start(cli, &repo, &store, args),
TimelineCommands::RecordFinish(args) => {
cmd_timeline_record_finish(cli, &repo, &store, args)
}
TimelineCommands::Fork(args) => cmd_timeline_fork(cli, &repo, &store, args),
TimelineCommands::Reset(args) => cmd_timeline_reset(cli, &repo, &store, args),
TimelineCommands::Recover(args) => cmd_timeline_recover(cli, &repo, &store, args),
}
}
fn cmd_timeline_status(
cli: &Cli,
repo: &Repository,
store: &TimelineStore,
args: TimelineStatusArgs,
) -> Result<()> {
if args.thread.trim().is_empty() {
return Err(anyhow!(RecoveryAdvice::missing_option(
"timeline_thread_required",
"--thread",
"timeline status",
"heddle timeline status --thread <thread>",
)));
}
let snapshot = repo.timeline_navigation_snapshot(store, &args.thread)?;
let current_step = snapshot
.cursor
.step_id
.as_ref()
.and_then(|cursor| snapshot.steps.iter().find(|step| &step.step_id == cursor))
.map(|step| TimelineStatusStepOutput {
step_id: step.step_id.to_string(),
branch_id: step.branch_id.to_string(),
parent_step_id: step.parent_step_id.as_ref().map(ToString::to_string),
tool_name: step.tool_name.clone(),
tool_status: step.status.as_ref().map(timeline_tool_status_label),
changed: step.changed,
payload_summary: step.payload_summary.clone(),
payload_hash: step.payload_hash.map(|hash| hash.to_hex()),
labels: step.labels.iter().map(timeline_label).collect(),
started_at_ms: step.started_at_ms,
finished_at_ms: step.finished_at_ms,
can_seek: step.can_seek,
can_fork: step.can_fork,
can_reset: step.can_reset,
can_materialize: step.can_materialize,
has_boundary_warning: step.has_boundary_warning,
});
let output = TimelineStatusOutput {
output_kind: "timeline_status",
status: "ok",
thread: snapshot.thread,
cursor_branch_id: snapshot.cursor.branch_id.as_ref().map(ToString::to_string),
cursor_step_id: snapshot.cursor.step_id.as_ref().map(ToString::to_string),
cursor_state: snapshot.cursor.state.map(|state| state.to_string_full()),
current_step,
active_branch_path: snapshot
.active_branch_path
.iter()
.map(ToString::to_string)
.collect(),
can_undo: snapshot.actions.can_undo,
can_redo: snapshot.actions.can_redo,
branch_count: snapshot.branches.len(),
step_count: snapshot.steps.len(),
recovery: snapshot
.recovery
.map(|recovery| TimelineStatusRecoveryOutput {
status: timeline_navigation_recovery_status_label(&recovery.status),
branch_id: recovery.branch_id.to_string(),
from_step_id: recovery.from_step_id.as_ref().map(ToString::to_string),
to_step_id: recovery.to_step_id.as_ref().map(ToString::to_string),
from_state: recovery.from_state.to_string_full(),
to_state: recovery.to_state.to_string_full(),
reason: timeline_cursor_move_reason_label(&recovery.reason).to_string(),
moved_at_ms: recovery.moved_at_ms,
checkout_state: recovery.checkout_state.map(|state| state.to_string_full()),
}),
};
print_timeline_status(cli, repo, output)
}
fn cmd_timeline_record_start(
cli: &Cli,
repo: &Repository,
store: &TimelineStore,
args: TimelineRecordStartArgs,
) -> Result<()> {
let native = native_tool_ref(&args.tool)?;
let step_id = recording_step_id(&args.tool, &native);
let before_state = require_current_change(repo, "timeline record-start")?;
let payload = payload_metadata(&args.tool)?;
let _record_guard = store.lock_recording(&args.tool.thread)?;
let view = TimelineView::rebuild(store)?;
let (branch_id, parent_step_id) = timeline_position_for_recording(&view, &args.tool, &step_id);
let envelope = TimelineOperationEnvelope::new(
TimelineOperationBodyV1::ToolCallStarted(ToolCallStartedV1 {
thread: args.tool.thread.clone(),
step_id: step_id.clone(),
branch_id: branch_id.clone(),
parent_step_id: parent_step_id.clone(),
native,
tool_name: args.tool_name,
before_state,
payload,
started_at_ms: now_ms(),
}),
vec![TimelineLabel::ExternalSideEffectsUnknown],
);
let operation_id = store.write_operation(&envelope)?;
let snapshot = repo.timeline_navigation_snapshot(store, &args.tool.thread)?;
let output = TimelineRecordingOutput {
output_kind: "timeline_record_start",
status: "ok",
action: "record-start",
thread: args.tool.thread,
step_id: step_id.to_string(),
branch_id: branch_id.to_string(),
parent_step_id: parent_step_id.as_ref().map(ToString::to_string),
operation_id: operation_id.to_string_full(),
before_state: Some(before_state.to_string_full()),
after_state: None,
changed: None,
tool_status: None,
payload_summary: args.tool.summary,
payload_hash: payload_hash_string(args.tool.payload_hash.as_deref())?,
branch_count: snapshot.branches.len(),
step_count: snapshot.steps.len(),
};
print_timeline_recording(cli, repo, output)
}
fn cmd_timeline_record_finish(
cli: &Cli,
repo: &Repository,
store: &TimelineStore,
args: TimelineRecordFinishArgs,
) -> Result<()> {
let tool_status = parse_tool_status(&args.status)?;
let tool_status_label = timeline_tool_status_label(&tool_status);
let native = native_tool_ref(&args.tool)?;
let step_id = recording_step_id(&args.tool, &native);
let payload = payload_metadata(&args.tool)?;
let _record_guard = store.lock_recording(&args.tool.thread)?;
let view = TimelineView::rebuild(store)?;
let existing_step = view.step(&args.tool.thread, &step_id).cloned();
let (branch_id, parent_step_id) = existing_step
.as_ref()
.map(|step| (step.branch_id.clone(), step.parent_step_id.clone()))
.unwrap_or_else(|| timeline_position_for_recording(&view, &args.tool, &step_id));
let current_state = require_current_change(repo, "timeline record-finish")?;
let before_state = existing_step
.as_ref()
.and_then(|step| step.before_state)
.unwrap_or(current_state);
let after_state = current_state;
let changed = before_state != after_state;
let mut labels = vec![TimelineLabel::ExternalSideEffectsUnknown];
if changed {
labels.push(TimelineLabel::RepoReversible);
}
let envelope = TimelineOperationEnvelope::new(
TimelineOperationBodyV1::ToolCallFinished(ToolCallFinishedV1 {
thread: args.tool.thread.clone(),
step_id: step_id.clone(),
branch_id: branch_id.clone(),
native,
status: tool_status,
before_state,
after_state,
capture_state: None,
capture_oplog_batch_id: None,
changed,
touched_paths: Vec::new(),
payload,
finished_at_ms: now_ms(),
}),
labels,
);
let operation_id = store.write_operation(&envelope)?;
let snapshot = repo.timeline_navigation_snapshot(store, &args.tool.thread)?;
let output = TimelineRecordingOutput {
output_kind: "timeline_record_finish",
status: "ok",
action: "record-finish",
thread: args.tool.thread,
step_id: step_id.to_string(),
branch_id: branch_id.to_string(),
parent_step_id: parent_step_id.as_ref().map(ToString::to_string),
operation_id: operation_id.to_string_full(),
before_state: Some(before_state.to_string_full()),
after_state: Some(after_state.to_string_full()),
changed: Some(changed),
tool_status: Some(tool_status_label),
payload_summary: args.tool.summary,
payload_hash: payload_hash_string(args.tool.payload_hash.as_deref())?,
branch_count: snapshot.branches.len(),
step_count: snapshot.steps.len(),
};
print_timeline_recording(cli, repo, output)
}
fn cmd_timeline_fork(
cli: &Cli,
repo: &Repository,
store: &TimelineStore,
args: TimelineForkArgs,
) -> Result<()> {
let selection = target_selection(&args.target)?;
let reason = parse_branch_reason(&args.reason)?;
let outcome = repo.fork_timeline_from_selector(
store,
&selection.thread,
&selection.selector,
selection.branch_constraint.as_ref(),
args.branch.map(TimelineBranchId::new),
reason,
now_ms(),
)?;
let output = TimelineActionOutput {
output_kind: "timeline_action",
status: "completed",
action: "fork",
thread: selection.thread,
branch_id: Some(outcome.branch_id.to_string()),
parent_branch_id: Some(outcome.parent_branch_id.to_string()),
from_step_id: outcome.from_step_id.map(|id| id.to_string()),
cursor_branch_id: outcome
.navigation
.cursor
.branch_id
.as_ref()
.map(ToString::to_string),
cursor_step_id: outcome
.navigation
.cursor
.step_id
.as_ref()
.map(ToString::to_string),
operation_id: Some(outcome.operation_id.to_string_full()),
recovered_operation_id: None,
materialized: None,
materialization_status: None,
recovery_status: None,
blocker_count: 0,
branch_count: outcome.navigation.branches.len(),
step_count: outcome.navigation.steps.len(),
};
print_timeline_action(cli, repo, output)
}
fn cmd_timeline_reset(
cli: &Cli,
repo: &Repository,
store: &TimelineStore,
args: TimelineResetArgs,
) -> Result<()> {
let selection = target_selection(&args.target)?;
let mode = parse_materialize_mode(&args.mode)?;
let outcome = repo.reset_timeline_cursor(
store,
&selection.thread,
&selection.selector,
mode,
selection.branch_constraint.as_ref(),
args.materialize,
now_ms(),
)?;
let materialized = outcome.materialization.as_ref().map(|materialization| {
matches!(
materialization.status,
TimelineMaterializeStatus::Materialized | TimelineMaterializeStatus::AlreadyAtTarget
)
});
let materialization_status = outcome
.materialization
.as_ref()
.map(|materialization| materialize_status_label(&materialization.status).to_string());
let blocker_count = outcome
.materialization
.as_ref()
.map(|materialization| {
materialization.preview.blockers.len()
+ usize::from(materialization.recovery.blocker.is_some())
})
.unwrap_or_default();
let output = TimelineActionOutput {
output_kind: "timeline_action",
status: "completed",
action: "reset",
thread: selection.thread,
branch_id: outcome
.navigation
.cursor
.branch_id
.as_ref()
.map(ToString::to_string),
parent_branch_id: None,
from_step_id: None,
cursor_branch_id: outcome
.navigation
.cursor
.branch_id
.as_ref()
.map(ToString::to_string),
cursor_step_id: outcome
.navigation
.cursor
.step_id
.as_ref()
.map(ToString::to_string),
operation_id: outcome.cursor_operation_id.map(|id| id.to_string_full()),
recovered_operation_id: outcome
.materialization
.as_ref()
.and_then(|materialization| materialization.recovery.cursor_operation_id)
.map(|id| id.to_string_full()),
materialized,
materialization_status,
recovery_status: outcome.materialization.as_ref().map(|materialization| {
recovery_status_label(&materialization.recovery.status).to_string()
}),
blocker_count,
branch_count: outcome.navigation.branches.len(),
step_count: outcome.navigation.steps.len(),
};
print_timeline_action(cli, repo, output)
}
fn cmd_timeline_recover(
cli: &Cli,
repo: &Repository,
store: &TimelineStore,
args: TimelineRecoverArgs,
) -> Result<()> {
let outcome = repo.recover_timeline_materialization_action(store, &args.thread)?;
let output = TimelineActionOutput {
output_kind: "timeline_action",
status: "completed",
action: "recover",
thread: args.thread,
branch_id: outcome
.navigation
.cursor
.branch_id
.as_ref()
.map(ToString::to_string),
parent_branch_id: None,
from_step_id: None,
cursor_branch_id: outcome
.navigation
.cursor
.branch_id
.as_ref()
.map(ToString::to_string),
cursor_step_id: outcome
.navigation
.cursor
.step_id
.as_ref()
.map(ToString::to_string),
operation_id: None,
recovered_operation_id: outcome
.recovery
.cursor_operation_id
.map(|id| id.to_string_full()),
materialized: None,
materialization_status: None,
recovery_status: Some(recovery_status_label(&outcome.recovery.status).to_string()),
blocker_count: usize::from(outcome.recovery.blocker.is_some()),
branch_count: outcome.navigation.branches.len(),
step_count: outcome.navigation.steps.len(),
};
print_timeline_action(cli, repo, output)
}
fn print_timeline_action(cli: &Cli, repo: &Repository, output: TimelineActionOutput) -> Result<()> {
if should_output_json(cli, Some(repo.config())) {
println!("{}", serde_json::to_string(&output)?);
return Ok(());
}
match output.action {
"fork" => {
let branch = output.branch_id.as_deref().unwrap_or("-");
let parent = output.parent_branch_id.as_deref().unwrap_or("-");
let step = output.from_step_id.as_deref().unwrap_or("cursor");
println!(
"Forked timeline branch {} from {}/{}",
style::bold(branch),
parent,
step
);
}
"reset" => {
let branch = output.cursor_branch_id.as_deref().unwrap_or("-");
let step = output.cursor_step_id.as_deref().unwrap_or("-");
println!("Reset timeline cursor to {branch}/{step}");
if let Some(status) = &output.materialization_status {
println!(
"Materialization: {}{}",
status,
if output.blocker_count > 0 {
format!(
" ({} blocker{})",
output.blocker_count,
plural(output.blocker_count)
)
} else {
String::new()
}
);
}
}
"recover" => {
println!(
"Timeline recovery: {}",
output.recovery_status.as_deref().unwrap_or("unknown")
);
if output.blocker_count > 0 {
println!(
"Blockers: {} item{}",
output.blocker_count,
plural(output.blocker_count)
);
}
}
_ => {}
}
Ok(())
}
fn print_timeline_status(cli: &Cli, repo: &Repository, output: TimelineStatusOutput) -> Result<()> {
if should_output_json(cli, Some(repo.config())) {
println!("{}", serde_json::to_string(&output)?);
return Ok(());
}
println!(
"Timeline {}: {} step{} on {} branch{}",
style::bold(&output.thread),
output.step_count,
plural(output.step_count),
output.branch_count,
plural(output.branch_count)
);
if let Some(step) = &output.cursor_step_id {
println!(
"Cursor: {}/{}",
output.cursor_branch_id.as_deref().unwrap_or("-"),
step
);
} else {
println!("Cursor: none");
}
if let Some(recovery) = &output.recovery {
println!("Recovery: {}", recovery.status);
}
Ok(())
}
fn print_timeline_recording(
cli: &Cli,
repo: &Repository,
output: TimelineRecordingOutput,
) -> Result<()> {
if should_output_json(cli, Some(repo.config())) {
println!("{}", serde_json::to_string(&output)?);
return Ok(());
}
println!(
"Recorded timeline {} {} ({})",
output.action,
style::bold(&output.step_id),
output.operation_id
);
Ok(())
}
fn native_tool_ref(args: &TimelineRecordToolArgs) -> Result<NativeToolCallRefV1> {
if args.thread.trim().is_empty() {
return Err(anyhow!(RecoveryAdvice::missing_option(
"timeline_thread_required",
"--thread",
"timeline recording",
"heddle timeline record-start --thread <thread> --tool-call <id>",
)));
}
if args.harness.trim().is_empty() {
return Err(anyhow!(RecoveryAdvice::missing_option(
"timeline_record_harness_required",
"--harness",
"timeline recording",
"heddle timeline record-start --harness opencode --tool-call <id>",
)));
}
if args.tool_call.trim().is_empty() {
return Err(anyhow!(RecoveryAdvice::missing_option(
"timeline_record_tool_call_required",
"--tool-call",
"timeline recording",
"heddle timeline record-start --tool-call <id>",
)));
}
Ok(NativeToolCallRefV1 {
harness: args.harness.clone(),
session_id: args.session.clone(),
message_id: args.message.clone(),
tool_call_id: args.tool_call.clone(),
})
}
fn recording_step_id(
args: &TimelineRecordToolArgs,
native: &NativeToolCallRefV1,
) -> TimelineStepId {
args.step_id
.as_ref()
.map(|step| TimelineStepId::new(step.clone()))
.unwrap_or_else(|| {
let key = format!(
"{}\0{}\0{}\0{}",
native.harness,
native.session_id.as_deref().unwrap_or(""),
native.message_id.as_deref().unwrap_or(""),
native.tool_call_id
);
let hash =
ContentHash::compute_typed("timeline-native-tool-call-v1", key.as_bytes()).to_hex();
TimelineStepId::new(format!("tls-{}", &hash[..24]))
})
}
fn timeline_position_for_recording(
view: &TimelineView,
args: &TimelineRecordToolArgs,
step_id: &TimelineStepId,
) -> (TimelineBranchId, Option<TimelineStepId>) {
let branch_id = args
.branch
.as_ref()
.map(|branch| TimelineBranchId::new(branch.clone()))
.or_else(|| {
view.status(&args.thread)
.and_then(|status| status.current_branch_id.clone())
})
.unwrap_or_else(|| TimelineBranchId::new("tlb-main"));
let parent_step_id = view
.status(&args.thread)
.and_then(|status| status.current_step_id.clone())
.filter(|current| current != step_id);
(branch_id, parent_step_id)
}
fn payload_metadata(args: &TimelineRecordToolArgs) -> Result<Option<TimelineToolPayloadMetadata>> {
let hash = match args.payload_hash.as_deref() {
Some(hash) => Some(parse_payload_hash(hash)?),
None => None,
};
if args.summary.is_none() && hash.is_none() {
return Ok(None);
}
Ok(Some(TimelineToolPayloadMetadata {
summary: args.summary.clone(),
hash,
}))
}
fn parse_payload_hash(value: &str) -> Result<ContentHash> {
ContentHash::from_hex(value).map_err(|_| {
anyhow!(RecoveryAdvice::malformed_option_value(
"timeline_payload_hash_invalid",
"--payload-hash",
value,
"a 64-character hex content hash",
"heddle timeline record-start --tool-call <id> --payload-hash <hex>",
))
})
}
fn payload_hash_string(value: Option<&str>) -> Result<Option<String>> {
value
.map(|hash| parse_payload_hash(hash).map(|parsed| parsed.to_hex()))
.transpose()
}
fn require_current_change(repo: &Repository, context: &str) -> Result<ChangeId> {
repo.current_state()?
.map(|state| state.change_id)
.or(repo.head()?)
.ok_or_else(|| anyhow!("{context} requires a repository state"))
}
fn parse_tool_status(value: &str) -> Result<TimelineToolCallStatus> {
match value {
"succeeded" => Ok(TimelineToolCallStatus::Succeeded),
"failed" => Ok(TimelineToolCallStatus::Failed),
"cancelled" => Ok(TimelineToolCallStatus::Cancelled),
other => Err(anyhow!(RecoveryAdvice::malformed_option_value(
"timeline_tool_status_invalid",
"--status",
other,
"succeeded, failed, or cancelled",
"heddle timeline record-finish --tool-call <id> --status succeeded",
))),
}
}
fn timeline_tool_status_label(status: &TimelineToolCallStatus) -> &'static str {
match status {
TimelineToolCallStatus::Succeeded => "succeeded",
TimelineToolCallStatus::Failed => "failed",
TimelineToolCallStatus::Cancelled => "cancelled",
}
}
fn timeline_cursor_move_reason_label(reason: &TimelineCursorMoveReason) -> &'static str {
match reason {
TimelineCursorMoveReason::SeekToolCall => "seek-tool-call",
TimelineCursorMoveReason::Undo => "undo",
TimelineCursorMoveReason::Redo => "redo",
TimelineCursorMoveReason::Reset => "reset",
TimelineCursorMoveReason::AutoAdvance => "auto-advance",
}
}
fn timeline_label(label: &TimelineLabel) -> &'static str {
match label {
TimelineLabel::RepoReversible => "repo-reversible",
TimelineLabel::ExternalSideEffectsUnknown => "external-side-effects-unknown",
TimelineLabel::IgnoredPathTouched => "ignored-path-touched",
TimelineLabel::OutsideRepoTouched => "outside-repo-touched",
TimelineLabel::PurgeBoundary => "purge-boundary",
TimelineLabel::CaptureFailed => "capture-failed",
}
}
fn timeline_navigation_recovery_status_label(
status: &TimelineNavigationRecoveryStatus,
) -> &'static str {
match status {
TimelineNavigationRecoveryStatus::PendingCursorRecord => "pending-cursor-record",
TimelineNavigationRecoveryStatus::Blocked => "blocked",
TimelineNavigationRecoveryStatus::AlreadyApplied => "already-applied",
}
}
#[derive(Debug)]
struct TimelineSelection {
thread: String,
selector: TimelineSeekSelector,
branch_constraint: Option<TimelineSeekBranchConstraint>,
}
fn target_selection(args: &TimelineTargetArgs) -> Result<TimelineSelection> {
if args.thread.trim().is_empty() {
return Err(anyhow!(RecoveryAdvice::missing_option(
"timeline_thread_required",
"--thread",
"timeline navigation",
TIMELINE_RESET_CURRENT_COMMAND,
)));
}
let selected = args.step.is_some() as u8
+ args.tool_call.is_some() as u8
+ args.undo as u8
+ args.redo as u8
+ args.current as u8;
if selected != 1 {
return Err(anyhow!(RecoveryAdvice::invalid_usage(
"timeline_target_required",
"select exactly one timeline target: --step, --tool-call, --undo, --redo, or --current",
"Set exactly one timeline selector. Use `--current` to target the current cursor.",
TIMELINE_RESET_CURRENT_COMMAND,
)));
}
let branch = args
.from_branch
.as_ref()
.map(|branch| TimelineBranchId::new(branch.clone()));
let (selector, branch_constraint) = if let Some(step_id) = &args.step {
(
TimelineSeekSelector::StepId(TimelineStepId::new(step_id.clone())),
branch.map(TimelineSeekBranchConstraint::Target),
)
} else if let Some(tool_call_id) = &args.tool_call {
if args.harness.trim().is_empty() {
return Err(anyhow!(RecoveryAdvice::missing_option(
"timeline_tool_call_harness_required",
"--harness",
"--tool-call timeline targets",
TIMELINE_TOOL_CALL_COMMAND,
)));
}
(
TimelineSeekSelector::NativeToolCall(TimelineNativeToolKey {
harness: args.harness.clone(),
session_id: args.session.clone(),
message_id: args.message.clone(),
tool_call_id: tool_call_id.clone(),
}),
None,
)
} else if args.undo {
(
TimelineSeekSelector::Undo,
branch.map(TimelineSeekBranchConstraint::Current),
)
} else if args.redo {
(
TimelineSeekSelector::Redo,
branch.map(TimelineSeekBranchConstraint::Current),
)
} else {
(
TimelineSeekSelector::CurrentCursor,
branch.map(TimelineSeekBranchConstraint::Current),
)
};
Ok(TimelineSelection {
thread: args.thread.clone(),
selector,
branch_constraint,
})
}
fn parse_branch_reason(value: &str) -> Result<TimelineBranchReason> {
match value {
"explicit-fork" => Ok(TimelineBranchReason::ExplicitFork),
"edit-from-rewound-cursor" => Ok(TimelineBranchReason::EditFromRewoundCursor),
"retry" => Ok(TimelineBranchReason::Retry),
"fan-out" => Ok(TimelineBranchReason::FanOut),
other => Err(anyhow!(RecoveryAdvice::malformed_option_value(
"timeline_branch_reason_invalid",
"--reason",
other,
"explicit-fork, edit-from-rewound-cursor, retry, or fan-out",
"heddle timeline fork --thread <thread> --current --reason explicit-fork",
))),
}
}
fn parse_materialize_mode(value: &str) -> Result<TimelineMaterializeMode> {
match value {
"fail-if-dirty" => Ok(TimelineMaterializeMode::FailIfDirty),
"capture-current-then-seek" => Ok(TimelineMaterializeMode::CaptureCurrentThenSeek),
other => Err(anyhow!(RecoveryAdvice::malformed_option_value(
"timeline_materialize_mode_invalid",
"--mode",
other,
"fail-if-dirty or capture-current-then-seek",
"heddle timeline reset --thread <thread> --current --mode fail-if-dirty",
))),
}
}
fn materialize_status_label(status: &TimelineMaterializeStatus) -> &'static str {
match status {
TimelineMaterializeStatus::Materialized => "materialized",
TimelineMaterializeStatus::AlreadyAtTarget => "already-at-target",
TimelineMaterializeStatus::Refused => "refused",
TimelineMaterializeStatus::Unsupported => "unsupported",
TimelineMaterializeStatus::RecoveryBlocked => "recovery-blocked",
}
}
fn recovery_status_label(status: &TimelineMaterializationRecoveryStatus) -> &'static str {
match status {
TimelineMaterializationRecoveryStatus::NoPending => "no-pending",
TimelineMaterializationRecoveryStatus::CursorRecorded => "cursor-recorded",
TimelineMaterializationRecoveryStatus::AlreadyApplied => "already-applied",
TimelineMaterializationRecoveryStatus::Blocked => "blocked",
}
}
fn plural(count: usize) -> &'static str {
if count == 1 { "" } else { "s" }
}
fn now_ms() -> i64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|duration| duration.as_millis() as i64)
.unwrap_or_default()
}
#[derive(Serialize)]
struct TimelineStatusOutput {
output_kind: &'static str,
status: &'static str,
thread: String,
cursor_branch_id: Option<String>,
cursor_step_id: Option<String>,
cursor_state: Option<String>,
current_step: Option<TimelineStatusStepOutput>,
active_branch_path: Vec<String>,
can_undo: bool,
can_redo: bool,
branch_count: usize,
step_count: usize,
recovery: Option<TimelineStatusRecoveryOutput>,
}
#[derive(Serialize)]
struct TimelineStatusStepOutput {
step_id: String,
branch_id: String,
parent_step_id: Option<String>,
tool_name: Option<String>,
tool_status: Option<&'static str>,
changed: Option<bool>,
payload_summary: Option<String>,
payload_hash: Option<String>,
labels: Vec<&'static str>,
started_at_ms: Option<i64>,
finished_at_ms: Option<i64>,
can_seek: bool,
can_fork: bool,
can_reset: bool,
can_materialize: bool,
has_boundary_warning: bool,
}
#[derive(Serialize)]
struct TimelineStatusRecoveryOutput {
status: &'static str,
branch_id: String,
from_step_id: Option<String>,
to_step_id: Option<String>,
from_state: String,
to_state: String,
reason: String,
moved_at_ms: i64,
checkout_state: Option<String>,
}
#[derive(Serialize)]
struct TimelineRecordingOutput {
output_kind: &'static str,
status: &'static str,
action: &'static str,
thread: String,
step_id: String,
branch_id: String,
parent_step_id: Option<String>,
operation_id: String,
before_state: Option<String>,
after_state: Option<String>,
changed: Option<bool>,
tool_status: Option<&'static str>,
payload_summary: Option<String>,
payload_hash: Option<String>,
branch_count: usize,
step_count: usize,
}
#[derive(Serialize)]
struct TimelineActionOutput {
output_kind: &'static str,
status: &'static str,
action: &'static str,
thread: String,
branch_id: Option<String>,
parent_branch_id: Option<String>,
from_step_id: Option<String>,
cursor_branch_id: Option<String>,
cursor_step_id: Option<String>,
operation_id: Option<String>,
recovered_operation_id: Option<String>,
materialized: Option<bool>,
materialization_status: Option<String>,
recovery_status: Option<String>,
blocker_count: usize,
branch_count: usize,
step_count: usize,
}
#[cfg(test)]
mod tests {
use super::*;
fn target() -> TimelineTargetArgs {
TimelineTargetArgs {
thread: "main".to_string(),
from_branch: None,
step: None,
tool_call: None,
harness: "opencode".to_string(),
session: None,
message: None,
undo: false,
redo: false,
current: false,
}
}
#[test]
fn target_selection_requires_one_target() {
assert!(target_selection(&target()).is_err());
let mut args = target();
args.step = Some("tls-one".to_string());
args.tool_call = Some("call-1".to_string());
assert!(target_selection(&args).is_err());
}
#[test]
fn target_selection_builds_native_tool_call_selector() {
let mut args = target();
args.tool_call = Some("call-1".to_string());
args.session = Some("session-1".to_string());
let selection = target_selection(&args).unwrap();
let TimelineSeekSelector::NativeToolCall(native) = selection.selector else {
panic!("expected native tool call selector");
};
assert_eq!(native.harness, "opencode");
assert_eq!(native.session_id.as_deref(), Some("session-1"));
assert_eq!(native.tool_call_id, "call-1");
assert!(selection.branch_constraint.is_none());
}
}