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}