Skip to main content

prospero_types/
model.rs

1//! Fleet read-model DTOs (wasm-compatible). Moved out of `prospero-core` so the
2//! WASM dashboard can share them (prospero #98); `prospero-core` re-exports each
3//! from its original module path. Serde output is unchanged.
4
5use std::collections::BTreeMap;
6use std::path::PathBuf;
7
8use serde::{Deserialize, Serialize};
9
10/// One source checkout within a workspace. (The `discover_sources` filesystem
11/// logic stays in `prospero-core`; only this struct is shared.)
12#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
13pub struct Source {
14    /// Directory basename (unique within a workspace).
15    pub name: String,
16    /// Absolute path to the source checkout.
17    pub path: PathBuf,
18}
19
20/// Per-repo provider/environment configuration applied to its caliband daemon.
21#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
22pub struct RepoProviderConfig {
23    /// Selected provider → `CALIBAN_PROVIDER`.
24    #[serde(default, skip_serializing_if = "Option::is_none")]
25    pub provider: Option<String>,
26    /// Provider base URL / host → `{PROVIDER}_BASE_URL`.
27    #[serde(default, skip_serializing_if = "Option::is_none")]
28    pub base_url: Option<String>,
29    /// NAME of an env var in prosperod's environment whose value is injected as
30    /// `{PROVIDER}_API_KEY` at spawn time. Never the literal secret.
31    #[serde(default, skip_serializing_if = "Option::is_none")]
32    pub api_key_from_env: Option<String>,
33    /// Raw escape-hatch env overrides (highest precedence within a repo).
34    ///
35    /// Unlike `api_key_from_env` (a reference), values here are stored verbatim
36    /// in the repo config store and returned by the repos/fleet API — do not
37    /// put secrets here; use `api_key_from_env` for credentials.
38    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
39    pub env: BTreeMap<String, String>,
40}
41
42/// A source checkout spec for a workspace: a git remote and where to mount it.
43/// Used by the k8s config plane to build a `Workspace` CR's `sources[]`.
44#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
45pub struct WorkspaceSourceSpec {
46    /// Source identifier (matches caliband's workspace source name).
47    pub name: String,
48    /// Git remote to clone.
49    pub repo: String,
50    /// Git ref to check out (defaults to `main` when omitted).
51    #[serde(default, skip_serializing_if = "Option::is_none")]
52    pub r#ref: Option<String>,
53    /// Absolute mount path in the pod (e.g. `/work/caliban`).
54    pub path: String,
55}
56
57/// A named model provider within a workspace. Each agent binds one by name.
58#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
59pub struct ProviderSpec {
60    /// Provider identifier, unique within the workspace (e.g. `planner`).
61    pub name: String,
62    /// Provider kind (e.g. `ollama`, `anthropic`, `openai`).
63    pub kind: String,
64    /// Override base URL (e.g. `http://192.168.1.240:11434`).
65    #[serde(default, skip_serializing_if = "Option::is_none")]
66    pub base_url: Option<String>,
67    /// Default model for this provider.
68    #[serde(default, skip_serializing_if = "Option::is_none")]
69    pub model: Option<String>,
70    /// Reference to an existing Secret holding this provider's API key. Keyless
71    /// providers (e.g. ollama) omit it. Prospero only *names* the Secret — it
72    /// never reads it (the operator validates existence).
73    #[serde(default, skip_serializing_if = "Option::is_none")]
74    pub credentials_ref: Option<CredentialsRef>,
75}
76
77/// A by-name reference to a key within an existing Secret.
78#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
79pub struct CredentialsRef {
80    /// Name of the Secret (same namespace).
81    pub secret_name: String,
82    /// Key within the Secret's data.
83    pub key: String,
84}
85
86/// Isolation defaults for agents launched against a workspace.
87#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
88pub struct IsolationConfig {
89    /// RuntimeClass (e.g. `gvisor`, `kata`).
90    #[serde(default, skip_serializing_if = "Option::is_none")]
91    pub runtime_class: Option<String>,
92    /// Worktree isolation strategy (e.g. `per-source`).
93    #[serde(default, skip_serializing_if = "Option::is_none")]
94    pub worktrees: Option<String>,
95}
96
97/// Backend-neutral workspace configuration accepted at the API boundary
98/// (`POST /api/workspaces`, `PUT /api/workspaces/{name}/config`).
99///
100/// The rich fields (`sources`/`providers`/`default_provider`/`isolation`) drive a
101/// k8s `Workspace` CR; the flattened [`RepoProviderConfig`] carries the
102/// `LocalFleet` single-provider/env shape. Each backend **projects out the
103/// subset it uses**, so one endpoint serves both: `#[serde(flatten)]` keeps
104/// legacy local bodies (`{provider, base_url, api_key_from_env, env}`)
105/// deserializing unchanged, while k8s reads the named-provider list + Secret
106/// references it needs.
107#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
108pub struct WorkspaceConfig {
109    /// Human-friendly dashboard label (k8s `displayName`; local ignores it).
110    #[serde(default, skip_serializing_if = "Option::is_none")]
111    pub display_name: Option<String>,
112    /// Git source checkouts (k8s only; local derives its sources from `root`).
113    #[serde(default, skip_serializing_if = "Vec::is_empty")]
114    pub sources: Vec<WorkspaceSourceSpec>,
115    /// Named providers (k8s only; local uses the flattened single provider).
116    #[serde(default, skip_serializing_if = "Vec::is_empty")]
117    pub providers: Vec<ProviderSpec>,
118    /// Provider name agents get when they don't request one (k8s only).
119    #[serde(default, skip_serializing_if = "Option::is_none")]
120    pub default_provider: Option<String>,
121    /// Default isolation for agents (k8s only).
122    #[serde(default, skip_serializing_if = "Option::is_none")]
123    pub isolation: Option<IsolationConfig>,
124    /// LocalFleet single-provider/env configuration. Flattened so the existing
125    /// local request shape is unchanged.
126    #[serde(flatten)]
127    pub local: RepoProviderConfig,
128}
129
130/// A provider as surfaced on the read side (`GET /api/workspaces`): enough for
131/// the dashboard's launch-modal provider picker and a "has credentials" pill,
132/// without exposing the Secret reference itself.
133#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
134pub struct ProviderInfo {
135    /// Provider name (what an agent binds by `providerRef`).
136    pub name: String,
137    /// Provider kind (e.g. `ollama`, `anthropic`).
138    pub kind: String,
139    /// Override base URL, if set.
140    ///
141    /// On the read side because the config editor round-trips it: without it,
142    /// reopening a workspace showed a blank base URL and saving silently
143    /// dropped the stored one, pointing every agent back at the in-pod default
144    /// (#188). Unlike the credential Secret, a base URL is not sensitive.
145    #[serde(default, skip_serializing_if = "Option::is_none")]
146    pub base_url: Option<String>,
147    /// Default model for this provider, if set.
148    #[serde(default, skip_serializing_if = "Option::is_none")]
149    pub model: Option<String>,
150    /// Whether the provider references a credential Secret (keyless providers
151    /// like ollama are `false`). The Secret name/key is intentionally not
152    /// surfaced on the read side.
153    pub has_credentials: bool,
154}
155
156/// Reconciliation status of a workspace, surfaced for the dashboard's status
157/// pill + failure tooltip. Backend-neutral (local workspaces report `None`).
158#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
159pub struct WorkspaceStatusInfo {
160    /// Lifecycle phase (`Pending` / `Reconciling` / `Ready` / `Failed`).
161    pub phase: String,
162    /// Human-readable detail (e.g. a missing-Secret message), when `Failed`.
163    #[serde(default, skip_serializing_if = "Option::is_none")]
164    pub message: Option<String>,
165}
166
167/// A workspace as seen by the config plane's read side: the persisted
168/// configuration plus reconciliation status. Returned by
169/// `FleetAdmin::list_workspaces` and merged into `GET /api/workspaces` so a
170/// configured-but-agentless workspace is still visible with its status.
171#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
172pub struct WorkspaceInfo {
173    /// Workspace object name (agents bind it via `workspaceRef`).
174    pub name: String,
175    /// Human-friendly display label, if set.
176    #[serde(default, skip_serializing_if = "Option::is_none")]
177    pub display_name: Option<String>,
178    /// The workspace's source checkouts.
179    #[serde(default, skip_serializing_if = "Vec::is_empty")]
180    pub sources: Vec<WorkspaceSourceSpec>,
181    /// Named providers agents can bind to.
182    #[serde(default, skip_serializing_if = "Vec::is_empty")]
183    pub providers: Vec<ProviderInfo>,
184    /// Provider bound when an agent requests none.
185    #[serde(default, skip_serializing_if = "Option::is_none")]
186    pub default_provider: Option<String>,
187    /// Reconciliation status, if the backend reports one.
188    #[serde(default, skip_serializing_if = "Option::is_none")]
189    pub status: Option<WorkspaceStatusInfo>,
190}
191
192/// Aggregate readiness of prosperod, distinct from mere liveness.
193///
194/// `ready` gates traffic/restarts: it is `true` only when the durable store can
195/// accept writes. The workspace-health counts are an informational summary
196/// (per-workspace reachability is already surfaced in `/api/workspaces` and `/api/fleet`).
197#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
198pub struct Readiness {
199    /// Overall ready signal — currently equivalent to `store_writable`.
200    pub ready: bool,
201    /// Whether the durable event store can accept writes.
202    pub store_writable: bool,
203    /// Total managed workspaces.
204    pub workspaces_total: usize,
205    /// Workspaces whose caliband responded to the last poll.
206    pub workspaces_healthy: usize,
207    /// Workspaces whose caliband was unreachable at the last poll.
208    pub workspaces_unreachable: usize,
209}
210
211/// Lifecycle state of an agent. Mirrors caliban's `AgentStatus` wire enum exactly
212/// so the same value round-trips through both protocols.
213#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
214#[serde(rename_all = "snake_case")]
215pub enum AgentStatus {
216    /// Registered, not yet executing.
217    Spawning,
218    /// Actively running (or attached).
219    Running,
220    /// Awaiting input; no compute pending.
221    Idle,
222    /// Stopped via kill.
223    Killed,
224    /// Finished successfully.
225    Done,
226    /// Finished with an error.
227    Failed,
228    /// Supervisor restarted while active; needs recovery.
229    Crashed,
230}
231
232impl AgentStatus {
233    /// True for states where the agent will produce no further work.
234    pub fn is_terminal(self) -> bool {
235        matches!(
236            self,
237            AgentStatus::Killed | AgentStatus::Done | AgentStatus::Failed | AgentStatus::Crashed
238        )
239    }
240
241    /// True while the agent may still be streaming output worth attaching to.
242    pub fn is_active(self) -> bool {
243        matches!(self, AgentStatus::Spawning | AgentStatus::Running)
244    }
245}
246
247/// Connectivity of a managed workspace's caliband daemon.
248#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
249#[serde(tag = "state", rename_all = "snake_case")]
250pub enum WorkspaceHealth {
251    /// The control socket responded to the last poll.
252    Healthy,
253    /// The control socket could not be reached; carries the reason.
254    Unreachable {
255        /// Human-readable reason from the last failed poll.
256        reason: String,
257    },
258}
259
260/// Prospero's view of a single agent (projected from a caliban `AgentRecord`).
261#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
262pub struct Agent {
263    /// Opaque caliban agent id.
264    pub id: String,
265    /// Human-readable label.
266    pub name: String,
267    /// Owning workspace name (Prospero registry key).
268    pub workspace: String,
269    /// Current lifecycle state.
270    pub status: AgentStatus,
271    /// RFC-3339 timestamp when the agent was registered.
272    pub started_at: String,
273    /// True if the agent runs in an isolated git worktree.
274    pub isolated: bool,
275    /// True if the agent was spawned in interactive mode (accepts operator input).
276    pub interactive: bool,
277    /// Path to the agent's session directory on disk.
278    pub session_dir: PathBuf,
279}
280
281/// A managed workspace (root + its source checkouts) and the agents running
282/// under its single caliband.
283#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
284pub struct Workspace {
285    /// Registry key (operator-chosen short name).
286    pub name: String,
287    /// Canonical workspace root path.
288    pub root: PathBuf,
289    /// The source checkouts discovered under `root` (1..N). Filesystem-derived
290    /// at snapshot-build time, not persisted.
291    #[serde(default)]
292    pub sources: Vec<Source>,
293    /// Health of the workspace's caliband daemon.
294    pub health: WorkspaceHealth,
295    /// The workspace's provider config (so operators can read back what a workspace is
296    /// configured with). Defaults to empty for workspaces with no config set.
297    #[serde(default)]
298    pub config: RepoProviderConfig,
299    /// Agents currently known under this workspace.
300    pub agents: Vec<Agent>,
301}
302
303/// A point-in-time view of the whole fleet on one host.
304#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
305pub struct FleetSnapshot {
306    /// Host identity (single host in the first stab).
307    pub host: String,
308    /// Managed workspaces and their agents.
309    pub workspaces: Vec<Workspace>,
310}
311
312impl FleetSnapshot {
313    /// Find an agent by id across all workspaces, returning `(repo_name, &Agent)`.
314    pub fn find_agent(&self, id: &str) -> Option<(&str, &Agent)> {
315        self.workspaces.iter().find_map(|r| {
316            r.agents
317                .iter()
318                .find(|a| a.id == id)
319                .map(|a| (r.name.as_str(), a))
320        })
321    }
322}
323
324/// Stable identifier for a running agent (caliband's agent id).
325#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
326pub struct AgentId(pub String);
327
328impl AgentId {
329    #[must_use]
330    pub fn as_str(&self) -> &str {
331        &self.0
332    }
333}
334impl From<&str> for AgentId {
335    fn from(s: &str) -> Self {
336        Self(s.to_string())
337    }
338}
339impl From<String> for AgentId {
340    fn from(s: String) -> Self {
341        Self(s)
342    }
343}
344impl std::fmt::Display for AgentId {
345    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
346        f.write_str(&self.0)
347    }
348}
349
350#[cfg(test)]
351mod tests {
352    use super::*;
353
354    #[test]
355    fn agent_id_roundtrips_str() {
356        let id = AgentId::from("agent-abc");
357        assert_eq!(id.as_str(), "agent-abc");
358        assert_eq!(id.to_string(), "agent-abc");
359    }
360
361    #[test]
362    fn status_serializes_snake_case() {
363        let j = serde_json::to_string(&AgentStatus::Running).unwrap();
364        assert_eq!(j, "\"running\"");
365    }
366
367    #[test]
368    fn status_terminal_and_active_partition_correctly() {
369        for s in [AgentStatus::Spawning, AgentStatus::Running] {
370            assert!(s.is_active() && !s.is_terminal());
371        }
372        for s in [
373            AgentStatus::Killed,
374            AgentStatus::Done,
375            AgentStatus::Failed,
376            AgentStatus::Crashed,
377        ] {
378            assert!(s.is_terminal() && !s.is_active());
379        }
380        // Idle is neither active nor terminal: awaiting input.
381        assert!(!AgentStatus::Idle.is_active() && !AgentStatus::Idle.is_terminal());
382    }
383
384    #[test]
385    fn repo_health_tags_state() {
386        let j = serde_json::to_string(&WorkspaceHealth::Healthy).unwrap();
387        assert_eq!(j, "{\"state\":\"healthy\"}");
388        let j = serde_json::to_string(&WorkspaceHealth::Unreachable {
389            reason: "no socket".into(),
390        })
391        .unwrap();
392        assert_eq!(j, "{\"state\":\"unreachable\",\"reason\":\"no socket\"}");
393    }
394
395    #[test]
396    fn find_agent_searches_across_repos() {
397        let snap = FleetSnapshot {
398            host: "local".into(),
399            workspaces: vec![Workspace {
400                name: "prospero".into(),
401                root: "/r".into(),
402                sources: vec![],
403                health: WorkspaceHealth::Healthy,
404                config: RepoProviderConfig::default(),
405                agents: vec![Agent {
406                    id: "a1".into(),
407                    name: "x".into(),
408                    workspace: "prospero".into(),
409                    status: AgentStatus::Running,
410                    started_at: "t".into(),
411                    isolated: true,
412                    interactive: false,
413                    session_dir: "/s".into(),
414                }],
415            }],
416        };
417        let (workspace, agent) = snap.find_agent("a1").unwrap();
418        assert_eq!(workspace, "prospero");
419        assert_eq!(agent.id, "a1");
420        assert!(snap.find_agent("nope").is_none());
421    }
422}