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)]
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}