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