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}