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/**
221 * Bounds for an optional provider-owned lease that serializes one complete
222 * workspace tool execution. The runtime supplies a unique id and enforces the
223 * acquisition timeout locally; providers should also enforce both bounds at
224 * the remote execution boundary so a crashed runtime cannot orphan a lock.
225 */
226#[derive(Debug, Clone, PartialEq, Eq)]
227pub struct RunnerWorkspaceExecutionLeaseRequest {
228    pub execution_id: String,
229    pub acquire_timeout_ms: u64,
230    /**
231     * Maximum lifetime for the acquired remote lease. `None` means the turn
232     * has no runtime deadline; providers must still choose a finite bound.
233     */
234    pub lease_timeout_ms: Option<u64>,
235}
236
237/**
238 * Provider-owned serialization fence held around one complete workspace tool
239 * execution. `wait_lost` must remain pending while the fence is authoritative
240 * and is cancellation-safe: the runtime selects it against tool execution.
241 *
242 * `release` is called after every normal tool result or error, including after
243 * `wait_lost` reports that the fence ended. Implementations must also fail
244 * closed when the guard or an in-progress release future is dropped (for
245 * example, keep a remotely bounded lease alive until in-flight operations are
246 * quiescent or its server-side timeout expires).
247 */
248#[async_trait::async_trait]
249pub trait RemoteWorkspaceExecutionLease: Send + Sync + 'static {
250    async fn wait_lost(&self) -> anyhow::Result<()>;
251
252    async fn release(self: Box<Self>) -> anyhow::Result<()>;
253}
254
255#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
256pub struct RunnerPortRequest {
257    pub port: u16,
258    pub label: Option<String>,
259}
260
261#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
262pub struct RunnerPortResult {
263    pub port: u16,
264    pub url: Option<String>,
265}
266
267#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
268pub struct RunnerArtifactExportRequest {
269    pub path: PathBuf,
270    #[serde(default)]
271    pub recursive: bool,
272}
273
274#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
275pub struct RunnerArtifactExportResult {
276    pub path: PathBuf,
277    pub artifact_id: String,
278    pub url: Option<String>,
279    #[serde(default)]
280    pub metadata: serde_json::Value,
281}
282
283#[async_trait::async_trait]
284pub trait RemoteRunnerProvider: Send + Sync + 'static {
285    fn id(&self) -> RemoteRunnerProviderId;
286    fn capabilities(&self) -> RunnerCapabilities;
287
288    /**
289     * Optional setup guidance shown by runner pickers when the provider is
290     * installed but not yet usable (for example a missing credential env
291     * var). Must name only documented env vars and never include secret
292     * values. `None` means the provider is ready or needs no setup hint.
293     */
294    fn setup_hint(&self) -> Option<String> {
295        None
296    }
297
298    /**
299     * Default absolute workspace path on the runner for threads that select
300     * this provider as a runtime-level destination without an explicit
301     * per-thread workspace. When `Some`, selecting this runner (e.g. from the
302     * TUI runner picker or config `default_destination`) routes a new thread's
303     * coding tools into the runner at this path. `None` (the default) keeps the
304     * legacy behavior where only an explicit `thread/start` binding routes
305     * tools, so other providers are unchanged.
306     */
307    fn default_workspace(&self) -> Option<String> {
308        None
309    }
310
311    async fn create_session(
312        &self,
313        destination: RunnerDestination,
314    ) -> anyhow::Result<Arc<dyn RemoteRunnerSession>>;
315
316    async fn validate_destination(&self, _destination: &RunnerDestination) -> anyhow::Result<()> {
317        Ok(())
318    }
319
320    async fn resume_session(
321        &self,
322        state: RunnerSessionState,
323    ) -> anyhow::Result<Arc<dyn RemoteRunnerSession>>;
324
325    /**
326     * Reattach to a previously created remote sandbox from persisted state
327     * without provisioning a new one. Providers that expose a durable,
328     * rejoinable sandbox (see `RunnerCapabilities::detachable`) override this;
329     * the default reuses `resume_session`.
330     */
331    async fn rejoin_session(
332        &self,
333        state: RunnerSessionState,
334    ) -> anyhow::Result<Arc<dyn RemoteRunnerSession>> {
335        self.resume_session(state).await
336    }
337}
338
339#[async_trait::async_trait]
340pub trait RemoteRunnerSession: Send + Sync + 'static {
341    fn state(&self) -> RunnerSessionState;
342
343    /**
344     * Optionally acquire a provider-authoritative fence around a complete
345     * workspace tool execution. This complements the runtime's in-process
346     * session mutex for providers whose durable workspaces can be reached by
347     * multiple runtimes or replicas.
348     *
349     * Providers without a cross-process fence return `None`; they retain the
350     * runtime-local serialization behavior. Acquisition must be cancellation
351     * safe: if this future is dropped at the requested timeout, it must not
352     * leave an authoritative remote fence orphaned without its finite
353     * server-side expiry.
354     */
355    async fn acquire_workspace_execution_lease(
356        &self,
357        _request: RunnerWorkspaceExecutionLeaseRequest,
358    ) -> anyhow::Result<Option<Box<dyn RemoteWorkspaceExecutionLease>>> {
359        Ok(None)
360    }
361
362    /**
363     * Move the session toward a paused/standby state to save cost. Default is a
364     * no-op for providers that do not support pausing
365     * (`RunnerCapabilities::pausable == false`). Returns the post-pause state.
366     */
367    async fn pause(&self) -> anyhow::Result<RunnerSessionState> {
368        Ok(self.state())
369    }
370
371    /**
372     * Wake a paused/standby session so subsequent commands run immediately.
373     * Default is a no-op. Returns the post-resume state.
374     */
375    async fn resume(&self) -> anyhow::Result<RunnerSessionState> {
376        Ok(self.state())
377    }
378
379    /**
380     * Release the local session handle while keeping the remote sandbox alive
381     * for a later `rejoin_session`. Returns the durable state that callers must
382     * persist to rejoin. Default errors for providers that are not detachable.
383     */
384    async fn detach(&self) -> anyhow::Result<RunnerSessionState> {
385        anyhow::bail!("runner detach is not supported by this provider")
386    }
387
388    async fn run_command(
389        &self,
390        request: RunnerCommandRequest,
391    ) -> anyhow::Result<RunnerCommandResult>;
392
393    async fn cancel_command(&self, _command_id: &RunnerCommandId) -> anyhow::Result<bool> {
394        Ok(false)
395    }
396
397    async fn read_file(
398        &self,
399        request: RunnerFileReadRequest,
400    ) -> anyhow::Result<RunnerFileReadResult>;
401
402    async fn write_file(&self, request: RunnerFileWriteRequest) -> anyhow::Result<()>;
403
404    async fn expose_port(&self, request: RunnerPortRequest) -> anyhow::Result<RunnerPortResult>;
405
406    async fn export_artifact(
407        &self,
408        _request: RunnerArtifactExportRequest,
409    ) -> anyhow::Result<RunnerArtifactExportResult> {
410        anyhow::bail!("runner artifact export is not supported by this provider")
411    }
412
413    async fn snapshot(&self) -> anyhow::Result<Option<RunnerSnapshotRef>>;
414
415    async fn close(&self) -> anyhow::Result<()>;
416}
417
418#[cfg(test)]
419mod tests {
420    use super::*;
421
422    #[test]
423    fn remote_runner_types_round_trip_json() {
424        let destination = RunnerDestination {
425            id: "local".to_string(),
426            provider_id: "unix-local".to_string(),
427            config: serde_json::json!({ "root": "." }),
428            default_manifest: RunnerManifest {
429                entries: vec![RunnerManifestEntry {
430                    source: "src".into(),
431                    target: "workspace/src".into(),
432                    writable: true,
433                }],
434                mounts: vec![RunnerMount {
435                    name: "cache".to_string(),
436                    path: ".cache".into(),
437                    read_only: false,
438                    intent: RunnerMountIntent::default(),
439                }],
440            },
441        };
442
443        let encoded = serde_json::to_value(&destination).unwrap();
444        let decoded: RunnerDestination = serde_json::from_value(encoded).unwrap();
445
446        assert_eq!(decoded, destination);
447    }
448
449    #[test]
450    fn capabilities_round_trip_includes_lifecycle_flags() {
451        let capabilities = RunnerCapabilities {
452            command_exec: true,
453            file_read: true,
454            file_write: true,
455            port_preview: true,
456            snapshots: false,
457            cancellation: true,
458            artifact_export: false,
459            mounts: RunnerMountCapabilities::default(),
460            pausable: true,
461            detachable: true,
462        };
463        let encoded = serde_json::to_value(&capabilities).unwrap();
464        let decoded: RunnerCapabilities = serde_json::from_value(encoded).unwrap();
465        assert_eq!(decoded, capabilities);
466        assert!(decoded.pausable);
467        assert!(decoded.detachable);
468
469        // Older payloads without the lifecycle flags default to false.
470        let legacy: RunnerCapabilities = serde_json::from_value(serde_json::json!({
471            "command_exec": true,
472            "file_read": true,
473            "file_write": true,
474            "port_preview": false,
475            "snapshots": false,
476            "cancellation": false
477        }))
478        .unwrap();
479        assert!(!legacy.pausable);
480        assert!(!legacy.detachable);
481    }
482
483    #[test]
484    fn command_and_port_operations_are_protocol_safe() {
485        let command = RunnerCommandRequest {
486            command_id: "cmd-1".to_string(),
487            program: "sh".to_string(),
488            args: vec!["-lc".to_string(), "echo hi".to_string()],
489            cwd: Some("workspace".into()),
490            env: vec![("RUST_LOG".to_string(), "info".to_string())],
491            timeout_ms: None,
492        };
493        let port = RunnerPortResult {
494            port: 3000,
495            url: Some("https://preview.example".to_string()),
496        };
497
498        assert_eq!(
499            serde_json::from_value::<RunnerCommandRequest>(serde_json::to_value(&command).unwrap())
500                .unwrap(),
501            command
502        );
503        assert_eq!(
504            serde_json::from_value::<RunnerPortResult>(serde_json::to_value(&port).unwrap())
505                .unwrap(),
506            port
507        );
508    }
509
510    #[test]
511    fn mount_and_artifact_operations_are_protocol_safe() {
512        let mount = RunnerMount {
513            name: "dataset".to_string(),
514            path: "mnt/dataset".into(),
515            read_only: true,
516            intent: RunnerMountIntent {
517                kind: RunnerMountKind::R2,
518                uri: "r2://bucket/prefix".to_string(),
519                credentials: Some(RunnerSecretRef {
520                    id: "r2-readonly".to_string(),
521                }),
522            },
523        };
524        let artifact = RunnerArtifactExportResult {
525            path: "out/report.json".into(),
526            artifact_id: "artifact-1".to_string(),
527            url: Some("https://artifacts.example/report.json".to_string()),
528            metadata: serde_json::json!({ "size": 128 }),
529        };
530
531        assert_eq!(
532            serde_json::from_value::<RunnerMount>(serde_json::to_value(&mount).unwrap()).unwrap(),
533            mount
534        );
535        assert_eq!(
536            serde_json::from_value::<RunnerArtifactExportResult>(
537                serde_json::to_value(&artifact).unwrap()
538            )
539            .unwrap(),
540            artifact
541        );
542    }
543}