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#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
21#[serde(rename_all = "camelCase")]
22pub struct ForkProviderDescriptor {
23    pub id: ForkProviderId,
24    pub display_name: String,
25    pub capabilities: ForkCapabilities,
26}
27
28#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
29#[serde(rename_all = "camelCase")]
30pub struct ForkCapabilities {
31    pub create: bool,
32    pub list: bool,
33    pub remove: bool,
34    pub resume: bool,
35    pub diff_summary: bool,
36    pub merge_back: bool,
37    pub copy_on_write: bool,
38    pub remote_compute: bool,
39}
40
41/// Why a fork is being created; recorded for provenance/audit only.
42#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
43#[serde(rename_all = "snake_case")]
44pub enum ForkReason {
45    ConversationFork,
46    SubagentLane,
47    TaskLane,
48    Experiment,
49    Other,
50}
51
52/// Source-state policy applied before fork creation.
53#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
54#[serde(rename_all = "camelCase")]
55pub struct ForkPolicy {
56    /// Allow forking from a source with uncommitted/dirty state. Providers
57    /// that cannot honor `true` must fail with a clear error rather than
58    /// silently copying dirty state. Default: fail closed on dirty sources
59    /// (Roder-owned `.roder/` state is always exempt).
60    #[serde(default)]
61    pub allow_dirty_source: bool,
62}
63
64#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
65#[serde(rename_all = "snake_case")]
66pub enum ForkStatus {
67    Active,
68    Removed,
69    /// Recorded as existing but its workspace is missing on disk.
70    Missing,
71}
72
73#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
74#[serde(rename_all = "snake_case")]
75pub enum ForkCleanupPolicy {
76    /// Removal only via an explicit, path-confirmed request (default).
77    #[default]
78    Explicit,
79    /// Eligible for policy-driven auto-clean when its task lane exits.
80    AutoOnTaskExit,
81}
82
83/// Provider-recorded provenance. All fields optional so local and remote
84/// providers can populate what they actually know; never secret-bearing.
85#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
86#[serde(rename_all = "camelCase")]
87pub struct ForkProvenance {
88    #[serde(default, skip_serializing_if = "Option::is_none")]
89    pub branch: Option<String>,
90    #[serde(default, skip_serializing_if = "Option::is_none")]
91    pub source_branch: Option<String>,
92    #[serde(default, skip_serializing_if = "Option::is_none")]
93    pub source_commit: Option<String>,
94    #[serde(default, skip_serializing_if = "Option::is_none")]
95    pub snapshot_id: Option<String>,
96    #[serde(default, skip_serializing_if = "Option::is_none")]
97    pub session_id: Option<String>,
98    #[serde(with = "time::serde::rfc3339")]
99    pub created_at: OffsetDateTime,
100}
101
102impl ForkProvenance {
103    pub fn at(created_at: OffsetDateTime) -> Self {
104        Self {
105            branch: None,
106            source_branch: None,
107            source_commit: None,
108            snapshot_id: None,
109            session_id: None,
110            created_at,
111        }
112    }
113}
114
115#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
116#[serde(rename_all = "camelCase")]
117pub struct ForkRequest {
118    pub source_workspace: PathBuf,
119    /// User-facing fork name; providers sanitize into their naming scheme.
120    #[serde(default, skip_serializing_if = "Option::is_none")]
121    pub name: Option<String>,
122    pub reason: ForkReason,
123    #[serde(default)]
124    pub policy: ForkPolicy,
125    /// Provider-specific options; must never carry secrets.
126    #[serde(default)]
127    pub provider_config: serde_json::Value,
128}
129
130#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
131#[serde(rename_all = "camelCase")]
132pub struct WorkspaceFork {
133    pub id: ForkId,
134    pub provider_id: ForkProviderId,
135    pub source_workspace: PathBuf,
136    /// The writable workspace this fork provides.
137    pub workspace: PathBuf,
138    pub status: ForkStatus,
139    pub provenance: ForkProvenance,
140    #[serde(default)]
141    pub cleanup: ForkCleanupPolicy,
142    /// Non-secret provider metadata for display/debugging.
143    #[serde(default)]
144    pub metadata: serde_json::Value,
145}
146
147/// Removal is destructive and always path-confirmed: the caller must name
148/// the exact fork workspace being removed.
149#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
150#[serde(rename_all = "camelCase")]
151pub struct RemoveForkPolicy {
152    pub confirm_workspace: PathBuf,
153}
154
155#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
156#[serde(rename_all = "camelCase")]
157pub struct RemoveForkResult {
158    pub id: ForkId,
159    pub removed: bool,
160    pub workspace: PathBuf,
161}
162
163/// Provider contract for workspace forks. Registered through the extension
164/// registry (`ProvidedService::ForkProvider`); providers declare their
165/// capabilities and must not assume ambient authority beyond them.
166#[async_trait::async_trait]
167pub trait ForkProvider: Send + Sync + 'static {
168    fn descriptor(&self) -> ForkProviderDescriptor;
169
170    async fn create_fork(&self, request: ForkRequest) -> anyhow::Result<WorkspaceFork>;
171
172    /// Lists forks of `source_workspace` known to this provider.
173    async fn list_forks(&self, source_workspace: &Path) -> anyhow::Result<Vec<WorkspaceFork>>;
174
175    /// Re-resolves a fork by id (e.g. after restart), reporting `Missing`
176    /// status when its workspace disappeared out-of-band.
177    async fn resume_fork(&self, id: &ForkId) -> anyhow::Result<WorkspaceFork>;
178
179    async fn remove_fork(
180        &self,
181        id: &ForkId,
182        policy: RemoveForkPolicy,
183    ) -> anyhow::Result<RemoveForkResult>;
184}