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, PartialEq, Eq)]
227pub struct RunnerWorkspaceExecutionLeaseRequest {
228 pub execution_id: String,
229 pub acquire_timeout_ms: u64,
230 pub lease_timeout_ms: Option<u64>,
235}
236
237#[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 fn setup_hint(&self) -> Option<String> {
295 None
296 }
297
298 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 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 async fn acquire_workspace_execution_lease(
356 &self,
357 _request: RunnerWorkspaceExecutionLeaseRequest,
358 ) -> anyhow::Result<Option<Box<dyn RemoteWorkspaceExecutionLease>>> {
359 Ok(None)
360 }
361
362 async fn pause(&self) -> anyhow::Result<RunnerSessionState> {
368 Ok(self.state())
369 }
370
371 async fn resume(&self) -> anyhow::Result<RunnerSessionState> {
376 Ok(self.state())
377 }
378
379 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 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}