1use std::path::PathBuf;
12use std::sync::Arc;
13
14use roder_api::events::{
15 EventEnvelope, RoderEvent, ThreadCreated, ThreadForkFailed, ThreadForkRemoved,
16 ThreadForkRequested, ThreadForked, ThreadId, TurnId,
17};
18use roder_api::forks::{
19 ForkPolicy, ForkReason, ForkRequest, ForkStatus, RemoveForkPolicy, WorkspaceFork,
20};
21use roder_api::thread::{ThreadMetadata, ThreadStore};
22use time::OffsetDateTime;
23
24use crate::Runtime;
25use crate::forks::DEFAULT_FORK_PROVIDER;
26
27#[derive(Debug, Clone)]
28pub struct ForkThreadRequest {
29 pub parent_thread_id: ThreadId,
30 pub name: String,
33 pub from_turn_id: Option<TurnId>,
35 pub provider_id: Option<String>,
37 pub provider_config: serde_json::Value,
39}
40
41impl ForkThreadRequest {
42 pub fn new(parent_thread_id: ThreadId, name: impl Into<String>) -> Self {
43 Self {
44 parent_thread_id,
45 name: name.into(),
46 from_turn_id: None,
47 provider_id: None,
48 provider_config: serde_json::json!({}),
49 }
50 }
51}
52
53#[derive(Debug, Clone)]
54pub struct ForkThreadOutcome {
55 pub child: ThreadMetadata,
56 pub warnings: Vec<String>,
57}
58
59impl Runtime {
60 pub async fn fork_thread(
63 &self,
64 request: ForkThreadRequest,
65 ) -> anyhow::Result<ForkThreadOutcome> {
66 self.emit(RoderEvent::ThreadForkRequested(ThreadForkRequested {
67 parent_thread_id: request.parent_thread_id.clone(),
68 name: request.name.clone(),
69 timestamp: OffsetDateTime::now_utc(),
70 }))
71 .await;
72 match self.fork_thread_inner(&request).await {
73 Ok(outcome) => Ok(outcome),
74 Err(error) => {
75 self.emit(RoderEvent::ThreadForkFailed(ThreadForkFailed {
76 parent_thread_id: request.parent_thread_id.clone(),
77 name: request.name.clone(),
78 message: error.to_string(),
79 timestamp: OffsetDateTime::now_utc(),
80 }))
81 .await;
82 Err(error)
83 }
84 }
85 }
86
87 async fn fork_thread_inner(
88 &self,
89 request: &ForkThreadRequest,
90 ) -> anyhow::Result<ForkThreadOutcome> {
91 let store = self
92 .thread_store
93 .clone()
94 .ok_or_else(|| anyhow::anyhow!("conversation forks require a thread store"))?;
95 let parent = store
96 .load_thread(&request.parent_thread_id)
97 .await?
98 .ok_or_else(|| {
99 anyhow::anyhow!("parent thread {} was not found", request.parent_thread_id)
100 })?;
101 let parent_metadata = parent.metadata.clone().ok_or_else(|| {
102 anyhow::anyhow!(
103 "parent thread {} has no metadata to fork from",
104 request.parent_thread_id
105 )
106 })?;
107
108 let provider_id = request
111 .provider_id
112 .clone()
113 .unwrap_or_else(|| DEFAULT_FORK_PROVIDER.to_string());
114 let fork = self
115 .create_workspace_fork(
116 &provider_id,
117 ForkRequest {
118 source_workspace: PathBuf::from(&parent_metadata.workspace),
119 name: Some(request.name.clone()),
120 reason: ForkReason::ConversationFork,
121 policy: ForkPolicy::default(),
122 provider_config: request.provider_config.clone(),
123 },
124 )
125 .await?;
126
127 let now = OffsetDateTime::now_utc();
128 let seed_events = seed_events_for_child(&parent.events, request.from_turn_id.as_deref())?;
129 let mut warnings = Vec::new();
130 if request.from_turn_id.is_none() && seed_events.is_empty() && !parent.events.is_empty() {
131 warnings.push(
132 "parent thread has events but none were conversation records; the fork starts \
133 with an empty transcript"
134 .to_string(),
135 );
136 }
137
138 let child_id = uuid::Uuid::new_v4().to_string();
139 let child_metadata = ThreadMetadata {
140 thread_id: child_id.clone(),
141 title: Some(match &parent_metadata.title {
142 Some(title) => format!("{title} (fork: {})", request.name),
143 None => format!("fork: {}", request.name),
144 }),
145 workspace: fork.workspace.display().to_string(),
146 workspace_id: None,
148 root_id: None,
149 provider: parent_metadata.provider.clone(),
150 model: parent_metadata.model.clone(),
151 selection_mode: parent_metadata.selection_mode.clone(),
152 tool_allowlist: parent_metadata.tool_allowlist.clone(),
153 developer_instructions: parent_metadata.developer_instructions.clone(),
154 external_tools: parent_metadata.external_tools.clone(),
155 runner_destination: None,
157 runner_state: None,
158 runner_binding: None,
159 created_at: now,
160 updated_at: now,
161 message_count: 0,
162 usage: None,
163 parent_thread_id: Some(request.parent_thread_id.clone()),
164 forked_from_turn_id: request.from_turn_id.clone(),
165 workspace_fork: Some(fork.clone()),
166 };
167
168 if let Err(error) = self
169 .seed_child_thread(&store, child_metadata.clone(), &child_id, seed_events)
170 .await
171 {
172 let _ = self
174 .remove_workspace_fork(
175 &provider_id,
176 &fork.id,
177 RemoveForkPolicy {
178 confirm_workspace: fork.workspace.clone(),
179 },
180 )
181 .await;
182 return Err(error);
183 }
184
185 self.emit(RoderEvent::ThreadCreated(ThreadCreated {
186 thread_id: child_id.clone(),
187 timestamp: OffsetDateTime::now_utc(),
188 }))
189 .await;
190 self.emit(RoderEvent::ThreadForked(ThreadForked {
191 parent_thread_id: request.parent_thread_id.clone(),
192 child_thread_id: child_id.clone(),
193 fork,
194 timestamp: OffsetDateTime::now_utc(),
195 }))
196 .await;
197
198 let child = store
199 .load_thread_metadata(&child_id)
200 .await?
201 .unwrap_or(child_metadata);
202 Ok(ForkThreadOutcome { child, warnings })
203 }
204
205 async fn seed_child_thread(
206 &self,
207 store: &Arc<dyn ThreadStore>,
208 child_metadata: ThreadMetadata,
209 child_id: &ThreadId,
210 seed_events: Vec<EventEnvelope>,
211 ) -> anyhow::Result<()> {
212 store.create_thread(child_metadata).await?;
213 for envelope in &seed_events {
214 store.append_event(child_id, envelope).await?;
215 }
216 Ok(())
217 }
218
219 pub async fn remove_thread_workspace_fork(
226 &self,
227 thread_id: &ThreadId,
228 confirm_path: &str,
229 ) -> anyhow::Result<WorkspaceFork> {
230 let store = self
231 .thread_store
232 .clone()
233 .ok_or_else(|| anyhow::anyhow!("conversation forks require a thread store"))?;
234 let mut metadata = store
235 .load_thread_metadata(thread_id)
236 .await?
237 .ok_or_else(|| anyhow::anyhow!("thread {thread_id} was not found"))?;
238 let mut fork = metadata
239 .workspace_fork
240 .clone()
241 .ok_or_else(|| anyhow::anyhow!("thread {thread_id} is not a workspace fork"))?;
242 anyhow::ensure!(
243 fork.status == ForkStatus::Active,
244 "fork {} was already removed",
245 fork.id
246 );
247 anyhow::ensure!(
248 std::path::Path::new(confirm_path) == fork.workspace,
249 "confirmation path does not match the fork workspace {}; removal is \
250 path-confirmed to prevent accidental deletion",
251 fork.workspace.display()
252 );
253
254 self.remove_workspace_fork(
255 &fork.provider_id.clone(),
256 &fork.id.clone(),
257 RemoveForkPolicy {
258 confirm_workspace: fork.workspace.clone(),
259 },
260 )
261 .await?;
262
263 fork.status = ForkStatus::Removed;
264 metadata.workspace_fork = Some(fork.clone());
265 metadata.updated_at = OffsetDateTime::now_utc();
266 store.update_thread_metadata(metadata).await?;
267
268 self.emit(RoderEvent::ThreadForkRemoved(ThreadForkRemoved {
269 thread_id: thread_id.clone(),
270 fork_id: fork.id.clone(),
271 worktree_path: fork.workspace.display().to_string(),
272 timestamp: OffsetDateTime::now_utc(),
273 }))
274 .await;
275 Ok(fork)
276 }
277}
278
279fn seed_events_for_child(
286 events: &[EventEnvelope],
287 from_turn_id: Option<&str>,
288) -> anyhow::Result<Vec<EventEnvelope>> {
289 let mut ordered: Vec<&EventEnvelope> = events.iter().collect();
290 ordered.sort_by_key(|envelope| envelope.seq);
291
292 let cutoff = match from_turn_id {
293 Some(turn_id) => {
294 let last = ordered
295 .iter()
296 .rposition(|envelope| envelope.turn_id.as_deref() == Some(turn_id))
297 .ok_or_else(|| {
298 anyhow::anyhow!("turn {turn_id} was not found in the parent thread")
299 })?;
300 last + 1
301 }
302 None => ordered.len(),
303 };
304
305 Ok(ordered[..cutoff]
306 .iter()
307 .filter(|envelope| {
308 matches!(
309 envelope.event,
310 RoderEvent::TurnStarted(_)
311 | RoderEvent::TranscriptItemAppended(_)
312 | RoderEvent::TurnCompleted(_)
313 | RoderEvent::TurnFailed(_)
314 | RoderEvent::TurnInterrupted(_)
315 )
316 })
317 .map(|envelope| (*envelope).clone())
318 .collect())
319}
320
321#[cfg(test)]
322mod tests {
323 use super::*;
324 use roder_api::events::{EventSource, TranscriptItemAppended, TurnCompleted, TurnStarted};
325 use roder_api::transcript::{TranscriptItem, UserMessage};
326
327 fn envelope(seq: u64, turn_id: &str, event: RoderEvent) -> EventEnvelope {
328 EventEnvelope {
329 event_id: format!("event-{seq}"),
330 seq,
331 timestamp: OffsetDateTime::UNIX_EPOCH,
332 source: EventSource::Core,
333 kind: event.kind().to_string(),
334 thread_id: Some("parent".to_string()),
335 turn_id: Some(turn_id.to_string()),
336 event,
337 }
338 }
339
340 fn turn_events(seq: u64, turn_id: &str, text: &str) -> Vec<EventEnvelope> {
341 vec![
342 envelope(
343 seq,
344 turn_id,
345 RoderEvent::TurnStarted(TurnStarted {
346 thread_id: "parent".to_string(),
347 turn_id: turn_id.to_string(),
348 runtime_profile: Default::default(),
349 timestamp: OffsetDateTime::UNIX_EPOCH,
350 }),
351 ),
352 envelope(
353 seq + 1,
354 turn_id,
355 RoderEvent::TranscriptItemAppended(TranscriptItemAppended {
356 thread_id: "parent".to_string(),
357 turn_id: turn_id.to_string(),
358 item_type: "user_message".to_string(),
359 item_index: None,
360 item: Some(TranscriptItem::UserMessage(UserMessage::text(text))),
361 timestamp: OffsetDateTime::UNIX_EPOCH,
362 }),
363 ),
364 envelope(
365 seq + 2,
366 turn_id,
367 RoderEvent::TurnCompleted(TurnCompleted {
368 thread_id: "parent".to_string(),
369 turn_id: turn_id.to_string(),
370 usage: None,
371 finish_reason: Some("stop".to_string()),
372 timestamp: OffsetDateTime::UNIX_EPOCH,
373 }),
374 ),
375 ]
376 }
377
378 #[test]
379 fn seed_events_keep_conversation_records_only() {
380 let mut events = turn_events(1, "turn-1", "hello");
381 events.push(envelope(
382 4,
383 "turn-1",
384 RoderEvent::ToolCallStarted(roder_api::events::ToolCallStarted {
385 thread_id: "parent".to_string(),
386 turn_id: "turn-1".to_string(),
387 tool_id: "call-1".to_string(),
388 tool_name: Some("shell".to_string()),
389 display_payload: None,
390 timestamp: OffsetDateTime::UNIX_EPOCH,
391 }),
392 ));
393
394 let seeded = seed_events_for_child(&events, None).unwrap();
395
396 assert_eq!(seeded.len(), 3, "tool events must not be replayed");
397 assert!(
398 seeded
399 .iter()
400 .all(|envelope| !matches!(envelope.event, RoderEvent::ToolCallStarted(_)))
401 );
402 }
403
404 #[test]
405 fn seed_events_truncate_at_requested_turn() {
406 let mut events = turn_events(1, "turn-1", "first");
407 events.extend(turn_events(10, "turn-2", "second"));
408
409 let seeded = seed_events_for_child(&events, Some("turn-1")).unwrap();
410 assert_eq!(seeded.len(), 3);
411 assert!(
412 seeded
413 .iter()
414 .all(|envelope| envelope.turn_id.as_deref() == Some("turn-1"))
415 );
416
417 let error = seed_events_for_child(&events, Some("missing-turn")).unwrap_err();
418 assert!(error.to_string().contains("missing-turn"));
419 }
420}