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
27mod history;
28use history::seed_events_for_child;
29
30#[derive(Debug, Clone)]
31pub struct ForkThreadRequest {
32 pub parent_thread_id: ThreadId,
33 pub name: String,
36 pub from_turn_id: Option<TurnId>,
38 pub provider_id: Option<String>,
40 pub provider_config: serde_json::Value,
42}
43
44impl ForkThreadRequest {
45 pub fn new(parent_thread_id: ThreadId, name: impl Into<String>) -> Self {
46 Self {
47 parent_thread_id,
48 name: name.into(),
49 from_turn_id: None,
50 provider_id: None,
51 provider_config: serde_json::json!({}),
52 }
53 }
54}
55
56#[derive(Debug, Clone)]
57pub struct ForkThreadOutcome {
58 pub child: ThreadMetadata,
59 pub warnings: Vec<String>,
60}
61
62impl Runtime {
63 pub(crate) async fn seed_agent_thread_history(
68 &self,
69 parent_thread_id: &ThreadId,
70 child_thread_id: &ThreadId,
71 fork_turns: &str,
72 ) -> anyhow::Result<()> {
73 if fork_turns == "none" {
74 return Ok(());
75 }
76 let Some(store) = self.thread_store.clone() else {
77 return Ok(());
78 };
79 let Some(parent) = store.load_thread(parent_thread_id).await? else {
80 return Ok(());
81 };
82 let mut events = seed_events_for_child(&parent.events, None)?;
83 if fork_turns != "all" {
84 let turn_count = fork_turns.parse::<usize>().map_err(|_| {
85 anyhow::anyhow!("fork_turns must be one of none, all, or a positive integer")
86 })?;
87 anyhow::ensure!(turn_count > 0, "fork_turns integer must be positive");
88 let mut ordered_turns = Vec::<TurnId>::new();
89 for event in &events {
90 if let Some(turn_id) = event.turn_id.as_ref()
91 && ordered_turns.last() != Some(turn_id)
92 {
93 ordered_turns.push(turn_id.clone());
94 }
95 }
96 let keep_from = ordered_turns.len().saturating_sub(turn_count);
97 let kept = &ordered_turns[keep_from..];
98 events.retain(|event| {
99 event
100 .turn_id
101 .as_ref()
102 .is_some_and(|turn_id| kept.contains(turn_id))
103 });
104 }
105 for event in &events {
106 store.append_event(child_thread_id, event).await?;
107 }
108 Ok(())
109 }
110
111 pub async fn fork_thread(
114 &self,
115 request: ForkThreadRequest,
116 ) -> anyhow::Result<ForkThreadOutcome> {
117 self.emit(RoderEvent::ThreadForkRequested(ThreadForkRequested {
118 parent_thread_id: request.parent_thread_id.clone(),
119 name: request.name.clone(),
120 timestamp: OffsetDateTime::now_utc(),
121 }))
122 .await;
123 match self.fork_thread_inner(&request).await {
124 Ok(outcome) => Ok(outcome),
125 Err(error) => {
126 self.emit(RoderEvent::ThreadForkFailed(ThreadForkFailed {
127 parent_thread_id: request.parent_thread_id.clone(),
128 name: request.name.clone(),
129 message: error.to_string(),
130 timestamp: OffsetDateTime::now_utc(),
131 }))
132 .await;
133 Err(error)
134 }
135 }
136 }
137
138 async fn fork_thread_inner(
139 &self,
140 request: &ForkThreadRequest,
141 ) -> anyhow::Result<ForkThreadOutcome> {
142 let store = self
143 .thread_store
144 .clone()
145 .ok_or_else(|| anyhow::anyhow!("conversation forks require a thread store"))?;
146 let parent = store
147 .load_thread(&request.parent_thread_id)
148 .await?
149 .ok_or_else(|| {
150 anyhow::anyhow!("parent thread {} was not found", request.parent_thread_id)
151 })?;
152 let parent_metadata = parent.metadata.clone().ok_or_else(|| {
153 anyhow::anyhow!(
154 "parent thread {} has no metadata to fork from",
155 request.parent_thread_id
156 )
157 })?;
158 let seed_events = seed_events_for_child(&parent.events, request.from_turn_id.as_deref())?;
161 let child_id = uuid::Uuid::new_v4().to_string();
162
163 let provider_id = request
166 .provider_id
167 .clone()
168 .unwrap_or_else(|| DEFAULT_FORK_PROVIDER.to_string());
169 let fork = self
170 .create_workspace_fork(
171 &provider_id,
172 ForkRequest {
173 ownership: Some(roder_api::forks::ForkOwnership {
174 creator_thread_id: Some(request.parent_thread_id.clone()),
175 owner_thread_id: Some(child_id.clone()),
176 }),
177 source_workspace: PathBuf::from(&parent_metadata.workspace),
178 name: Some(request.name.clone()),
179 reason: ForkReason::ConversationFork,
180 policy: ForkPolicy::default(),
181 provider_config: request.provider_config.clone(),
182 },
183 )
184 .await?;
185
186 let now = OffsetDateTime::now_utc();
187 let mut warnings = Vec::new();
188 if request.from_turn_id.is_none() && seed_events.is_empty() && !parent.events.is_empty() {
189 warnings.push(
190 "parent thread has events but none were conversation records; the fork starts \
191 with an empty transcript"
192 .to_string(),
193 );
194 }
195
196 let child_metadata = ThreadMetadata {
197 thread_id: child_id.clone(),
198 title: Some(match &parent_metadata.title {
199 Some(title) => format!("{title} (fork: {})", request.name),
200 None => format!("fork: {}", request.name),
201 }),
202 workspace: fork.workspace.display().to_string(),
203 workspace_id: None,
205 root_id: None,
206 provider: parent_metadata.provider.clone(),
207 model: parent_metadata.model.clone(),
208 selection_mode: parent_metadata.selection_mode.clone(),
209 tool_allowlist: parent_metadata.tool_allowlist.clone(),
210 service_tier: parent_metadata.service_tier.clone(),
211 developer_instructions: parent_metadata.developer_instructions.clone(),
212 external_tools: parent_metadata.external_tools.clone(),
213 runner_destination: None,
215 runner_state: None,
216 runner_binding: None,
217 created_at: now,
218 updated_at: now,
219 message_count: 0,
220 usage: None,
221 parent_thread_id: Some(request.parent_thread_id.clone()),
222 forked_from_turn_id: request.from_turn_id.clone(),
223 workspace_fork: Some(fork.clone()),
224 };
225
226 let seed = async {
227 self.seed_child_thread(&store, child_metadata.clone(), &child_id, seed_events)
228 .await?;
229 self.goals
230 .inherit_thread_goal_snapshot(&request.parent_thread_id, &child_id)
231 .await
232 }
233 .await;
234 let inherited_goal = match seed {
235 Ok(goal) => goal,
236 Err(error) => {
237 let _ = self
239 .remove_workspace_fork(
240 &provider_id,
241 &fork.id,
242 RemoveForkPolicy {
243 confirm_workspace: fork.workspace.clone(),
244 },
245 )
246 .await;
247 return Err(error);
248 }
249 };
250
251 self.emit(RoderEvent::ThreadCreated(ThreadCreated {
252 thread_id: child_id.clone(),
253 timestamp: OffsetDateTime::now_utc(),
254 }))
255 .await;
256 if let Some(goal) = inherited_goal {
257 self.goals.emit_goal_updated(goal).await;
258 }
259 self.emit(RoderEvent::ThreadForked(ThreadForked {
260 parent_thread_id: request.parent_thread_id.clone(),
261 child_thread_id: child_id.clone(),
262 fork,
263 timestamp: OffsetDateTime::now_utc(),
264 }))
265 .await;
266
267 let child = store
268 .load_thread_metadata(&child_id)
269 .await?
270 .unwrap_or(child_metadata);
271 Ok(ForkThreadOutcome { child, warnings })
272 }
273
274 async fn seed_child_thread(
275 &self,
276 store: &Arc<dyn ThreadStore>,
277 child_metadata: ThreadMetadata,
278 child_id: &ThreadId,
279 seed_events: Vec<EventEnvelope>,
280 ) -> anyhow::Result<()> {
281 store.create_thread(child_metadata).await?;
282 for envelope in &seed_events {
283 store.append_event(child_id, envelope).await?;
284 }
285 Ok(())
286 }
287
288 pub async fn remove_thread_workspace_fork(
295 &self,
296 thread_id: &ThreadId,
297 confirm_path: &str,
298 ) -> anyhow::Result<WorkspaceFork> {
299 let store = self
300 .thread_store
301 .clone()
302 .ok_or_else(|| anyhow::anyhow!("conversation forks require a thread store"))?;
303 let mut metadata = store
304 .load_thread_metadata(thread_id)
305 .await?
306 .ok_or_else(|| anyhow::anyhow!("thread {thread_id} was not found"))?;
307 let mut fork = metadata
308 .workspace_fork
309 .clone()
310 .ok_or_else(|| anyhow::anyhow!("thread {thread_id} is not a workspace fork"))?;
311 anyhow::ensure!(
312 fork.status == ForkStatus::Active,
313 "fork {} was already removed",
314 fork.id
315 );
316 anyhow::ensure!(
317 std::path::Path::new(confirm_path) == fork.workspace,
318 "confirmation path does not match the fork workspace {}; removal is \
319 path-confirmed to prevent accidental deletion",
320 fork.workspace.display()
321 );
322
323 self.remove_workspace_fork(
324 &fork.provider_id.clone(),
325 &fork.id.clone(),
326 RemoveForkPolicy {
327 confirm_workspace: fork.workspace.clone(),
328 },
329 )
330 .await?;
331
332 fork.status = ForkStatus::Removed;
333 metadata.workspace_fork = Some(fork.clone());
334 metadata.updated_at = OffsetDateTime::now_utc();
335 store.update_thread_metadata(metadata).await?;
336
337 self.emit(RoderEvent::ThreadForkRemoved(ThreadForkRemoved {
338 thread_id: thread_id.clone(),
339 fork_id: fork.id.clone(),
340 worktree_path: fork.workspace.display().to_string(),
341 timestamp: OffsetDateTime::now_utc(),
342 }))
343 .await;
344 Ok(fork)
345 }
346}