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 #[serde(default)]
28 pub pausable: bool,
29 #[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#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
147pub struct ThreadRunnerBinding {
148 pub destination: RunnerDestination,
149 pub workspace: PathBuf,
151 #[serde(default)]
158 pub read_roots: Vec<PathBuf>,
159}
160
161#[derive(Clone)]
168pub struct RemoteWorkspace {
169 pub session: Arc<dyn RemoteRunnerSession>,
170 pub root: PathBuf,
171 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 #[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 fn setup_hint(&self) -> Option<String> {
260 None
261 }
262
263 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 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 async fn pause(&self) -> anyhow::Result<RunnerSessionState> {
314 Ok(self.state())
315 }
316
317 async fn resume(&self) -> anyhow::Result<RunnerSessionState> {
322 Ok(self.state())
323 }
324
325 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 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}