use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::Arc;
use roder_api::forks::{
ForkCapabilities, ForkId, ForkProvenance, ForkProvider, ForkProviderDescriptor, ForkRequest,
ForkStatus, RemoveForkPolicy, RemoveForkResult, WorkspaceFork,
};
use roder_api::remote_runner::{RemoteRunnerProvider, RemoteRunnerSession, RunnerDestination};
use tokio::sync::Mutex;
use crate::Runtime;
pub const DEFAULT_FORK_PROVIDER: &str = "git-worktree";
impl Runtime {
pub fn fork_providers(&self) -> Vec<ForkProviderDescriptor> {
self.registry
.fork_providers
.iter()
.map(|provider| provider.descriptor())
.collect()
}
pub fn fork_provider(&self, provider_id: &str) -> anyhow::Result<Arc<dyn ForkProvider>> {
self.registry.fork_provider(provider_id).ok_or_else(|| {
let available = self
.registry
.fork_providers
.iter()
.map(|provider| provider.descriptor().id)
.collect::<Vec<_>>()
.join(", ");
anyhow::anyhow!(
"fork provider {provider_id:?} is not installed (available: {})",
if available.is_empty() {
"none"
} else {
&available
}
)
})
}
pub async fn create_workspace_fork(
&self,
provider_id: &str,
request: ForkRequest,
) -> anyhow::Result<WorkspaceFork> {
anyhow::ensure!(
request.source_workspace.is_absolute(),
"fork source workspace must be an absolute path: {}",
request.source_workspace.display()
);
if let Some(name) = &request.name {
anyhow::ensure!(
!name.trim().is_empty() && name.len() <= 100,
"fork names must be 1-100 characters"
);
}
let provider = self.fork_provider(provider_id)?;
anyhow::ensure!(
provider.descriptor().capabilities.create,
"fork provider {provider_id:?} does not support creation"
);
provider.create_fork(request).await
}
pub async fn list_workspace_forks(
&self,
provider_id: &str,
source_workspace: &std::path::Path,
) -> anyhow::Result<Vec<WorkspaceFork>> {
self.fork_provider(provider_id)?
.list_forks(source_workspace)
.await
}
pub async fn resume_workspace_fork(
&self,
provider_id: &str,
id: &ForkId,
) -> anyhow::Result<WorkspaceFork> {
self.fork_provider(provider_id)?.resume_fork(id).await
}
pub async fn remove_workspace_fork(
&self,
provider_id: &str,
id: &ForkId,
policy: RemoveForkPolicy,
) -> anyhow::Result<RemoveForkResult> {
self.fork_provider(provider_id)?
.remove_fork(id, policy)
.await
}
}
pub const REMOTE_RUNNER_FORK_PROVIDER_ID: &str = "remote-runner";
pub struct RemoteRunnerForkAdapter {
provider: Arc<dyn RemoteRunnerProvider>,
destination: RunnerDestination,
runner_workspace: PathBuf,
sessions: Mutex<HashMap<ForkId, Arc<dyn RemoteRunnerSession>>>,
}
impl RemoteRunnerForkAdapter {
pub fn new(
provider: Arc<dyn RemoteRunnerProvider>,
destination: RunnerDestination,
runner_workspace: PathBuf,
) -> Self {
Self {
provider,
destination,
runner_workspace,
sessions: Mutex::new(HashMap::new()),
}
}
pub async fn session(&self, id: &ForkId) -> Option<Arc<dyn RemoteRunnerSession>> {
self.sessions.lock().await.get(id).cloned()
}
fn fork_for(&self, session: &Arc<dyn RemoteRunnerSession>) -> WorkspaceFork {
let state = session.state();
WorkspaceFork {
id: state.session_id.clone(),
provider_id: REMOTE_RUNNER_FORK_PROVIDER_ID.to_string(),
source_workspace: self.runner_workspace.clone(),
workspace: self.runner_workspace.clone(),
status: ForkStatus::Active,
provenance: ForkProvenance {
branch: None,
source_branch: None,
source_commit: None,
snapshot_id: state
.snapshot
.as_ref()
.map(|snapshot| snapshot.snapshot_id.clone()),
session_id: Some(state.session_id),
created_at: time::OffsetDateTime::now_utc(),
},
cleanup: Default::default(),
metadata: serde_json::json!({
"runnerProviderId": state.provider_id,
"destinationId": state.destination_id,
}),
}
}
}
#[async_trait::async_trait]
impl ForkProvider for RemoteRunnerForkAdapter {
fn descriptor(&self) -> ForkProviderDescriptor {
ForkProviderDescriptor {
id: REMOTE_RUNNER_FORK_PROVIDER_ID.to_string(),
display_name: format!("Remote runner ({})", self.destination.provider_id),
capabilities: ForkCapabilities {
create: true,
list: false,
remove: true,
resume: true,
diff_summary: false,
merge_back: false,
copy_on_write: false,
remote_compute: true,
},
}
}
async fn create_fork(&self, request: ForkRequest) -> anyhow::Result<WorkspaceFork> {
anyhow::ensure!(
!request.policy.allow_dirty_source,
"remote-runner forks always start from the destination's own state; \
allow_dirty_source has no meaning here and must stay false"
);
let session = self
.provider
.create_session(self.destination.clone())
.await?;
let fork = self.fork_for(&session);
self.sessions.lock().await.insert(fork.id.clone(), session);
Ok(fork)
}
async fn list_forks(&self, _source: &std::path::Path) -> anyhow::Result<Vec<WorkspaceFork>> {
let sessions = self.sessions.lock().await;
Ok(sessions
.values()
.map(|session| self.fork_for(session))
.collect())
}
async fn resume_fork(&self, id: &ForkId) -> anyhow::Result<WorkspaceFork> {
if let Some(session) = self.session(id).await {
return Ok(self.fork_for(&session));
}
let state = roder_api::remote_runner::RunnerSessionState {
provider_id: self.destination.provider_id.clone(),
session_id: id.clone(),
destination_id: self.destination.id.clone(),
snapshot: None,
metadata: self.destination.config.clone(),
};
let session = self.provider.resume_session(state).await?;
let fork = self.fork_for(&session);
self.sessions.lock().await.insert(fork.id.clone(), session);
Ok(fork)
}
async fn remove_fork(
&self,
id: &ForkId,
policy: RemoveForkPolicy,
) -> anyhow::Result<RemoveForkResult> {
anyhow::ensure!(
policy.confirm_workspace == self.runner_workspace,
"removal is path-confirmed: confirm the runner workspace {}",
self.runner_workspace.display()
);
let session = self
.sessions
.lock()
.await
.remove(id)
.ok_or_else(|| anyhow::anyhow!("remote fork {id} is not active in this process"))?;
session.close().await?;
Ok(RemoveForkResult {
id: id.clone(),
removed: true,
workspace: self.runner_workspace.clone(),
})
}
}