Skip to main content

prospero_core/
fleet_provider.rs

1//! The `FleetProvider` seam: an ensure-desired-state + observe abstraction over
2//! a fleet of caliband-supervised agents. `LocalFleet` is the caliband-over-Unix
3//! -sockets backend (today's behavior); future backends (K8sFleet — epic #274 P2;
4//! remote — prospero #1) implement the same trait. The live session plane
5//! (attach/stream/steer) is deliberately NOT part of this trait — it stays on
6//! `CalibandClient` and is shared across backends.
7
8use 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    /// Ensure an agent for `spec` exists and is attachable. Idempotent.
18    async fn ensure_agent(&self, spec: TaskSpec) -> Result<AgentHandle>;
19
20    /// Observe the fleet: an initial listing followed by live change events.
21    fn watch_fleet(&self) -> BoxStream<'static, FleetChange>;
22
23    /// Stop an agent per `drain` policy.
24    async fn stop_agent(&self, id: &AgentId, drain: DrainPolicy) -> Result<()>;
25
26    /// Restart an agent; returns the (possibly new) id.
27    async fn restart_agent(&self, id: &AgentId) -> Result<AgentId>;
28
29    /// Forget an agent entirely (local: remove from caliband's registry; k8s:
30    /// delete its `CalibanTask` CR). (#76)
31    async fn remove_agent(&self, id: &AgentId, force: bool) -> Result<()>;
32
33    /// A point-in-time view of the whole fleet. Local builds it from the poll
34    /// snapshot; k8s projects the live `CalibanTask`s. (#76)
35    async fn snapshot(&self) -> crate::model::FleetSnapshot;
36
37    /// Readiness of the backend + its store. Local reports store-writability +
38    /// per-workspace poll health; k8s reports store-writability + kube API
39    /// reachability. (#76)
40    async fn readiness(&self) -> crate::model::Readiness;
41
42    /// Backend counters for `/api/metrics`. (#76)
43    fn metrics(&self) -> crate::metrics::MetricsSnapshot;
44
45    /// Steer an interactive agent: deliver an inbound frame to its session
46    /// plane (local: over the per-agent Unix socket; k8s: dial the agent's
47    /// caliband endpoint over the network). (#76)
48    async fn send_input(
49        &self,
50        id: &AgentId,
51        input: crate::caliband::wire::AttachInbound,
52    ) -> Result<()>;
53}
54
55/// caliband-over-Unix-sockets backend — wraps today's `FleetManager` verbatim.
56#[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    /// Access the underlying manager (session plane, API handlers still use it
68    /// directly in P1).
69    #[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        // `spawn_agent_with_socket` already returns the per-agent socket
79        // `client.spawn` produced, so no follow-up `Attach` round-trip is
80        // needed to resolve it (and no new failure mode on the success path).
81        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            // Local always spawns: caliband assigns a fresh id per request, so
90            // there is no name-derived CR to collide with the way k8s has (#190).
91            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/// The workspace-registry / provider-config plane — a prospero concept
141/// (`Registry` of managed workspaces). Both backends implement it: `LocalFleet`
142/// projects the backend-neutral [`WorkspaceConfig`] onto its internal
143/// single-provider `RepoProviderConfig` path (unchanged); `K8sFleet` maps the
144/// rich fields onto a `Workspace` custom resource. The API returns 405 only
145/// where a backend leaves the `admin` seam unwired. (#76, #142)
146#[async_trait]
147pub trait FleetAdmin: Send + Sync {
148    /// Register a workspace and persist it. `root` is the LocalFleet checkout
149    /// path; k8s ignores it and uses `config.sources` instead.
150    async fn add_workspace(
151        &self,
152        name: String,
153        root: std::path::PathBuf,
154        config: crate::registry::WorkspaceConfig,
155    ) -> Result<()>;
156
157    /// Unregister a workspace; returns whether one existed.
158    async fn remove_workspace(&self, name: &str) -> Result<bool>;
159
160    /// Replace a workspace's configuration (local: restarts its caliband;
161    /// k8s: patches the `Workspace` CR, operator reconciles).
162    async fn set_workspace_config(
163        &self,
164        name: &str,
165        config: crate::registry::WorkspaceConfig,
166    ) -> Result<()>;
167
168    /// List configured workspaces with reconciliation status, for the read side
169    /// (`GET /api/workspaces`). The default returns empty: backends whose
170    /// workspaces already appear in the fleet snapshot (local) need not
171    /// duplicate them here. `K8sWorkspaceAdmin` overrides this to return its
172    /// `Workspace` CRs, so a configured-but-agentless workspace is still
173    /// visible with its status. (#142)
174    async fn list_workspaces(&self) -> Result<Vec<crate::registry::WorkspaceInfo>> {
175        Ok(Vec::new())
176    }
177
178    /// Whether workspace create/config completes asynchronously (the caller
179    /// should treat success as *accepted, reconciling* rather than *done*).
180    /// Local applies config synchronously (`false`); the k8s config plane hands
181    /// off to the operator's reconcile loop (`true` → the API answers `202`).
182    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        // LocalFleet uses only the single-provider/env subset; the rich k8s
196        // fields (sources/providers/…) don't apply to a local checkout.
197        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    /// Wire a `FleetManager` over a `FakeCaliband` control socket, following the
224    /// same discovery-derived path used across `fleet.rs`'s own inline tests
225    /// (e.g. `restart_caliband_shuts_down_and_clears_client`), then wrap it in
226    /// `LocalFleet`.
227    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; // no real caliband to spawn in tests
232        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    /// Regression for the whole-branch-review finding: `ensure_agent` used to
245    /// resolve the spawned agent's socket via a second `Attach` round-trip
246    /// (`FleetManager::agent_socket`) even though `client.spawn` already
247    /// returns the socket. That extra round-trip both duplicated a request
248    /// and introduced a new failure mode (a successful spawn could still fail
249    /// `ensure_agent` if the follow-up attach errored). Assert the fake sees a
250    /// `Spawn` but no `Attach`, and that the handle's socket is exactly the one
251    /// the fake's `Spawn` reply advertised.
252    #[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        // The endpoint on the handle must be the one caliband's `Spawned` reply
272        // advertised for this id, proving it came straight from `spawn`'s
273        // return value rather than a (now-absent) follow-up attach.
274        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        // Populate the manager's snapshot so `stop_agent` (via `kill_agent` ->
295        // `repo_of`) can resolve the agent's repo, mirroring how `fleet.rs`'s own
296        // tests poll once after a spawn before acting on the agent id.
297        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        // Confirm the kill actually reached the fake: re-poll and check the
305        // manager's own view of the agent's status.
306        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    /// Task 3: `watch_fleet` seeds from the current snapshot (here, just
335    /// `repo-a`'s `WorkspaceHealth`, since no agent exists yet) and then surfaces
336    /// live `FleetChange`s translated from the poll-diff events `reconcile`
337    /// emits — driven here by a `FakeCaliband` spawn + one `poll_repo_once`.
338    #[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        // Subscribe before the agent exists, exactly like a real watcher would:
348        // `watch_fleet` must pick up `a1`'s `Discovered` change even though its
349        // stream key (its own id) is unknowable until after the event fires.
350        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        // The initial burst carries `repo-a`'s `WorkspaceHealth` (seeded from
360        // `setup()`'s own `add_repo`-triggered poll) ahead of the post-seed
361        // `Discovered` diff; drain up to a few items for it, bounded so a
362        // regression fails fast instead of hanging.
363        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    /// Like [`setup`], but also runs `FleetManager::run`'s background poll
382    /// loop on a fast interval. `testkit::fleet_provider_conformance` is
383    /// deliberately generic over `&dyn FleetProvider` and never reaches for
384    /// `LocalFleet`-internal methods like `poll_repo_once`, so it needs a real
385    /// (if accelerated) reconciliation loop driving state forward underneath
386    /// it, the same way production does.
387    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; // no real caliband to spawn in tests
392        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    /// Task 4: `LocalFleet` satisfies the `FleetProvider` conformance suite.
408    #[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        // Tidy: stop the background poll loop before `_dir` (and its sockets)
413        // get removed on drop.
414        provider.manager().begin_shutdown();
415    }
416
417    /// #71 acceptance: `LocalFleet` drives caliband over **TCP + TLS + bearer
418    /// token** (ADR 0051) through the same `FleetProvider` trait — ensure /
419    /// observe / stop all cross the network control plane. Per-agent stream
420    /// sockets stay Unix in the fake's temp dir (same-process); full
421    /// per-agent-stream-over-TCP is prospero #64.
422    #[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        // ensure_agent issues Spawn over TCP+TLS+token.
445        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        // observe: a poll (List over TCP) surfaces the agent in the snapshot.
456        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        // stop: Kill over TCP.
466        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    /// #72 acceptance: a workspace whose root holds **two** source checkouts
482    /// registers both sources and drives the single caliband keyed on the
483    /// workspace root; agents surface through that one control socket.
484    #[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        // The one caliband is keyed on the (canonical) workspace root.
496        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        // Both source checkouts discovered under the workspace root.
504        {
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        // An agent (whichever source it runs in) surfaces via the one caliband.
518        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    /// #76: the extended `FleetProvider` methods (snapshot/readiness/metrics)
535    /// are reachable through the trait object and delegate to the manager.
536    #[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        // store is writable in the test setup.
544        assert!(r.store_writable);
545        let _ = p.metrics();
546    }
547
548    /// #76: the `FleetAdmin` seam registers/removes a workspace through the
549    /// trait object.
550    #[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}