1use std::collections::HashMap;
8use std::path::PathBuf;
9use std::sync::Arc;
10
11use roder_api::forks::{
12 ForkCapabilities, ForkId, ForkProvenance, ForkProvider, ForkProviderDescriptor, ForkRequest,
13 ForkStatus, RemoveForkPolicy, RemoveForkResult, WorkspaceFork,
14};
15use roder_api::remote_runner::{RemoteRunnerProvider, RemoteRunnerSession, RunnerDestination};
16use tokio::sync::Mutex;
17
18use crate::Runtime;
19
20pub const DEFAULT_FORK_PROVIDER: &str = "git-worktree";
22
23impl Runtime {
24 pub fn fork_providers(&self) -> Vec<ForkProviderDescriptor> {
26 self.registry
27 .fork_providers
28 .iter()
29 .map(|provider| provider.descriptor())
30 .collect()
31 }
32
33 pub fn fork_provider(&self, provider_id: &str) -> anyhow::Result<Arc<dyn ForkProvider>> {
36 self.registry.fork_provider(provider_id).ok_or_else(|| {
37 let available = self
38 .registry
39 .fork_providers
40 .iter()
41 .map(|provider| provider.descriptor().id)
42 .collect::<Vec<_>>()
43 .join(", ");
44 anyhow::anyhow!(
45 "fork provider {provider_id:?} is not installed (available: {})",
46 if available.is_empty() {
47 "none"
48 } else {
49 &available
50 }
51 )
52 })
53 }
54
55 pub async fn create_workspace_fork(
57 &self,
58 provider_id: &str,
59 request: ForkRequest,
60 ) -> anyhow::Result<WorkspaceFork> {
61 anyhow::ensure!(
62 request.source_workspace.is_absolute(),
63 "fork source workspace must be an absolute path: {}",
64 request.source_workspace.display()
65 );
66 if let Some(name) = &request.name {
67 anyhow::ensure!(
68 !name.trim().is_empty() && name.len() <= 100,
69 "fork names must be 1-100 characters"
70 );
71 }
72 let provider = self.fork_provider(provider_id)?;
73 anyhow::ensure!(
74 provider.descriptor().capabilities.create,
75 "fork provider {provider_id:?} does not support creation"
76 );
77 provider.create_fork(request).await
78 }
79
80 pub async fn list_workspace_forks(
81 &self,
82 provider_id: &str,
83 source_workspace: &std::path::Path,
84 ) -> anyhow::Result<Vec<WorkspaceFork>> {
85 self.fork_provider(provider_id)?
86 .list_forks(source_workspace)
87 .await
88 }
89
90 pub async fn resume_workspace_fork(
91 &self,
92 provider_id: &str,
93 id: &ForkId,
94 ) -> anyhow::Result<WorkspaceFork> {
95 self.fork_provider(provider_id)?.resume_fork(id).await
96 }
97
98 pub async fn remove_workspace_fork(
101 &self,
102 provider_id: &str,
103 id: &ForkId,
104 policy: RemoveForkPolicy,
105 ) -> anyhow::Result<RemoveForkResult> {
106 let provider = self.fork_provider(provider_id)?;
107 if provider_id == DEFAULT_FORK_PROVIDER {
108 let fork = provider.resume_fork(id).await?;
109 if let Some(owner) = fork.ownership.and_then(|owner| owner.owner_thread_id) {
110 anyhow::ensure!(
111 !self.has_active_turn_for_thread(&owner).await,
112 "cannot remove a worktree while its owner thread {owner} has an active turn"
113 );
114 }
115 }
116 provider.remove_fork(id, policy).await
117 }
118}
119
120pub const REMOTE_RUNNER_FORK_PROVIDER_ID: &str = "remote-runner";
123
124pub struct RemoteRunnerForkAdapter {
135 provider: Arc<dyn RemoteRunnerProvider>,
136 destination: RunnerDestination,
137 runner_workspace: PathBuf,
139 sessions: Mutex<HashMap<ForkId, Arc<dyn RemoteRunnerSession>>>,
140}
141
142impl RemoteRunnerForkAdapter {
143 pub fn new(
144 provider: Arc<dyn RemoteRunnerProvider>,
145 destination: RunnerDestination,
146 runner_workspace: PathBuf,
147 ) -> Self {
148 Self {
149 provider,
150 destination,
151 runner_workspace,
152 sessions: Mutex::new(HashMap::new()),
153 }
154 }
155
156 pub async fn session(&self, id: &ForkId) -> Option<Arc<dyn RemoteRunnerSession>> {
158 self.sessions.lock().await.get(id).cloned()
159 }
160
161 fn fork_for(&self, session: &Arc<dyn RemoteRunnerSession>) -> WorkspaceFork {
162 let state = session.state();
163 WorkspaceFork {
164 ownership: None,
165 id: state.session_id.clone(),
166 provider_id: REMOTE_RUNNER_FORK_PROVIDER_ID.to_string(),
167 source_workspace: self.runner_workspace.clone(),
168 workspace: self.runner_workspace.clone(),
169 status: ForkStatus::Active,
170 provenance: ForkProvenance {
171 branch: None,
172 source_branch: None,
173 source_commit: None,
174 snapshot_id: state
175 .snapshot
176 .as_ref()
177 .map(|snapshot| snapshot.snapshot_id.clone()),
178 session_id: Some(state.session_id),
179 created_at: time::OffsetDateTime::now_utc(),
180 },
181 cleanup: Default::default(),
182 metadata: serde_json::json!({
183 "runnerProviderId": state.provider_id,
184 "destinationId": state.destination_id,
185 }),
186 }
187 }
188}
189
190#[async_trait::async_trait]
191impl ForkProvider for RemoteRunnerForkAdapter {
192 fn descriptor(&self) -> ForkProviderDescriptor {
193 ForkProviderDescriptor {
194 id: REMOTE_RUNNER_FORK_PROVIDER_ID.to_string(),
195 display_name: format!("Remote runner ({})", self.destination.provider_id),
196 capabilities: ForkCapabilities {
197 create: true,
198 list: false,
199 remove: true,
200 resume: true,
201 diff_summary: false,
202 merge_back: false,
203 copy_on_write: false,
204 remote_compute: true,
205 },
206 }
207 }
208
209 async fn create_fork(&self, request: ForkRequest) -> anyhow::Result<WorkspaceFork> {
210 anyhow::ensure!(
211 !request.policy.allow_dirty_source,
212 "remote-runner forks always start from the destination's own state; \
213 allow_dirty_source has no meaning here and must stay false"
214 );
215 let session = self
216 .provider
217 .create_session(self.destination.clone())
218 .await?;
219 let fork = self.fork_for(&session);
220 self.sessions.lock().await.insert(fork.id.clone(), session);
221 Ok(fork)
222 }
223
224 async fn list_forks(&self, _source: &std::path::Path) -> anyhow::Result<Vec<WorkspaceFork>> {
225 let sessions = self.sessions.lock().await;
228 Ok(sessions
229 .values()
230 .map(|session| self.fork_for(session))
231 .collect())
232 }
233
234 async fn resume_fork(&self, id: &ForkId) -> anyhow::Result<WorkspaceFork> {
235 if let Some(session) = self.session(id).await {
236 return Ok(self.fork_for(&session));
237 }
238 let state = roder_api::remote_runner::RunnerSessionState {
240 provider_id: self.destination.provider_id.clone(),
241 session_id: id.clone(),
242 destination_id: self.destination.id.clone(),
243 snapshot: None,
244 metadata: self.destination.config.clone(),
245 };
246 let session = self.provider.resume_session(state).await?;
247 let fork = self.fork_for(&session);
248 self.sessions.lock().await.insert(fork.id.clone(), session);
249 Ok(fork)
250 }
251
252 async fn remove_fork(
253 &self,
254 id: &ForkId,
255 policy: RemoveForkPolicy,
256 ) -> anyhow::Result<RemoveForkResult> {
257 anyhow::ensure!(
258 policy.confirm_workspace == self.runner_workspace,
259 "removal is path-confirmed: confirm the runner workspace {}",
260 self.runner_workspace.display()
261 );
262 let session = self
263 .sessions
264 .lock()
265 .await
266 .remove(id)
267 .ok_or_else(|| anyhow::anyhow!("remote fork {id} is not active in this process"))?;
268 session.close().await?;
269 Ok(RemoveForkResult {
270 id: id.clone(),
271 removed: true,
272 workspace: self.runner_workspace.clone(),
273 })
274 }
275}