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