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     * Provider can transition a live session toward a paused/standby state and
25     * later resume it without losing the session's filesystem/process state.
26     */
27    #[serde(default)]
28    pub pausable: bool,
29    /**
30     * Provider can detach a session (releasing the local handle while keeping
31     * the remote sandbox alive) and later rejoin it from persisted state.
32     */
33    #[serde(default)]
34    pub detachable: bool,
35}
36
37#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
38pub struct RunnerMountCapabilities {
39    #[serde(default)]
40    pub s3: bool,
41    #[serde(default)]
42    pub gcs: bool,
43    #[serde(default)]
44    pub r2: bool,
45    #[serde(default)]
46    pub azure_blob: bool,
47    #[serde(default)]
48    pub box_storage: bool,
49    #[serde(default)]
50    pub provider_native: bool,
51}
52
53#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
54pub struct RunnerDestination {
55    pub id: RunnerDestinationId,
56    pub provider_id: RemoteRunnerProviderId,
57    #[serde(default)]
58    pub config: serde_json::Value,
59    #[serde(default)]
60    pub default_manifest: RunnerManifest,
61}
62
63#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
64pub struct RunnerManifest {
65    #[serde(default)]
66    pub entries: Vec<RunnerManifestEntry>,
67    #[serde(default)]
68    pub mounts: Vec<RunnerMount>,
69}
70
71#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
72pub struct RunnerManifestEntry {
73    pub source: PathBuf,
74    pub target: PathBuf,
75    pub writable: bool,
76}
77
78#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
79pub struct RunnerMount {
80    pub name: String,
81    pub path: PathBuf,
82    pub read_only: bool,
83    #[serde(default)]
84    pub intent: RunnerMountIntent,
85}
86
87#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
88pub struct RunnerMountIntent {
89    pub kind: RunnerMountKind,
90    pub uri: String,
91    #[serde(default, skip_serializing_if = "Option::is_none")]
92    pub credentials: Option<RunnerSecretRef>,
93}
94
95impl Default for RunnerMountIntent {
96    fn default() -> Self {
97        Self {
98            kind: RunnerMountKind::ProviderNative,
99            uri: String::new(),
100            credentials: None,
101        }
102    }
103}
104
105#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
106#[serde(rename_all = "snake_case")]
107pub enum RunnerMountKind {
108    S3,
109    Gcs,
110    R2,
111    AzureBlob,
112    BoxStorage,
113    ProviderNative,
114}
115
116#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
117pub struct RunnerSecretRef {
118    pub id: String,
119}
120
121#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
122pub struct RunnerSnapshotRef {
123    pub provider_id: RemoteRunnerProviderId,
124    pub snapshot_id: String,
125    #[serde(default)]
126    pub metadata: serde_json::Value,
127}
128
129#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
130pub struct RunnerSessionState {
131    pub provider_id: RemoteRunnerProviderId,
132    pub session_id: RemoteRunnerSessionId,
133    pub destination_id: RunnerDestinationId,
134    #[serde(default)]
135    pub snapshot: Option<RunnerSnapshotRef>,
136    #[serde(default)]
137    pub metadata: serde_json::Value,
138}
139
140/**
141 * Per-thread remote-runner binding chosen at thread creation. Native coding
142 * tools for a bound thread execute against this runner instead of the local
143 * filesystem; the destination config is persisted with the thread, so secrets
144 * must reach the provider through its environment, not this config.
145 */
146#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
147pub struct ThreadRunnerBinding {
148    pub destination: RunnerDestination,
149    /// Absolute path on the runner used as the thread's coding-tool workspace root.
150    pub workspace: PathBuf,
151    /**
152     * Extra absolute runner paths that file reads may resolve under, in
153     * addition to `workspace`. Writes and the working directory stay confined
154     * to `workspace`; these only widen read resolution (e.g. read-only
155     * resource mounts outside the writable workspace root).
156     */
157    #[serde(default)]
158    pub read_roots: Vec<PathBuf>,
159}
160
161/**
162 * Remote workspace handle carried on the tool execution context for
163 * runner-bound threads. Tools route file and shell operations through
164 * `session` with paths scoped under `root` (a path on the runner, not the
165 * local filesystem).
166 */
167#[derive(Clone)]
168pub struct RemoteWorkspace {
169    pub session: Arc<dyn RemoteRunnerSession>,
170    pub root: PathBuf,
171    /**
172     * Extra absolute runner paths reads may resolve under, beyond `root`.
173     * Writes and the working directory stay confined to `root`.
174     */
175    pub read_roots: Vec<PathBuf>,
176}
177
178#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
179pub struct RunnerCommandRequest {
180    pub command_id: RunnerCommandId,
181    pub program: String,
182    #[serde(default)]
183    pub args: Vec<String>,
184    #[serde(default)]
185    pub cwd: Option<PathBuf>,
186    #[serde(default)]
187    pub env: Vec<(String, String)>,
188    /// Optional wall-clock lease for the remote process. Providers should use
189    /// this as a server-side termination bound when their execution API can
190    /// outlive the client request.
191    #[serde(default, skip_serializing_if = "Option::is_none")]
192    pub timeout_ms: Option<u64>,
193}
194
195#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
196pub struct RunnerCommandResult {
197    pub command_id: RunnerCommandId,
198    pub exit_code: Option<i32>,
199    pub stdout: String,
200    pub stderr: String,
201}
202
203#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
204pub struct RunnerFileReadRequest {
205    pub path: PathBuf,
206}
207
208#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
209pub struct RunnerFileReadResult {
210    pub path: PathBuf,
211    pub contents: Vec<u8>,
212}
213
214#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
215pub struct RunnerFileWriteRequest {
216    pub path: PathBuf,
217    pub contents: Vec<u8>,
218}
219
220#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
221pub struct RunnerPortRequest {
222    pub port: u16,
223    pub label: Option<String>,
224}
225
226#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
227pub struct RunnerPortResult {
228    pub port: u16,
229    pub url: Option<String>,
230}
231
232#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
233pub struct RunnerArtifactExportRequest {
234    pub path: PathBuf,
235    #[serde(default)]
236    pub recursive: bool,
237}
238
239#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
240pub struct RunnerArtifactExportResult {
241    pub path: PathBuf,
242    pub artifact_id: String,
243    pub url: Option<String>,
244    #[serde(default)]
245    pub metadata: serde_json::Value,
246}
247
248#[async_trait::async_trait]
249pub trait RemoteRunnerProvider: Send + Sync + 'static {
250    fn id(&self) -> RemoteRunnerProviderId;
251    fn capabilities(&self) -> RunnerCapabilities;
252
253    /**
254     * Optional setup guidance shown by runner pickers when the provider is
255     * installed but not yet usable (for example a missing credential env
256     * var). Must name only documented env vars and never include secret
257     * values. `None` means the provider is ready or needs no setup hint.
258     */
259    fn setup_hint(&self) -> Option<String> {
260        None
261    }
262
263    /**
264     * Default absolute workspace path on the runner for threads that select
265     * this provider as a runtime-level destination without an explicit
266     * per-thread workspace. When `Some`, selecting this runner (e.g. from the
267     * TUI runner picker or config `default_destination`) routes a new thread's
268     * coding tools into the runner at this path. `None` (the default) keeps the
269     * legacy behavior where only an explicit `thread/start` binding routes
270     * tools, so other providers are unchanged.
271     */
272    fn default_workspace(&self) -> Option<String> {
273        None
274    }
275
276    async fn create_session(
277        &self,
278        destination: RunnerDestination,
279    ) -> anyhow::Result<Arc<dyn RemoteRunnerSession>>;
280
281    async fn validate_destination(&self, _destination: &RunnerDestination) -> anyhow::Result<()> {
282        Ok(())
283    }
284
285    async fn resume_session(
286        &self,
287        state: RunnerSessionState,
288    ) -> anyhow::Result<Arc<dyn RemoteRunnerSession>>;
289
290    /**
291     * Reattach to a previously created remote sandbox from persisted state
292     * without provisioning a new one. Providers that expose a durable,
293     * rejoinable sandbox (see `RunnerCapabilities::detachable`) override this;
294     * the default reuses `resume_session`.
295     */
296    async fn rejoin_session(
297        &self,
298        state: RunnerSessionState,
299    ) -> anyhow::Result<Arc<dyn RemoteRunnerSession>> {
300        self.resume_session(state).await
301    }
302}
303
304#[async_trait::async_trait]
305pub trait RemoteRunnerSession: Send + Sync + 'static {
306    fn state(&self) -> RunnerSessionState;
307
308    /**
309     * Move the session toward a paused/standby state to save cost. Default is a
310     * no-op for providers that do not support pausing
311     * (`RunnerCapabilities::pausable == false`). Returns the post-pause state.
312     */
313    async fn pause(&self) -> anyhow::Result<RunnerSessionState> {
314        Ok(self.state())
315    }
316
317    /**
318     * Wake a paused/standby session so subsequent commands run immediately.
319     * Default is a no-op. Returns the post-resume state.
320     */
321    async fn resume(&self) -> anyhow::Result<RunnerSessionState> {
322        Ok(self.state())
323    }
324
325    /**
326     * Release the local session handle while keeping the remote sandbox alive
327     * for a later `rejoin_session`. Returns the durable state that callers must
328     * persist to rejoin. Default errors for providers that are not detachable.
329     */
330    async fn detach(&self) -> anyhow::Result<RunnerSessionState> {
331        anyhow::bail!("runner detach is not supported by this provider")
332    }
333
334    async fn run_command(
335        &self,
336        request: RunnerCommandRequest,
337    ) -> anyhow::Result<RunnerCommandResult>;
338
339    async fn cancel_command(&self, _command_id: &RunnerCommandId) -> anyhow::Result<bool> {
340        Ok(false)
341    }
342
343    async fn read_file(
344        &self,
345        request: RunnerFileReadRequest,
346    ) -> anyhow::Result<RunnerFileReadResult>;
347
348    async fn write_file(&self, request: RunnerFileWriteRequest) -> anyhow::Result<()>;
349
350    async fn expose_port(&self, request: RunnerPortRequest) -> anyhow::Result<RunnerPortResult>;
351
352    async fn export_artifact(
353        &self,
354        _request: RunnerArtifactExportRequest,
355    ) -> anyhow::Result<RunnerArtifactExportResult> {
356        anyhow::bail!("runner artifact export is not supported by this provider")
357    }
358
359    async fn snapshot(&self) -> anyhow::Result<Option<RunnerSnapshotRef>>;
360
361    async fn close(&self) -> anyhow::Result<()>;
362}
363
364#[cfg(test)]
365mod tests {
366    use super::*;
367
368    #[test]
369    fn remote_runner_types_round_trip_json() {
370        let destination = RunnerDestination {
371            id: "local".to_string(),
372            provider_id: "unix-local".to_string(),
373            config: serde_json::json!({ "root": "." }),
374            default_manifest: RunnerManifest {
375                entries: vec![RunnerManifestEntry {
376                    source: "src".into(),
377                    target: "workspace/src".into(),
378                    writable: true,
379                }],
380                mounts: vec![RunnerMount {
381                    name: "cache".to_string(),
382                    path: ".cache".into(),
383                    read_only: false,
384                    intent: RunnerMountIntent::default(),
385                }],
386            },
387        };
388
389        let encoded = serde_json::to_value(&destination).unwrap();
390        let decoded: RunnerDestination = serde_json::from_value(encoded).unwrap();
391
392        assert_eq!(decoded, destination);
393    }
394
395    #[test]
396    fn capabilities_round_trip_includes_lifecycle_flags() {
397        let capabilities = RunnerCapabilities {
398            command_exec: true,
399            file_read: true,
400            file_write: true,
401            port_preview: true,
402            snapshots: false,
403            cancellation: true,
404            artifact_export: false,
405            mounts: RunnerMountCapabilities::default(),
406            pausable: true,
407            detachable: true,
408        };
409        let encoded = serde_json::to_value(&capabilities).unwrap();
410        let decoded: RunnerCapabilities = serde_json::from_value(encoded).unwrap();
411        assert_eq!(decoded, capabilities);
412        assert!(decoded.pausable);
413        assert!(decoded.detachable);
414
415        // Older payloads without the lifecycle flags default to false.
416        let legacy: RunnerCapabilities = serde_json::from_value(serde_json::json!({
417            "command_exec": true,
418            "file_read": true,
419            "file_write": true,
420            "port_preview": false,
421            "snapshots": false,
422            "cancellation": false
423        }))
424        .unwrap();
425        assert!(!legacy.pausable);
426        assert!(!legacy.detachable);
427    }
428
429    #[test]
430    fn command_and_port_operations_are_protocol_safe() {
431        let command = RunnerCommandRequest {
432            command_id: "cmd-1".to_string(),
433            program: "sh".to_string(),
434            args: vec!["-lc".to_string(), "echo hi".to_string()],
435            cwd: Some("workspace".into()),
436            env: vec![("RUST_LOG".to_string(), "info".to_string())],
437            timeout_ms: None,
438        };
439        let port = RunnerPortResult {
440            port: 3000,
441            url: Some("https://preview.example".to_string()),
442        };
443
444        assert_eq!(
445            serde_json::from_value::<RunnerCommandRequest>(serde_json::to_value(&command).unwrap())
446                .unwrap(),
447            command
448        );
449        assert_eq!(
450            serde_json::from_value::<RunnerPortResult>(serde_json::to_value(&port).unwrap())
451                .unwrap(),
452            port
453        );
454    }
455
456    #[test]
457    fn mount_and_artifact_operations_are_protocol_safe() {
458        let mount = RunnerMount {
459            name: "dataset".to_string(),
460            path: "mnt/dataset".into(),
461            read_only: true,
462            intent: RunnerMountIntent {
463                kind: RunnerMountKind::R2,
464                uri: "r2://bucket/prefix".to_string(),
465                credentials: Some(RunnerSecretRef {
466                    id: "r2-readonly".to_string(),
467                }),
468            },
469        };
470        let artifact = RunnerArtifactExportResult {
471            path: "out/report.json".into(),
472            artifact_id: "artifact-1".to_string(),
473            url: Some("https://artifacts.example/report.json".to_string()),
474            metadata: serde_json::json!({ "size": 128 }),
475        };
476
477        assert_eq!(
478            serde_json::from_value::<RunnerMount>(serde_json::to_value(&mount).unwrap()).unwrap(),
479            mount
480        );
481        assert_eq!(
482            serde_json::from_value::<RunnerArtifactExportResult>(
483                serde_json::to_value(&artifact).unwrap()
484            )
485            .unwrap(),
486            artifact
487        );
488    }
489}