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    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)]
32pub struct Capabilities {
33    /// Whether the workspace admin/config plane (add / remove / set-config) is
34    /// available. `true` for the local backend (registry) and, as of #142, for
35    /// k8s (a `Workspace`-CR editor). Only `false` if a backend leaves the
36    /// `admin` seam unwired.
37    pub admin: bool,
38    /// Whether workspace create/config completes asynchronously — the dashboard
39    /// uses this to (a) render the k8s config UI (named-provider list +
40    /// Secret-reference credentials, vs the local single-provider env-var form)
41    /// and (b) treat a save as *accepted, reconciling* rather than *done*.
42    /// `false` for local, `true` for k8s. (#143)
43    pub async_workspace_ops: bool,
44}
45
46/// Body for `POST /api/workspaces`.
47#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
48pub struct AddWorkspaceBody {
49    /// Operator-chosen short name.
50    pub name: String,
51    /// LocalFleet checkout path. Ignored under k8s (sources come from `config`),
52    /// so k8s requests may omit it.
53    #[serde(default)]
54    pub root: String,
55    /// Backend-neutral initial configuration. Local reads the flattened
56    /// single-provider/env subset; k8s reads sources/providers/etc.
57    #[serde(default)]
58    pub config: WorkspaceConfig,
59}
60
61/// Body for `PUT /api/workspaces/{name}/config`.
62#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
63pub struct SetConfigBody(pub WorkspaceConfig);
64
65/// Body for `POST /api/workspaces/{repo}/agents`.
66#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
67pub struct SpawnBody {
68    /// Initial prompt / task.
69    pub prompt: String,
70    /// Optional label.
71    #[serde(default)]
72    pub label: Option<String>,
73    /// Optional model override.
74    #[serde(default)]
75    pub model: Option<String>,
76    /// Isolation mode: `"worktree"` (default) or `"shared"`.
77    #[serde(default)]
78    pub isolation: Option<String>,
79    /// Optional tool allowlist.
80    #[serde(default)]
81    pub tool_allowlist: Option<Vec<String>>,
82    /// Run the agent in interactive mode (awaits operator input).
83    #[serde(default)]
84    pub interactive: bool,
85    /// Optional agent-template / frontmatter markdown file path (#6).
86    #[serde(default)]
87    pub frontmatter_path: Option<String>,
88    /// Which named workspace provider to bind (k8s config plane →
89    /// `CalibanTask.providerRef`). `None` ⇒ the workspace's default (#142).
90    #[serde(default)]
91    pub provider_ref: Option<String>,
92}
93
94impl SpawnBody {
95    /// Whether this spawn should get an isolated git worktree.
96    ///
97    /// Isolation defaults to worktree; only the explicit string `"shared"` opts
98    /// out. Kept here (not in the api adapter) because it is the *meaning of the
99    /// field*, and both ends need to agree on it — the dashboard reads it back
100    /// to render the isolation control.
101    #[must_use]
102    pub fn isolation_worktree(&self) -> bool {
103        !matches!(self.isolation.as_deref(), Some("shared"))
104    }
105}
106
107/// Response for a successful spawn.
108#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
109pub struct SpawnedResponse {
110    /// New agent id.
111    pub agent_id: String,
112    /// Owning workspace.
113    pub workspace: String,
114    /// Whether the agent runs in an isolated worktree.
115    pub isolated: bool,
116    /// Whether this request actually started a new agent, as opposed to
117    /// resolving to one that was already running (#190).
118    ///
119    /// Spawning is idempotent, and under k8s the `CalibanTask` name is derived
120    /// from the spec — so re-submitting an identical prompt attaches to the
121    /// existing run. Clients must distinguish the two, or they report a launch
122    /// that never happened.
123    ///
124    /// Defaults to `true` so a response from a pre-#190 daemon deserializes to
125    /// the behaviour clients already assumed.
126    #[serde(default = "default_created")]
127    pub created: bool,
128}
129
130/// `SpawnedResponse::created`'s default — see that field.
131fn default_created() -> bool {
132    true
133}
134
135/// Body for `POST /api/agents/{id}/input`.
136#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
137pub struct AgentInputBody {
138    /// Message text to inject into the interactive agent.
139    pub text: String,
140}
141
142/// Response for `POST /api/agents/{id}/respawn`.
143#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
144pub struct RespawnedResponse {
145    /// The new agent id.
146    pub agent_id: String,
147}
148
149/// Payload of a `gap` SSE event on `GET /api/agents/{id}/stream`.
150///
151/// The bus dropped `skipped` events after `last_seq` because the subscriber
152/// lagged (#28). The stream self-heals by replaying from the store, but the
153/// signal is still sent so a client can say "some output was missed" rather
154/// than silently rendering a discontinuous timeline.
155#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
156pub struct GapSignal {
157    /// How many events were dropped.
158    pub skipped: u64,
159    /// The last sequence number delivered before the gap.
160    pub last_seq: u64,
161}
162
163/// A workspace summary (no agents) for `GET /api/workspaces`.
164///
165/// The tail fields are populated by the k8s config plane (from `Workspace` CRs)
166/// and skipped for the local backend, so local responses are byte-for-byte
167/// unchanged.
168#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
169pub struct WorkspaceSummary {
170    /// Registry name.
171    pub name: String,
172    /// Workspace root.
173    pub root: String,
174    /// The source checkouts under the workspace root (1..N).
175    pub sources: Vec<Source>,
176    /// Caliband health.
177    pub health: WorkspaceHealth,
178    /// Number of known agents.
179    pub agent_count: usize,
180    /// Provider/environment config for this workspace.
181    pub config: RepoProviderConfig,
182    /// Human-friendly label (k8s config plane).
183    #[serde(default, skip_serializing_if = "Option::is_none")]
184    pub display_name: Option<String>,
185    /// The workspace's configured source **specs** (k8s config plane).
186    ///
187    /// `sources` above is the *discovered* view — name and path only — because
188    /// that is all a local checkout has. A k8s workspace's sources come from a
189    /// `Workspace` CR and additionally carry the git remote and ref, and the
190    /// config editor needs those to round-trip an edit: without them an
191    /// operator editing a workspace would face blank remote fields and have to
192    /// retype every one from memory. Empty for the local backend, so local
193    /// responses are unchanged.
194    #[serde(default, skip_serializing_if = "Vec::is_empty")]
195    pub source_specs: Vec<WorkspaceSourceSpec>,
196    /// Named providers agents can bind to (k8s config plane).
197    #[serde(default, skip_serializing_if = "Vec::is_empty")]
198    pub providers: Vec<ProviderInfo>,
199    /// Provider bound when an agent requests none (k8s config plane).
200    #[serde(default, skip_serializing_if = "Option::is_none")]
201    pub default_provider: Option<String>,
202    /// Reconciliation status (k8s config plane); absent for local.
203    #[serde(default, skip_serializing_if = "Option::is_none")]
204    pub status: Option<WorkspaceStatusInfo>,
205}
206
207/// How many agents reached each terminal state.
208///
209/// These are [`crate::AgentStatus`]'s terminal variants, counted from
210/// `status_changed` transitions — deliberately *not* `AgentFinished.outcome`,
211/// which carries caliban's open-ended result subtype ("EndOfTurn",
212/// "max_turns") and cannot be charted as a fixed set.
213#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
214pub struct OutcomeCounts {
215    /// Finished successfully.
216    pub done: u64,
217    /// Finished with an error.
218    pub failed: u64,
219    /// Stopped via kill.
220    pub killed: u64,
221    /// Supervisor restarted while active.
222    pub crashed: u64,
223}
224
225impl OutcomeCounts {
226    /// Total agents that reached any terminal state.
227    pub fn total(&self) -> u64 {
228        self.done + self.failed + self.killed + self.crashed
229    }
230}
231
232/// One day's usage within a workspace — the unit the fleet charts plot.
233#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
234pub struct UsageBucket {
235    /// UTC day, `YYYY-MM-DD`.
236    pub day: String,
237    /// Summed run cost in USD.
238    pub cost_usd: f64,
239    /// Summed turns.
240    pub turns: u64,
241    /// Terminal outcomes recorded on this day.
242    pub outcomes: OutcomeCounts,
243}
244
245/// One workspace's totals over the whole window, plus its daily series.
246///
247/// `cost_usd` and `outcomes` can legitimately disagree: an agent killed before
248/// it reported a result contributes to `outcomes.killed` with no cost at all, so
249/// a workspace may show outcomes against zero spend. Charts should not treat
250/// that as missing data.
251#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
252pub struct UsageGroup {
253    /// Workspace name.
254    pub workspace: String,
255    /// Cost across the window.
256    pub cost_usd: f64,
257    /// Turns across the window.
258    pub turns: u64,
259    /// Terminal outcomes across the window.
260    pub outcomes: OutcomeCounts,
261    /// Per-day breakdown, ascending by day. Only days with activity appear.
262    pub series: Vec<UsageBucket>,
263}
264
265/// `GET /api/usage` — aggregated spend and outcomes per workspace.
266///
267/// The window is echoed back so a client rendering an axis does not have to
268/// re-derive the defaults the server applied.
269#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
270pub struct UsageReport {
271    /// Inclusive window start (RFC-3339).
272    pub since: String,
273    /// Exclusive window end (RFC-3339).
274    pub until: String,
275    /// One entry per workspace with activity, ordered by name.
276    pub groups: Vec<UsageGroup>,
277}
278
279#[cfg(test)]
280mod tests {
281    use super::*;
282
283    /// The point of the move: every one of these round-trips through serde in
284    /// *both* directions, so the client and the server can share one definition.
285    #[test]
286    fn every_dto_round_trips_in_both_directions() {
287        macro_rules! round_trip {
288            ($v:expr) => {{
289                let v = $v;
290                let json = serde_json::to_string(&v).unwrap();
291                let back = serde_json::from_str(&json).unwrap();
292                assert_eq!(v, back, "round-trip changed the value: {json}");
293            }};
294        }
295
296        round_trip!(Capabilities {
297            admin: true,
298            async_workspace_ops: false,
299        });
300        round_trip!(AddWorkspaceBody {
301            name: "ws".into(),
302            root: "/w".into(),
303            config: WorkspaceConfig::default(),
304        });
305        round_trip!(SetConfigBody(WorkspaceConfig::default()));
306        round_trip!(SpawnBody {
307            prompt: "p".into(),
308            interactive: true,
309            ..SpawnBody::default()
310        });
311        round_trip!(SpawnedResponse {
312            agent_id: "a".into(),
313            workspace: "w".into(),
314            isolated: true,
315            created: false,
316        });
317        round_trip!(AgentInputBody { text: "hi".into() });
318        round_trip!(RespawnedResponse {
319            agent_id: "a2".into(),
320        });
321        round_trip!(WorkspaceSummary {
322            name: "ws".into(),
323            root: "/w".into(),
324            sources: vec![Source {
325                name: "s".into(),
326                path: "/w/s".into(),
327            }],
328            health: WorkspaceHealth::Healthy,
329            agent_count: 1,
330            config: RepoProviderConfig::default(),
331            source_specs: Vec::new(),
332            display_name: None,
333            providers: Vec::new(),
334            default_provider: None,
335            status: None,
336        });
337    }
338
339    /// #190: `created` was added after the fact, so a body from a pre-#190
340    /// daemon omits it. It must read back as `true` — the launch-happened
341    /// assumption every client already made — rather than failing to parse or
342    /// silently claiming an attach.
343    #[test]
344    fn spawned_response_created_defaults_to_true_when_absent() {
345        let legacy = r#"{"agent_id":"a","workspace":"w","isolated":true}"#;
346        let parsed: SpawnedResponse = serde_json::from_str(legacy).expect("legacy body parses");
347        assert!(parsed.created);
348
349        let explicit = r#"{"agent_id":"a","workspace":"w","isolated":true,"created":false}"#;
350        let parsed: SpawnedResponse = serde_json::from_str(explicit).expect("parses");
351        assert!(!parsed.created, "an explicit false must survive");
352    }
353
354    #[test]
355    fn isolation_defaults_to_worktree_and_only_shared_opts_out() {
356        let default = SpawnBody::default();
357        assert!(default.isolation_worktree());
358
359        let worktree = SpawnBody {
360            isolation: Some("worktree".into()),
361            ..SpawnBody::default()
362        };
363        assert!(worktree.isolation_worktree());
364
365        let shared = SpawnBody {
366            isolation: Some("shared".into()),
367            ..SpawnBody::default()
368        };
369        assert!(!shared.isolation_worktree());
370
371        // An unrecognised value must not silently drop isolation.
372        let nonsense = SpawnBody {
373            isolation: Some("Shared".into()),
374            ..SpawnBody::default()
375        };
376        assert!(nonsense.isolation_worktree());
377    }
378
379    /// The local backend's `GET /api/workspaces` payload must not gain k8s keys.
380    #[test]
381    fn local_workspace_summary_omits_the_k8s_tail() {
382        let s = WorkspaceSummary {
383            name: "ws".into(),
384            root: "/w".into(),
385            sources: vec![],
386            health: WorkspaceHealth::Healthy,
387            agent_count: 0,
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        let json = serde_json::to_string(&s).unwrap();
396        for absent in [
397            "display_name",
398            "providers",
399            "default_provider",
400            "status",
401            "source_specs",
402        ] {
403            assert!(!json.contains(absent), "{absent} leaked into {json}");
404        }
405    }
406}