Skip to main content

roder_api/
forks.rs

1//! Provider-neutral workspace forks (roadmap phase 81).
2//!
3//! A **fork** is a writable workspace copy or session derived from a source
4//! workspace — backing a thread, subagent lane, task branch, or experiment.
5//! Providers implement concrete storage/compute behavior (Git worktrees,
6//! Rift copy-on-write snapshots, remote sandboxes); core code only sees
7//! these types. Forks are not inference providers and are not GitHub
8//! repository forks.
9
10use std::path::{Path, PathBuf};
11
12use serde::{Deserialize, Serialize};
13use time::OffsetDateTime;
14
15pub type ForkProviderId = String;
16/// Provider-scoped fork identifier. Stable and resolvable by the provider
17/// alone (e.g. the absolute worktree path for `git-worktree`).
18pub type ForkId = String;
19
20/// Durable ownership, independent of a worktree's path or branch naming.
21/// Git forks with `None` are external, unmanaged checkouts. Other providers
22/// may omit ownership when they do not support this contract.
23#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
24#[serde(rename_all = "camelCase")]
25pub struct ForkOwnership {
26    #[serde(default, skip_serializing_if = "Option::is_none")]
27    pub creator_thread_id: Option<crate::events::ThreadId>,
28    #[serde(default, skip_serializing_if = "Option::is_none")]
29    pub owner_thread_id: Option<crate::events::ThreadId>,
30}
31
32/// Opt-in filesystem isolation for a collaboration agent, separate from
33/// conversation-history forking. Worktrees start from committed HEAD.
34#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
35#[serde(rename_all = "snake_case")]
36pub enum AgentWorkspaceIsolation {
37    #[default]
38    Shared,
39    Worktree,
40}
41
42#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
43#[serde(rename_all = "camelCase")]
44pub struct ForkProviderDescriptor {
45    pub id: ForkProviderId,
46    pub display_name: String,
47    pub capabilities: ForkCapabilities,
48}
49
50#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
51#[serde(rename_all = "camelCase")]
52pub struct ForkCapabilities {
53    pub create: bool,
54    pub list: bool,
55    pub remove: bool,
56    pub resume: bool,
57    pub diff_summary: bool,
58    pub merge_back: bool,
59    pub copy_on_write: bool,
60    pub remote_compute: bool,
61}
62
63/// Why a fork is being created; recorded for provenance/audit only.
64#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
65#[serde(rename_all = "snake_case")]
66pub enum ForkReason {
67    ConversationFork,
68    SubagentLane,
69    TaskLane,
70    Experiment,
71    Other,
72}
73
74/// Source-state policy applied before fork creation.
75#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
76#[serde(rename_all = "camelCase")]
77pub struct ForkPolicy {
78    /// Allow forking from a source with uncommitted/dirty state. Providers
79    /// that cannot honor `true` must fail with a clear error rather than
80    /// silently copying dirty state. Default: fail closed on dirty sources
81    /// (Roder-owned `.roder/` state is always exempt).
82    #[serde(default)]
83    pub allow_dirty_source: bool,
84}
85
86#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
87#[serde(rename_all = "snake_case")]
88pub enum ForkStatus {
89    Active,
90    Removed,
91    /// Recorded as existing but its workspace is missing on disk.
92    Missing,
93}
94
95#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
96#[serde(rename_all = "snake_case")]
97pub enum ForkCleanupPolicy {
98    /// Removal only via an explicit, path-confirmed request (default).
99    #[default]
100    Explicit,
101    /// Eligible for policy-driven auto-clean when its task lane exits.
102    AutoOnTaskExit,
103}
104
105/// Provider-recorded provenance. All fields optional so local and remote
106/// providers can populate what they actually know; never secret-bearing.
107#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
108#[serde(rename_all = "camelCase")]
109pub struct ForkProvenance {
110    #[serde(default, skip_serializing_if = "Option::is_none")]
111    pub branch: Option<String>,
112    #[serde(default, skip_serializing_if = "Option::is_none")]
113    pub source_branch: Option<String>,
114    #[serde(default, skip_serializing_if = "Option::is_none")]
115    pub source_commit: Option<String>,
116    #[serde(default, skip_serializing_if = "Option::is_none")]
117    pub snapshot_id: Option<String>,
118    #[serde(default, skip_serializing_if = "Option::is_none")]
119    pub session_id: Option<String>,
120    #[serde(with = "time::serde::rfc3339")]
121    pub created_at: OffsetDateTime,
122}
123
124impl ForkProvenance {
125    pub fn at(created_at: OffsetDateTime) -> Self {
126        Self {
127            branch: None,
128            source_branch: None,
129            source_commit: None,
130            snapshot_id: None,
131            session_id: None,
132            created_at,
133        }
134    }
135}
136
137#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
138#[serde(rename_all = "camelCase")]
139pub struct ForkRequest {
140    pub source_workspace: PathBuf,
141    #[serde(default, skip_serializing_if = "Option::is_none")]
142    pub ownership: Option<ForkOwnership>,
143    /// User-facing fork name; providers sanitize into their naming scheme.
144    #[serde(default, skip_serializing_if = "Option::is_none")]
145    pub name: Option<String>,
146    pub reason: ForkReason,
147    #[serde(default)]
148    pub policy: ForkPolicy,
149    /// Provider-specific options; must never carry secrets.
150    #[serde(default)]
151    pub provider_config: serde_json::Value,
152}
153
154#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
155#[serde(rename_all = "camelCase")]
156pub struct WorkspaceFork {
157    pub id: ForkId,
158    #[serde(default, skip_serializing_if = "Option::is_none")]
159    pub ownership: Option<ForkOwnership>,
160    pub provider_id: ForkProviderId,
161    pub source_workspace: PathBuf,
162    /// The writable workspace this fork provides.
163    pub workspace: PathBuf,
164    pub status: ForkStatus,
165    pub provenance: ForkProvenance,
166    #[serde(default)]
167    pub cleanup: ForkCleanupPolicy,
168    /// Non-secret provider metadata for display/debugging.
169    #[serde(default)]
170    pub metadata: serde_json::Value,
171}
172
173/// Removal is destructive and always path-confirmed: the caller must name
174/// the exact fork workspace being removed.
175#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
176#[serde(rename_all = "camelCase")]
177pub struct RemoveForkPolicy {
178    pub confirm_workspace: PathBuf,
179}
180
181#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
182#[serde(rename_all = "camelCase")]
183pub struct RemoveForkResult {
184    pub id: ForkId,
185    pub removed: bool,
186    pub workspace: PathBuf,
187}
188
189/// Provider contract for workspace forks. Registered through the extension
190/// registry (`ProvidedService::ForkProvider`); providers declare their
191/// capabilities and must not assume ambient authority beyond them.
192#[async_trait::async_trait]
193pub trait ForkProvider: Send + Sync + 'static {
194    fn descriptor(&self) -> ForkProviderDescriptor;
195
196    async fn create_fork(&self, request: ForkRequest) -> anyhow::Result<WorkspaceFork>;
197
198    /// Lists forks of `source_workspace` known to this provider.
199    async fn list_forks(&self, source_workspace: &Path) -> anyhow::Result<Vec<WorkspaceFork>>;
200
201    /// Re-resolves a fork by id (e.g. after restart), reporting `Missing`
202    /// status when its workspace disappeared out-of-band.
203    async fn resume_fork(&self, id: &ForkId) -> anyhow::Result<WorkspaceFork>;
204
205    async fn remove_fork(
206        &self,
207        id: &ForkId,
208        policy: RemoveForkPolicy,
209    ) -> anyhow::Result<RemoveForkResult>;
210}