Skip to main content

prospero_types/
api.rs

1//! Control-plane request/response DTOs — the write half of the HTTP contract.
2//!
3//! These live here rather than in `prospero-api` for the same reason the read
4//! model moved in #98: `prospero-api` pulls axum, tokio, and `prospero-core`, so
5//! nothing in it compiles to `wasm32`, and the Dioxus dashboard would otherwise
6//! have to hand-duplicate every one of these types — reintroducing exactly the
7//! client/server drift Rust/WASM was chosen to avoid.
8//!
9//! Each type derives **both** `Serialize` and `Deserialize`, so one definition
10//! serves both ends: the server deserialises a request body the client
11//! serialised, and the client deserialises a response the server serialised.
12//! `prospero-api` re-exports all of them from their original paths, so serde
13//! output and every import site are unchanged.
14//!
15//! Behaviour stays out: mapping a [`SpawnBody`] onto `prospero-core`'s
16//! `SpawnRequest` is an adapter concern and lives in `prospero-api`, per
17//! ADR 0006's one-directional `api → core` rule.
18
19use serde::{Deserialize, Serialize};
20
21use crate::model::{
22    PermissionPosture, ProviderInfo, RepoProviderConfig, Source, WorkspaceConfig, WorkspaceHealth,
23    WorkspaceSourceSpec, WorkspaceStatusInfo,
24};
25
26/// Backend capability signal for the dashboard (`GET /api/capabilities`).
27///
28/// Fixed for the process lifetime — the dashboard fetches it once and gates its
29/// admin/registry controls on it, so it never offers operations the active
30/// backend can't serve. (#99)
31#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
32#[cfg_attr(feature = "openapi", derive(schemars::JsonSchema))]
33pub struct Capabilities {
34    /// Whether the workspace admin/config plane (add / remove / set-config) is
35    /// available. `true` for the local backend (registry) and, as of #142, for
36    /// k8s (a `Workspace`-CR editor). Only `false` if a backend leaves the
37    /// `admin` seam unwired.
38    pub admin: bool,
39    /// Whether workspace create/config completes asynchronously — the dashboard
40    /// uses this to (a) render the k8s config UI (named-provider list +
41    /// Secret-reference credentials, vs the local single-provider env-var form)
42    /// and (b) treat a save as *accepted, reconciling* rather than *done*.
43    /// `false` for local, `true` for k8s. (#143)
44    pub async_workspace_ops: bool,
45}
46
47/// Body for `POST /api/workspaces`.
48#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
49#[cfg_attr(feature = "openapi", derive(schemars::JsonSchema))]
50pub struct AddWorkspaceBody {
51    /// Operator-chosen short name.
52    pub name: String,
53    /// LocalFleet checkout path. Ignored under k8s (sources come from `config`),
54    /// so k8s requests may omit it.
55    #[serde(default)]
56    pub root: String,
57    /// Backend-neutral initial configuration. Local reads the flattened
58    /// single-provider/env subset; k8s reads sources/providers/etc.
59    #[serde(default)]
60    pub config: WorkspaceConfig,
61}
62
63/// Body for `PUT /api/workspaces/{name}/config`.
64#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
65#[cfg_attr(feature = "openapi", derive(schemars::JsonSchema))]
66pub struct SetConfigBody(pub WorkspaceConfig);
67
68/// Body for `POST /api/workspaces/{repo}/agents`.
69#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
70#[cfg_attr(feature = "openapi", derive(schemars::JsonSchema))]
71pub struct SpawnBody {
72    /// Initial prompt / task.
73    pub prompt: String,
74    /// Optional label.
75    #[serde(default)]
76    pub label: Option<String>,
77    /// Optional model override.
78    #[serde(default)]
79    pub model: Option<String>,
80    /// Isolation mode: `"worktree"` (default) or `"shared"`.
81    #[serde(default)]
82    pub isolation: Option<String>,
83    /// Optional tool allowlist.
84    #[serde(default)]
85    pub tool_allowlist: Option<Vec<String>>,
86    /// Run the agent in interactive mode (awaits operator input).
87    #[serde(default)]
88    pub interactive: bool,
89    /// Optional agent-template / frontmatter markdown file path (#6).
90    #[serde(default)]
91    pub frontmatter_path: Option<String>,
92    /// Which named workspace provider to bind (k8s config plane →
93    /// `CalibanTask.providerRef`). `None` ⇒ the workspace's default (#142).
94    #[serde(default)]
95    pub provider_ref: Option<String>,
96    /// Kill the agent after this many seconds of wall-clock time (#221).
97    /// Absent ⇒ no limit. Enforced by prospero, not by the agent.
98    #[serde(default)]
99    pub timeout_secs: Option<u64>,
100    /// How this session handles tool calls needing permission (#238). Absent ⇒
101    /// [`PermissionPosture::Supervised`]. Asking for
102    /// [`PermissionPosture::Unattended`] requires an `admin`-scope credential
103    /// (ADR-0010); the handler rejects it otherwise.
104    #[serde(default)]
105    pub permission_posture: PermissionPosture,
106}
107
108impl SpawnBody {
109    /// Whether this spawn should get an isolated git worktree.
110    ///
111    /// Isolation defaults to worktree; only the explicit string `"shared"` opts
112    /// out. Kept here (not in the api adapter) because it is the *meaning of the
113    /// field*, and both ends need to agree on it — the dashboard reads it back
114    /// to render the isolation control.
115    #[must_use]
116    pub fn isolation_worktree(&self) -> bool {
117        !matches!(self.isolation.as_deref(), Some("shared"))
118    }
119}
120
121/// Response for a successful spawn.
122#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
123#[cfg_attr(feature = "openapi", derive(schemars::JsonSchema))]
124pub struct SpawnedResponse {
125    /// New agent id.
126    pub agent_id: String,
127    /// Owning workspace.
128    pub workspace: String,
129    /// Whether the agent runs in an isolated worktree.
130    pub isolated: bool,
131    /// Whether this request actually started a new agent, as opposed to
132    /// resolving to one that was already running (#190).
133    ///
134    /// Spawning is idempotent, and under k8s the `CalibanTask` name is derived
135    /// from the spec — so re-submitting an identical prompt attaches to the
136    /// existing run. Clients must distinguish the two, or they report a launch
137    /// that never happened.
138    ///
139    /// Defaults to `true` so a response from a pre-#190 daemon deserializes to
140    /// the behaviour clients already assumed.
141    #[serde(default = "default_created")]
142    pub created: bool,
143}
144
145/// `SpawnedResponse::created`'s default — see that field.
146fn default_created() -> bool {
147    true
148}
149
150/// Body for `POST /api/agents/{id}/input`.
151#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
152#[cfg_attr(feature = "openapi", derive(schemars::JsonSchema))]
153pub struct AgentInputBody {
154    /// Message text to inject into the interactive agent.
155    pub text: String,
156}
157
158/// Response for `POST /api/agents/{id}/respawn`.
159#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
160#[cfg_attr(feature = "openapi", derive(schemars::JsonSchema))]
161pub struct RespawnedResponse {
162    /// The new agent id.
163    pub agent_id: String,
164}
165
166/// Payload of a `gap` SSE event on `GET /api/agents/{id}/stream`.
167///
168/// The bus dropped `skipped` events after `last_seq` because the subscriber
169/// lagged (#28). The stream self-heals by replaying from the store, but the
170/// signal is still sent so a client can say "some output was missed" rather
171/// than silently rendering a discontinuous timeline.
172#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
173#[cfg_attr(feature = "openapi", derive(schemars::JsonSchema))]
174pub struct GapSignal {
175    /// How many events were dropped.
176    pub skipped: u64,
177    /// The last sequence number delivered before the gap.
178    pub last_seq: u64,
179}
180
181/// A workspace summary (no agents) for `GET /api/workspaces`.
182///
183/// The tail fields are populated by the k8s config plane (from `Workspace` CRs)
184/// and skipped for the local backend, so local responses are byte-for-byte
185/// unchanged.
186#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
187#[cfg_attr(feature = "openapi", derive(schemars::JsonSchema))]
188pub struct WorkspaceSummary {
189    /// Registry name.
190    pub name: String,
191    /// Workspace root.
192    pub root: String,
193    /// The source checkouts under the workspace root (1..N).
194    pub sources: Vec<Source>,
195    /// Caliband health.
196    pub health: WorkspaceHealth,
197    /// Number of known agents.
198    pub agent_count: usize,
199    /// Provider/environment config for this workspace.
200    pub config: RepoProviderConfig,
201    /// Human-friendly label (k8s config plane).
202    #[serde(default, skip_serializing_if = "Option::is_none")]
203    pub display_name: Option<String>,
204    /// The workspace's configured source **specs** (k8s config plane).
205    ///
206    /// `sources` above is the *discovered* view — name and path only — because
207    /// that is all a local checkout has. A k8s workspace's sources come from a
208    /// `Workspace` CR and additionally carry the git remote and ref, and the
209    /// config editor needs those to round-trip an edit: without them an
210    /// operator editing a workspace would face blank remote fields and have to
211    /// retype every one from memory. Empty for the local backend, so local
212    /// responses are unchanged.
213    #[serde(default, skip_serializing_if = "Vec::is_empty")]
214    pub source_specs: Vec<WorkspaceSourceSpec>,
215    /// Named providers agents can bind to (k8s config plane).
216    #[serde(default, skip_serializing_if = "Vec::is_empty")]
217    pub providers: Vec<ProviderInfo>,
218    /// Provider bound when an agent requests none (k8s config plane).
219    #[serde(default, skip_serializing_if = "Option::is_none")]
220    pub default_provider: Option<String>,
221    /// Reconciliation status (k8s config plane); absent for local.
222    #[serde(default, skip_serializing_if = "Option::is_none")]
223    pub status: Option<WorkspaceStatusInfo>,
224}
225
226/// How many agents reached each terminal state.
227///
228/// These are [`crate::AgentStatus`]'s terminal variants, counted from
229/// `status_changed` transitions — deliberately *not* `AgentFinished.outcome`,
230/// which carries caliban's open-ended result subtype ("EndOfTurn",
231/// "max_turns") and cannot be charted as a fixed set.
232#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
233#[cfg_attr(feature = "openapi", derive(schemars::JsonSchema))]
234pub struct OutcomeCounts {
235    /// Finished successfully.
236    pub done: u64,
237    /// Finished with an error.
238    pub failed: u64,
239    /// Stopped via kill.
240    pub killed: u64,
241    /// Supervisor restarted while active.
242    pub crashed: u64,
243    /// Runs prospero killed for passing their wall-clock deadline (#221).
244    /// Also counted in `killed` — the kill is real; this says it was policy.
245    #[serde(default)]
246    pub timed_out: u64,
247}
248
249impl OutcomeCounts {
250    /// Total agents that reached any terminal state.
251    pub fn total(&self) -> u64 {
252        self.done + self.failed + self.killed + self.crashed
253    }
254}
255
256/// One day's usage within a workspace — the unit the fleet charts plot.
257#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
258#[cfg_attr(feature = "openapi", derive(schemars::JsonSchema))]
259pub struct UsageBucket {
260    /// UTC day, `YYYY-MM-DD`.
261    pub day: String,
262    /// Summed run cost in USD.
263    pub cost_usd: f64,
264    /// Summed turns.
265    pub turns: u64,
266    /// Terminal outcomes recorded on this day.
267    pub outcomes: OutcomeCounts,
268}
269
270/// One workspace's totals over the whole window, plus its daily series.
271///
272/// `cost_usd` and `outcomes` can legitimately disagree: an agent killed before
273/// it reported a result contributes to `outcomes.killed` with no cost at all, so
274/// a workspace may show outcomes against zero spend. Charts should not treat
275/// that as missing data.
276#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
277#[cfg_attr(feature = "openapi", derive(schemars::JsonSchema))]
278pub struct UsageGroup {
279    /// Workspace name.
280    pub workspace: String,
281    /// Cost across the window.
282    pub cost_usd: f64,
283    /// Turns across the window.
284    pub turns: u64,
285    /// Terminal outcomes across the window.
286    pub outcomes: OutcomeCounts,
287    /// Per-day breakdown, ascending by day. Only days with activity appear.
288    pub series: Vec<UsageBucket>,
289}
290
291/// The widest `GET /api/usage?days=` window the server answers (#255).
292///
293/// The server builds the window with `chrono::Duration::days`, which panics
294/// past about 106 million days. A century is far beyond any retained history
295/// and far inside that bound. Shared here so the server's limit and the CLI's
296/// `--since` limit are one number, not two that can drift.
297pub const MAX_USAGE_WINDOW_DAYS: i64 = 36_500;
298
299/// `GET /api/usage` — aggregated spend and outcomes per workspace.
300///
301/// The window is echoed back so a client rendering an axis does not have to
302/// re-derive the defaults the server applied.
303#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
304#[cfg_attr(feature = "openapi", derive(schemars::JsonSchema))]
305pub struct UsageReport {
306    /// Inclusive window start (RFC-3339).
307    pub since: String,
308    /// Exclusive window end (RFC-3339).
309    pub until: String,
310    /// One entry per workspace with activity, ordered by name.
311    pub groups: Vec<UsageGroup>,
312}
313
314#[cfg(test)]
315mod tests {
316    use super::*;
317
318    /// #238: an old client's body has no posture; it must mean supervised, and
319    /// a spawn body must be able to ask for unattended.
320    /// #221: a timeout is opt-in, and an old client's body simply has none.
321    #[test]
322    fn spawn_body_timeout_is_optional() {
323        let body: SpawnBody = serde_json::from_str(r#"{"prompt":"hi"}"#).unwrap();
324        assert_eq!(body.timeout_secs, None);
325
326        let capped: SpawnBody =
327            serde_json::from_str(r#"{"prompt":"hi","timeout_secs":3600}"#).unwrap();
328        assert_eq!(capped.timeout_secs, Some(3600));
329    }
330
331    #[test]
332    fn spawn_body_permission_posture_defaults_to_supervised() {
333        let body: SpawnBody = serde_json::from_str(r#"{"prompt":"hi"}"#).unwrap();
334        assert_eq!(body.permission_posture, PermissionPosture::Supervised);
335
336        let unattended: SpawnBody =
337            serde_json::from_str(r#"{"prompt":"hi","permission_posture":"unattended"}"#).unwrap();
338        assert_eq!(unattended.permission_posture, PermissionPosture::Unattended);
339    }
340
341    /// The point of the move: every one of these round-trips through serde in
342    /// *both* directions, so the client and the server can share one definition.
343    #[test]
344    fn every_dto_round_trips_in_both_directions() {
345        macro_rules! round_trip {
346            ($v:expr) => {{
347                let v = $v;
348                let json = serde_json::to_string(&v).unwrap();
349                let back = serde_json::from_str(&json).unwrap();
350                assert_eq!(v, back, "round-trip changed the value: {json}");
351            }};
352        }
353
354        round_trip!(Capabilities {
355            admin: true,
356            async_workspace_ops: false,
357        });
358        round_trip!(AddWorkspaceBody {
359            name: "ws".into(),
360            root: "/w".into(),
361            config: WorkspaceConfig::default(),
362        });
363        round_trip!(SetConfigBody(WorkspaceConfig::default()));
364        round_trip!(SpawnBody {
365            prompt: "p".into(),
366            interactive: true,
367            ..SpawnBody::default()
368        });
369        round_trip!(SpawnedResponse {
370            agent_id: "a".into(),
371            workspace: "w".into(),
372            isolated: true,
373            created: false,
374        });
375        round_trip!(AgentInputBody { text: "hi".into() });
376        round_trip!(RespawnedResponse {
377            agent_id: "a2".into(),
378        });
379        round_trip!(WorkspaceSummary {
380            name: "ws".into(),
381            root: "/w".into(),
382            sources: vec![Source {
383                name: "s".into(),
384                path: "/w/s".into(),
385            }],
386            health: WorkspaceHealth::Healthy,
387            agent_count: 1,
388            config: RepoProviderConfig::default(),
389            source_specs: Vec::new(),
390            display_name: None,
391            providers: Vec::new(),
392            default_provider: None,
393            status: None,
394        });
395    }
396
397    /// #190: `created` was added after the fact, so a body from a pre-#190
398    /// daemon omits it. It must read back as `true` — the launch-happened
399    /// assumption every client already made — rather than failing to parse or
400    /// silently claiming an attach.
401    #[test]
402    fn spawned_response_created_defaults_to_true_when_absent() {
403        let legacy = r#"{"agent_id":"a","workspace":"w","isolated":true}"#;
404        let parsed: SpawnedResponse = serde_json::from_str(legacy).expect("legacy body parses");
405        assert!(parsed.created);
406
407        let explicit = r#"{"agent_id":"a","workspace":"w","isolated":true,"created":false}"#;
408        let parsed: SpawnedResponse = serde_json::from_str(explicit).expect("parses");
409        assert!(!parsed.created, "an explicit false must survive");
410    }
411
412    #[test]
413    fn isolation_defaults_to_worktree_and_only_shared_opts_out() {
414        let default = SpawnBody::default();
415        assert!(default.isolation_worktree());
416
417        let worktree = SpawnBody {
418            isolation: Some("worktree".into()),
419            ..SpawnBody::default()
420        };
421        assert!(worktree.isolation_worktree());
422
423        let shared = SpawnBody {
424            isolation: Some("shared".into()),
425            ..SpawnBody::default()
426        };
427        assert!(!shared.isolation_worktree());
428
429        // An unrecognised value must not silently drop isolation.
430        let nonsense = SpawnBody {
431            isolation: Some("Shared".into()),
432            ..SpawnBody::default()
433        };
434        assert!(nonsense.isolation_worktree());
435    }
436
437    /// The local backend's `GET /api/workspaces` payload must not gain k8s keys.
438    #[test]
439    fn local_workspace_summary_omits_the_k8s_tail() {
440        let s = WorkspaceSummary {
441            name: "ws".into(),
442            root: "/w".into(),
443            sources: vec![],
444            health: WorkspaceHealth::Healthy,
445            agent_count: 0,
446            config: RepoProviderConfig::default(),
447            source_specs: Vec::new(),
448            display_name: None,
449            providers: Vec::new(),
450            default_provider: None,
451            status: None,
452        };
453        let json = serde_json::to_string(&s).unwrap();
454        for absent in [
455            "display_name",
456            "providers",
457            "default_provider",
458            "status",
459            "source_specs",
460        ] {
461            assert!(!json.contains(absent), "{absent} leaked into {json}");
462        }
463    }
464}