Skip to main content

roder_api/
remote_runner.rs

1use std::path::PathBuf;
2use std::sync::Arc;
3
4use serde::{Deserialize, Serialize};
5
6pub type RemoteRunnerProviderId = String;
7pub type RemoteRunnerSessionId = String;
8pub type RunnerDestinationId = String;
9pub type RunnerCommandId = String;
10
11#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
12pub struct RunnerCapabilities {
13    pub command_exec: bool,
14    pub file_read: bool,
15    pub file_write: bool,
16    pub port_preview: bool,
17    pub snapshots: bool,
18    pub cancellation: bool,
19    #[serde(default)]
20    pub artifact_export: bool,
21    #[serde(default)]
22    pub mounts: RunnerMountCapabilities,
23}
24
25#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
26pub struct RunnerMountCapabilities {
27    #[serde(default)]
28    pub s3: bool,
29    #[serde(default)]
30    pub gcs: bool,
31    #[serde(default)]
32    pub r2: bool,
33    #[serde(default)]
34    pub azure_blob: bool,
35    #[serde(default)]
36    pub box_storage: bool,
37    #[serde(default)]
38    pub provider_native: bool,
39}
40
41#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
42pub struct RunnerDestination {
43    pub id: RunnerDestinationId,
44    pub provider_id: RemoteRunnerProviderId,
45    #[serde(default)]
46    pub config: serde_json::Value,
47    #[serde(default)]
48    pub default_manifest: RunnerManifest,
49}
50
51#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
52pub struct RunnerManifest {
53    #[serde(default)]
54    pub entries: Vec<RunnerManifestEntry>,
55    #[serde(default)]
56    pub mounts: Vec<RunnerMount>,
57}
58
59#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
60pub struct RunnerManifestEntry {
61    pub source: PathBuf,
62    pub target: PathBuf,
63    pub writable: bool,
64}
65
66#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
67pub struct RunnerMount {
68    pub name: String,
69    pub path: PathBuf,
70    pub read_only: bool,
71    #[serde(default)]
72    pub intent: RunnerMountIntent,
73}
74
75#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
76pub struct RunnerMountIntent {
77    pub kind: RunnerMountKind,
78    pub uri: String,
79    #[serde(default, skip_serializing_if = "Option::is_none")]
80    pub credentials: Option<RunnerSecretRef>,
81}
82
83impl Default for RunnerMountIntent {
84    fn default() -> Self {
85        Self {
86            kind: RunnerMountKind::ProviderNative,
87            uri: String::new(),
88            credentials: None,
89        }
90    }
91}
92
93#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
94#[serde(rename_all = "snake_case")]
95pub enum RunnerMountKind {
96    S3,
97    Gcs,
98    R2,
99    AzureBlob,
100    BoxStorage,
101    ProviderNative,
102}
103
104#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
105pub struct RunnerSecretRef {
106    pub id: String,
107}
108
109#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
110pub struct RunnerSnapshotRef {
111    pub provider_id: RemoteRunnerProviderId,
112    pub snapshot_id: String,
113    #[serde(default)]
114    pub metadata: serde_json::Value,
115}
116
117#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
118pub struct RunnerSessionState {
119    pub provider_id: RemoteRunnerProviderId,
120    pub session_id: RemoteRunnerSessionId,
121    pub destination_id: RunnerDestinationId,
122    #[serde(default)]
123    pub snapshot: Option<RunnerSnapshotRef>,
124    #[serde(default)]
125    pub metadata: serde_json::Value,
126}
127
128/**
129 * Per-thread remote-runner binding chosen at thread creation. Native coding
130 * tools for a bound thread execute against this runner instead of the local
131 * filesystem; the destination config is persisted with the thread, so secrets
132 * must reach the provider through its environment, not this config.
133 */
134#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
135pub struct ThreadRunnerBinding {
136    pub destination: RunnerDestination,
137    /// Absolute path on the runner used as the thread's coding-tool workspace root.
138    pub workspace: PathBuf,
139    /**
140     * Extra absolute runner paths that file reads may resolve under, in
141     * addition to `workspace`. Writes and the working directory stay confined
142     * to `workspace`; these only widen read resolution (e.g. read-only
143     * resource mounts outside the writable workspace root).
144     */
145    #[serde(default)]
146    pub read_roots: Vec<PathBuf>,
147}
148
149/**
150 * Remote workspace handle carried on the tool execution context for
151 * runner-bound threads. Tools route file and shell operations through
152 * `session` with paths scoped under `root` (a path on the runner, not the
153 * local filesystem).
154 */
155#[derive(Clone)]
156pub struct RemoteWorkspace {
157    pub session: Arc<dyn RemoteRunnerSession>,
158    pub root: PathBuf,
159    /**
160     * Extra absolute runner paths reads may resolve under, beyond `root`.
161     * Writes and the working directory stay confined to `root`.
162     */
163    pub read_roots: Vec<PathBuf>,
164}
165
166#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
167pub struct RunnerCommandRequest {
168    pub command_id: RunnerCommandId,
169    pub program: String,
170    #[serde(default)]
171    pub args: Vec<String>,
172    #[serde(default)]
173    pub cwd: Option<PathBuf>,
174    #[serde(default)]
175    pub env: Vec<(String, String)>,
176}
177
178#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
179pub struct RunnerCommandResult {
180    pub command_id: RunnerCommandId,
181    pub exit_code: Option<i32>,
182    pub stdout: String,
183    pub stderr: String,
184}
185
186#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
187pub struct RunnerFileReadRequest {
188    pub path: PathBuf,
189}
190
191#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
192pub struct RunnerFileReadResult {
193    pub path: PathBuf,
194    pub contents: Vec<u8>,
195}
196
197#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
198pub struct RunnerFileWriteRequest {
199    pub path: PathBuf,
200    pub contents: Vec<u8>,
201}
202
203#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
204pub struct RunnerPortRequest {
205    pub port: u16,
206    pub label: Option<String>,
207}
208
209#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
210pub struct RunnerPortResult {
211    pub port: u16,
212    pub url: Option<String>,
213}
214
215#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
216pub struct RunnerArtifactExportRequest {
217    pub path: PathBuf,
218    #[serde(default)]
219    pub recursive: bool,
220}
221
222#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
223pub struct RunnerArtifactExportResult {
224    pub path: PathBuf,
225    pub artifact_id: String,
226    pub url: Option<String>,
227    #[serde(default)]
228    pub metadata: serde_json::Value,
229}
230
231#[async_trait::async_trait]
232pub trait RemoteRunnerProvider: Send + Sync + 'static {
233    fn id(&self) -> RemoteRunnerProviderId;
234    fn capabilities(&self) -> RunnerCapabilities;
235
236    /**
237     * Optional setup guidance shown by runner pickers when the provider is
238     * installed but not yet usable (for example a missing credential env
239     * var). Must name only documented env vars and never include secret
240     * values. `None` means the provider is ready or needs no setup hint.
241     */
242    fn setup_hint(&self) -> Option<String> {
243        None
244    }
245
246    async fn create_session(
247        &self,
248        destination: RunnerDestination,
249    ) -> anyhow::Result<Arc<dyn RemoteRunnerSession>>;
250
251    async fn validate_destination(&self, _destination: &RunnerDestination) -> anyhow::Result<()> {
252        Ok(())
253    }
254
255    async fn resume_session(
256        &self,
257        state: RunnerSessionState,
258    ) -> anyhow::Result<Arc<dyn RemoteRunnerSession>>;
259}
260
261#[async_trait::async_trait]
262pub trait RemoteRunnerSession: Send + Sync + 'static {
263    fn state(&self) -> RunnerSessionState;
264
265    async fn run_command(
266        &self,
267        request: RunnerCommandRequest,
268    ) -> anyhow::Result<RunnerCommandResult>;
269
270    async fn cancel_command(&self, _command_id: &RunnerCommandId) -> anyhow::Result<bool> {
271        Ok(false)
272    }
273
274    async fn read_file(
275        &self,
276        request: RunnerFileReadRequest,
277    ) -> anyhow::Result<RunnerFileReadResult>;
278
279    async fn write_file(&self, request: RunnerFileWriteRequest) -> anyhow::Result<()>;
280
281    async fn expose_port(&self, request: RunnerPortRequest) -> anyhow::Result<RunnerPortResult>;
282
283    async fn export_artifact(
284        &self,
285        _request: RunnerArtifactExportRequest,
286    ) -> anyhow::Result<RunnerArtifactExportResult> {
287        anyhow::bail!("runner artifact export is not supported by this provider")
288    }
289
290    async fn snapshot(&self) -> anyhow::Result<Option<RunnerSnapshotRef>>;
291
292    async fn close(&self) -> anyhow::Result<()>;
293}
294
295#[cfg(test)]
296mod tests {
297    use super::*;
298
299    #[test]
300    fn remote_runner_types_round_trip_json() {
301        let destination = RunnerDestination {
302            id: "local".to_string(),
303            provider_id: "unix-local".to_string(),
304            config: serde_json::json!({ "root": "." }),
305            default_manifest: RunnerManifest {
306                entries: vec![RunnerManifestEntry {
307                    source: "src".into(),
308                    target: "workspace/src".into(),
309                    writable: true,
310                }],
311                mounts: vec![RunnerMount {
312                    name: "cache".to_string(),
313                    path: ".cache".into(),
314                    read_only: false,
315                    intent: RunnerMountIntent::default(),
316                }],
317            },
318        };
319
320        let encoded = serde_json::to_value(&destination).unwrap();
321        let decoded: RunnerDestination = serde_json::from_value(encoded).unwrap();
322
323        assert_eq!(decoded, destination);
324    }
325
326    #[test]
327    fn command_and_port_operations_are_protocol_safe() {
328        let command = RunnerCommandRequest {
329            command_id: "cmd-1".to_string(),
330            program: "sh".to_string(),
331            args: vec!["-lc".to_string(), "echo hi".to_string()],
332            cwd: Some("workspace".into()),
333            env: vec![("RUST_LOG".to_string(), "info".to_string())],
334        };
335        let port = RunnerPortResult {
336            port: 3000,
337            url: Some("https://preview.example".to_string()),
338        };
339
340        assert_eq!(
341            serde_json::from_value::<RunnerCommandRequest>(serde_json::to_value(&command).unwrap())
342                .unwrap(),
343            command
344        );
345        assert_eq!(
346            serde_json::from_value::<RunnerPortResult>(serde_json::to_value(&port).unwrap())
347                .unwrap(),
348            port
349        );
350    }
351
352    #[test]
353    fn mount_and_artifact_operations_are_protocol_safe() {
354        let mount = RunnerMount {
355            name: "dataset".to_string(),
356            path: "mnt/dataset".into(),
357            read_only: true,
358            intent: RunnerMountIntent {
359                kind: RunnerMountKind::R2,
360                uri: "r2://bucket/prefix".to_string(),
361                credentials: Some(RunnerSecretRef {
362                    id: "r2-readonly".to_string(),
363                }),
364            },
365        };
366        let artifact = RunnerArtifactExportResult {
367            path: "out/report.json".into(),
368            artifact_id: "artifact-1".to_string(),
369            url: Some("https://artifacts.example/report.json".to_string()),
370            metadata: serde_json::json!({ "size": 128 }),
371        };
372
373        assert_eq!(
374            serde_json::from_value::<RunnerMount>(serde_json::to_value(&mount).unwrap()).unwrap(),
375            mount
376        );
377        assert_eq!(
378            serde_json::from_value::<RunnerArtifactExportResult>(
379                serde_json::to_value(&artifact).unwrap()
380            )
381            .unwrap(),
382            artifact
383        );
384    }
385}