Skip to main content

prospero_core/
model.rs

1//! Prospero's fleet domain model: `Host -> Workspace -> [Agent]`.
2//!
3//! The read-model DTOs (`Agent`, `Workspace`, `FleetSnapshot`, `AgentStatus`,
4//! `WorkspaceHealth`, `Readiness`, `AgentId`) now live in [`prospero_types`] so
5//! the WASM dashboard can share them (prospero #98); they are re-exported here
6//! from their original path. The control-plane types below (`TaskSpec`,
7//! `AgentHandle`, `DrainPolicy`, `FleetChange`) stay in `prospero-core` — they
8//! reference core-only types (`SpawnRequest`, `Endpoint`).
9
10use serde::{Deserialize, Serialize};
11
12pub use prospero_types::{
13    Agent, AgentId, AgentStatus, FleetSnapshot, PermissionPosture, Readiness, Workspace,
14    WorkspaceHealth,
15};
16
17/// Desired state for one agent — the provider-agnostic spec `ensure_agent` takes.
18/// Generalizes today's `(workspace, SpawnRequest)` pair (fleet.rs:618).
19#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
20pub struct TaskSpec {
21    pub workspace: String,
22    pub request: crate::fleet::SpawnRequest,
23}
24
25/// Handle to a provisioned agent, resolved when it is attachable.
26#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
27pub struct AgentHandle {
28    pub id: AgentId,
29    pub workspace: String,
30    /// Endpoint the agent's per-agent socket is reachable at. `None` until the
31    /// backend has resolved one — e.g. a k8s agent between spawn and Running.
32    pub endpoint: Option<crate::caliband::wire::Endpoint>,
33    /// Whether this call actually started a new agent (#190).
34    ///
35    /// `ensure_agent` is idempotent, and under k8s the `CalibanTask` name is
36    /// derived from the spec — so re-submitting an identical prompt resolves to
37    /// the *existing* agent. That is the right behaviour, but the caller must be
38    /// able to tell, or the UI reports a launch that never happened.
39    ///
40    /// `false` from [`crate::k8s::fleet::handle_from`], which only ever observes
41    /// a `CalibanTask` that already exists.
42    pub created: bool,
43}
44
45/// How to stop an agent. `Kill` preserves today's unconditional behavior.
46#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
47#[serde(rename_all = "snake_case")]
48pub enum DrainPolicy {
49    #[default]
50    Kill,
51    Graceful {
52        timeout_ms: u64,
53    },
54}
55
56/// A change in the observed fleet — the item type of `watch_fleet`.
57/// Mirrors the poll-diff variants `reconcile` already emits (fleet.rs:811).
58#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
59#[serde(tag = "kind", rename_all = "snake_case")]
60pub enum FleetChange {
61    Discovered {
62        id: AgentId,
63        workspace: String,
64        agent: Agent,
65    },
66    StatusChanged {
67        id: AgentId,
68        workspace: String,
69        from: AgentStatus,
70        to: AgentStatus,
71    },
72    Gone {
73        id: AgentId,
74        workspace: String,
75    },
76    WorkspaceHealth {
77        workspace: String,
78        health: WorkspaceHealth,
79    },
80}
81
82#[cfg(test)]
83mod fleet_provider_types_tests {
84    use super::*;
85
86    #[test]
87    fn drain_policy_defaults_to_kill() {
88        assert!(matches!(DrainPolicy::default(), DrainPolicy::Kill));
89    }
90    #[test]
91    fn fleet_change_serdes() {
92        let c = FleetChange::StatusChanged {
93            id: AgentId::from("a1"),
94            workspace: "r".into(),
95            from: AgentStatus::Spawning,
96            to: AgentStatus::Running,
97        };
98        let j = serde_json::to_string(&c).unwrap();
99        let back: FleetChange = serde_json::from_str(&j).unwrap();
100        assert_eq!(back, c);
101    }
102}