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}