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}