1use async_trait::async_trait;
9use futures::stream::BoxStream;
10
11use crate::error::Result;
12use crate::fleet::FleetManager;
13use crate::model::{AgentHandle, AgentId, DrainPolicy, FleetChange, TaskSpec};
14
15#[async_trait]
16pub trait FleetProvider: Send + Sync {
17 async fn ensure_agent(&self, spec: TaskSpec) -> Result<AgentHandle>;
19
20 fn watch_fleet(&self) -> BoxStream<'static, FleetChange>;
22
23 async fn stop_agent(&self, id: &AgentId, drain: DrainPolicy) -> Result<()>;
25
26 async fn restart_agent(&self, id: &AgentId) -> Result<AgentId>;
28
29 async fn remove_agent(&self, id: &AgentId, force: bool) -> Result<()>;
32
33 async fn snapshot(&self) -> crate::model::FleetSnapshot;
36
37 async fn readiness(&self) -> crate::model::Readiness;
41
42 fn metrics(&self) -> crate::metrics::MetricsSnapshot;
44
45 async fn send_input(
49 &self,
50 id: &AgentId,
51 input: crate::caliband::wire::AttachInbound,
52 ) -> Result<()>;
53}
54
55#[derive(Clone)]
57pub struct LocalFleet {
58 inner: FleetManager,
59}
60
61impl LocalFleet {
62 #[must_use]
63 pub fn new(inner: FleetManager) -> Self {
64 Self { inner }
65 }
66
67 #[must_use]
70 pub fn manager(&self) -> &FleetManager {
71 &self.inner
72 }
73}
74
75#[async_trait]
76impl FleetProvider for LocalFleet {
77 async fn ensure_agent(&self, spec: TaskSpec) -> Result<AgentHandle> {
78 let (id, endpoint) = self
82 .inner
83 .spawn_agent_with_socket(&spec.workspace, spec.request)
84 .await?;
85 Ok(AgentHandle {
86 id: AgentId::from(id),
87 workspace: spec.workspace,
88 endpoint: Some(endpoint),
89 created: true,
92 })
93 }
94
95 fn watch_fleet(&self) -> BoxStream<'static, FleetChange> {
96 self.inner.watch_changes()
97 }
98
99 async fn stop_agent(&self, id: &AgentId, drain: DrainPolicy) -> Result<()> {
100 match drain {
101 DrainPolicy::Kill => self.inner.kill_agent(id.as_str()).await,
102 DrainPolicy::Graceful { timeout_ms } => {
103 self.inner
104 .drain_agent(id.as_str(), std::time::Duration::from_millis(timeout_ms))
105 .await
106 }
107 }
108 }
109
110 async fn restart_agent(&self, id: &AgentId) -> Result<AgentId> {
111 let new_id = self.inner.respawn_agent(id.as_str()).await?;
112 Ok(AgentId::from(new_id))
113 }
114
115 async fn remove_agent(&self, id: &AgentId, force: bool) -> Result<()> {
116 self.inner.rm_agent(id.as_str(), force).await
117 }
118
119 async fn snapshot(&self) -> crate::model::FleetSnapshot {
120 self.inner.snapshot().await
121 }
122
123 async fn readiness(&self) -> crate::model::Readiness {
124 self.inner.readiness().await
125 }
126
127 fn metrics(&self) -> crate::metrics::MetricsSnapshot {
128 self.inner.metrics()
129 }
130
131 async fn send_input(
132 &self,
133 id: &AgentId,
134 input: crate::caliband::wire::AttachInbound,
135 ) -> Result<()> {
136 self.inner.send_agent_input(id.as_str(), input).await
137 }
138}
139
140#[async_trait]
147pub trait FleetAdmin: Send + Sync {
148 async fn add_workspace(
151 &self,
152 name: String,
153 root: std::path::PathBuf,
154 config: crate::registry::WorkspaceConfig,
155 ) -> Result<()>;
156
157 async fn remove_workspace(&self, name: &str) -> Result<bool>;
159
160 async fn set_workspace_config(
163 &self,
164 name: &str,
165 config: crate::registry::WorkspaceConfig,
166 ) -> Result<()>;
167
168 async fn list_workspaces(&self) -> Result<Vec<crate::registry::WorkspaceInfo>> {
175 Ok(Vec::new())
176 }
177
178 fn workspace_ops_are_async(&self) -> bool {
183 false
184 }
185}
186
187#[async_trait]
188impl FleetAdmin for LocalFleet {
189 async fn add_workspace(
190 &self,
191 name: String,
192 root: std::path::PathBuf,
193 config: crate::registry::WorkspaceConfig,
194 ) -> Result<()> {
195 self.inner
198 .add_workspace_with_config(name, root, config.local)
199 .await
200 }
201
202 async fn remove_workspace(&self, name: &str) -> Result<bool> {
203 self.inner.remove_repo(name).await
204 }
205
206 async fn set_workspace_config(
207 &self,
208 name: &str,
209 config: crate::registry::WorkspaceConfig,
210 ) -> Result<()> {
211 self.inner.set_repo_config(name, config.local).await
212 }
213}
214
215#[cfg(all(test, feature = "testkit"))]
216mod local_fleet_tests {
217 use super::*;
218 use crate::fleet::{FleetConfig, SpawnRequest};
219 use crate::store::JsonlStore;
220 use crate::testkit::FakeCaliband;
221 use std::sync::Arc;
222
223 async fn setup() -> (LocalFleet, FakeCaliband, tempfile::TempDir) {
228 let dir = tempfile::tempdir().unwrap();
229 let mut config = FleetConfig::new("local", dir.path());
230 config.discovery_env.caliban_daemon_runtime_dir = Some(dir.path().to_path_buf());
231 config.ensure.autostart = false; let root = dir.path().join("repo-a");
233 std::fs::create_dir_all(&root).unwrap();
234 let socket = crate::discovery::resolve_socket(&root, &config.discovery_env).unwrap();
235
236 let fake = FakeCaliband::start_at(&socket).await.unwrap();
237 let store = Arc::new(JsonlStore::open(dir.path()).unwrap());
238 let mgr = FleetManager::new(config, store).await.unwrap();
239 mgr.add_repo("repo-a", &root).await.unwrap();
240
241 (LocalFleet::new(mgr), fake, dir)
242 }
243
244 #[tokio::test]
253 async fn ensure_agent_does_not_issue_a_second_attach() {
254 let (provider, fake, _dir) = setup().await;
255
256 let handle = provider
257 .ensure_agent(TaskSpec {
258 workspace: "repo-a".into(),
259 request: SpawnRequest::new("task"),
260 })
261 .await
262 .expect("ensure_agent");
263
264 assert!(!fake.received_specs().is_empty(), "spawn reached the fake");
265 assert!(
266 fake.received_attach_ids().is_empty(),
267 "ensure_agent must not issue an Attach to resolve the socket it already has, but saw: {:?}",
268 fake.received_attach_ids()
269 );
270
271 let expected = crate::caliband::wire::Endpoint::Unix {
275 path: _dir.path().join(format!("{}.sock", handle.id.as_str())),
276 };
277 assert_eq!(handle.endpoint, Some(expected));
278 }
279
280 #[tokio::test]
281 async fn ensure_then_stop_agent_via_provider() {
282 let (provider, fake, _dir) = setup().await;
283
284 let handle = provider
285 .ensure_agent(TaskSpec {
286 workspace: "repo-a".into(),
287 request: SpawnRequest::new("task"),
288 })
289 .await
290 .expect("ensure_agent");
291 assert_eq!(handle.workspace, "repo-a");
292 assert!(!fake.received_specs().is_empty());
293
294 provider.manager().poll_repo_once("repo-a").await;
298
299 provider
300 .stop_agent(&handle.id, DrainPolicy::Kill)
301 .await
302 .expect("stop");
303
304 provider.manager().poll_repo_once("repo-a").await;
307 let snap = provider.manager().snapshot().await;
308 let (_, agent) = snap
309 .find_agent(handle.id.as_str())
310 .expect("agent still known");
311 assert_eq!(agent.status, crate::model::AgentStatus::Killed);
312 }
313
314 #[tokio::test]
315 async fn restart_agent_returns_new_id() {
316 let (provider, _fake, _dir) = setup().await;
317
318 let handle = provider
319 .ensure_agent(TaskSpec {
320 workspace: "repo-a".into(),
321 request: SpawnRequest::new("task"),
322 })
323 .await
324 .expect("ensure_agent");
325 provider.manager().poll_repo_once("repo-a").await;
326
327 let new_id = provider
328 .restart_agent(&handle.id)
329 .await
330 .expect("restart_agent");
331 assert_ne!(new_id, handle.id);
332 }
333
334 #[tokio::test]
339 async fn watch_fleet_reports_discovered() {
340 use crate::model::FleetChange;
341 use crate::testkit::test_record;
342 use futures::StreamExt;
343 use std::time::Duration;
344
345 let (provider, mut fake, dir) = setup().await;
346
347 let mut changes = provider.watch_fleet();
351
352 fake.add_agent(
353 test_record("a1", dir.path(), crate::model::AgentStatus::Running, false),
354 Vec::new(),
355 )
356 .await;
357 provider.manager().poll_repo_once("repo-a").await;
358
359 let mut discovered = None;
364 for _ in 0..5 {
365 let item = tokio::time::timeout(Duration::from_secs(1), changes.next())
366 .await
367 .expect("timed out waiting for a FleetChange")
368 .expect("watch_fleet stream ended unexpectedly");
369 if matches!(item, FleetChange::Discovered { .. }) {
370 discovered = Some(item);
371 break;
372 }
373 }
374 let ev = discovered.expect("did not observe a Discovered change in time");
375 assert!(
376 matches!(ev, FleetChange::Discovered { ref id, workspace: ref repo, .. }
377 if id.as_str() == "a1" && repo == "repo-a")
378 );
379 }
380
381 async fn setup_with_background_poll() -> (LocalFleet, FakeCaliband, tempfile::TempDir) {
388 let dir = tempfile::tempdir().unwrap();
389 let mut config = FleetConfig::new("local", dir.path());
390 config.discovery_env.caliban_daemon_runtime_dir = Some(dir.path().to_path_buf());
391 config.ensure.autostart = false; config.poll_interval = std::time::Duration::from_millis(20);
393 let root = dir.path().join("repo-a");
394 std::fs::create_dir_all(&root).unwrap();
395 let socket = crate::discovery::resolve_socket(&root, &config.discovery_env).unwrap();
396
397 let fake = FakeCaliband::start_at(&socket).await.unwrap();
398 let store = Arc::new(JsonlStore::open(dir.path()).unwrap());
399 let mgr = FleetManager::new(config, store).await.unwrap();
400 mgr.add_repo("repo-a", &root).await.unwrap();
401
402 tokio::spawn(mgr.clone().run());
403
404 (LocalFleet::new(mgr), fake, dir)
405 }
406
407 #[tokio::test]
409 async fn local_fleet_satisfies_conformance() {
410 let (provider, fake, _dir) = setup_with_background_poll().await;
411 crate::testkit::fleet_provider_conformance(&provider, &fake).await;
412 provider.manager().begin_shutdown();
415 }
416
417 #[tokio::test]
423 async fn local_fleet_drives_control_plane_over_tcp_tls() {
424 use crate::testkit::FakeCaliband;
425
426 let (fake, fixture) = FakeCaliband::start_tcp_tls("s3cr3t").await.unwrap();
427 let dir = tempfile::tempdir().unwrap();
428 let mut config = FleetConfig::new("local", dir.path());
429 config.ensure.autostart = false;
430 config.poll_interval = std::time::Duration::from_millis(20);
431 config.caliband_network = Some(crate::fleet::CalibandNetworkConfig {
432 addr: fixture.addr.clone(),
433 ca_pem: fixture.ca_pem.clone(),
434 server_name: "localhost".into(),
435 token: Some("s3cr3t".into()),
436 });
437 let root = dir.path().join("repo-a");
438 std::fs::create_dir_all(&root).unwrap();
439 let store = Arc::new(JsonlStore::open(dir.path()).unwrap());
440 let mgr = FleetManager::new(config, store).await.unwrap();
441 mgr.add_repo("repo-a", &root).await.unwrap();
442 let provider = LocalFleet::new(mgr);
443
444 let handle = provider
446 .ensure_agent(TaskSpec {
447 workspace: "repo-a".into(),
448 request: SpawnRequest::new("task"),
449 })
450 .await
451 .expect("ensure_agent over tcp+tls");
452 assert_eq!(handle.workspace, "repo-a");
453 assert!(!fake.received_specs().is_empty(), "spawn reached the fake");
454
455 provider.manager().poll_repo_once("repo-a").await;
457 {
458 let snap = provider.manager().snapshot().await;
459 assert!(
460 snap.find_agent(handle.id.as_str()).is_some(),
461 "agent observed over the tcp control plane"
462 );
463 }
464
465 provider
467 .stop_agent(&handle.id, DrainPolicy::Kill)
468 .await
469 .expect("kill over tcp+tls");
470 provider.manager().poll_repo_once("repo-a").await;
471 let snap = provider.manager().snapshot().await;
472 let (_, agent) = snap
473 .find_agent(handle.id.as_str())
474 .expect("agent still known");
475 assert_eq!(agent.status, crate::model::AgentStatus::Killed);
476
477 provider.manager().begin_shutdown();
478 let _ = fake;
479 }
480
481 #[tokio::test]
485 async fn workspace_with_two_sources_drives_one_caliband() {
486 use crate::testkit::test_record;
487
488 let dir = tempfile::tempdir().unwrap();
489 std::fs::create_dir_all(dir.path().join("alpha/.git")).unwrap();
490 std::fs::create_dir_all(dir.path().join("beta/.git")).unwrap();
491
492 let mut config = FleetConfig::new("local", dir.path());
493 config.discovery_env.caliban_daemon_runtime_dir = Some(dir.path().to_path_buf());
494 config.ensure.autostart = false;
495 let socket = crate::discovery::resolve_socket(dir.path(), &config.discovery_env).unwrap();
497 let mut fake = FakeCaliband::start_at(&socket).await.unwrap();
498 let store = Arc::new(JsonlStore::open(dir.path()).unwrap());
499 let mgr = FleetManager::new(config, store).await.unwrap();
500
501 mgr.add_workspace("ws", dir.path()).await.unwrap();
502
503 {
505 let snap = mgr.snapshot().await;
506 let ws = snap.workspaces.iter().find(|w| w.name == "ws").unwrap();
507 assert_eq!(
508 ws.sources
509 .iter()
510 .map(|s| s.name.as_str())
511 .collect::<Vec<_>>(),
512 vec!["alpha", "beta"],
513 "workspace enumerates its two sources"
514 );
515 }
516
517 let canon = crate::discovery::canonical_root(dir.path()).unwrap();
519 fake.add_agent(
520 test_record("a1", &canon, crate::model::AgentStatus::Running, false),
521 Vec::new(),
522 )
523 .await;
524 mgr.poll_repo_once("ws").await;
525 let snap = mgr.snapshot().await;
526 assert!(
527 snap.find_agent("a1").is_some(),
528 "agent observed through the single workspace caliband"
529 );
530
531 mgr.begin_shutdown();
532 }
533
534 #[tokio::test]
537 async fn local_fleet_snapshot_readiness_metrics_via_trait() {
538 let (provider, _fake, _dir) = setup().await;
539 let p: &dyn FleetProvider = &provider;
540 let snap = p.snapshot().await;
541 assert!(snap.workspaces.iter().any(|w| w.name == "repo-a"));
542 let r = p.readiness().await;
543 assert!(r.store_writable);
545 let _ = p.metrics();
546 }
547
548 #[tokio::test]
551 async fn local_fleet_admin_add_and_remove_workspace() {
552 let (provider, _fake, dir) = setup().await;
553 let admin: &dyn FleetAdmin = &provider;
554 let root = dir.path().join("repo-b");
555 std::fs::create_dir_all(&root).unwrap();
556 admin
557 .add_workspace("repo-b".into(), root, Default::default())
558 .await
559 .unwrap();
560 assert!(
561 provider
562 .snapshot()
563 .await
564 .workspaces
565 .iter()
566 .any(|w| w.name == "repo-b")
567 );
568 assert!(admin.remove_workspace("repo-b").await.unwrap());
569 }
570}