car_proto/lib.rs
1//! JSON-RPC 2.0 protocol types for CAR client-server communication.
2//!
3//! The protocol is bidirectional over WebSocket:
4//! - Client → Server: session.init, tools.register, proposal.submit, verify
5//! - Server → Client: tools.execute (callback for tool execution)
6//! - Server → Client: execution.event (notifications)
7
8pub mod approval_summary;
9mod canonical;
10
11pub use canonical::{canonical_json, canonical_sha256};
12
13/// Wire protocol version for the daemon JSON-RPC protocol. Bump ONLY on a
14/// backward-incompatible change to the request/response shapes or method
15/// semantics (NOT on every release — this is independent of the package
16/// semver). Client and server exchange this in the `server.handshake` RPC so
17/// version drift FAILS LOUD with a clear error instead of silently
18/// misbehaving or hanging.
19pub const PROTOCOL_VERSION: u32 = 3;
20
21/// Authenticated model-catalog content identity and snapshot reads.
22pub const MODELS_CATALOG_IDENTITY_CAPABILITY: &str = "models.catalog-identity.v1";
23
24/// Immutable model identity on every inference completion.
25pub const INFER_MODEL_IDENTITY_CAPABILITY: &str = "infer.model-identity.v1";
26
27/// Per-session cancellation of an active inference.
28pub const INFER_CANCEL_CAPABILITY: &str = "infer.cancel.v1";
29
30/// Relative deadline control for an active inference.
31pub const INFER_DEADLINE_CAPABILITY: &str = "infer.deadline.v1";
32
33/// Explicit bounded pagination for run list, replay, and live subscription.
34pub const RUNS_PAGINATION_CAPABILITY: &str = "runs.pagination.v1";
35
36/// Authenticated same-agent reclaim of an orphaned live run.
37pub const RUNS_RESUME_CAPABILITY: &str = "runs.resume.v1";
38
39/// Durable, authenticated cancellation of an active run.
40pub const RUNS_CANCEL_CAPABILITY: &str = "runs.cancel.v1";
41
42/// Runtime-observed state mutations returned by raw WebSocket tool callbacks.
43pub const TOOLS_CALLBACK_STATE_CAPABILITY: &str = "tools.callback-state.v1";
44
45/// Host-managed, schema-bound exact agent/tool approvals on WebSocket admission.
46pub const AGENT_TOOL_OVERRIDES_CAPABILITY: &str = "permissions.agent-tool-overrides.v1";
47
48/// In-app feedback (`feedback.compose_preview` / `submit` / `status` / `list`):
49/// the consent-previewed, redacted bug-report handoff into Parslee's intake.
50/// Optional — a client that does not negotiate it cannot spool reports or read
51/// submission summaries on that connection (the daemon refuses with the
52/// standard capability-mismatch error), mirroring every other gated surface.
53pub const FEEDBACK_CAPABILITY: &str = "feedback.v1";
54
55/// Capabilities implemented by this protocol version. Kept sorted so the
56/// handshake response is deterministic across clients and platforms.
57pub const SUPPORTED_CAPABILITIES: &[&str] = &[
58 AGENT_TOOL_OVERRIDES_CAPABILITY,
59 FEEDBACK_CAPABILITY,
60 INFER_CANCEL_CAPABILITY,
61 INFER_DEADLINE_CAPABILITY,
62 INFER_MODEL_IDENTITY_CAPABILITY,
63 MODELS_CATALOG_IDENTITY_CAPABILITY,
64 RUNS_CANCEL_CAPABILITY,
65 RUNS_PAGINATION_CAPABILITY,
66 RUNS_RESUME_CAPABILITY,
67 TOOLS_CALLBACK_STATE_CAPABILITY,
68];
69
70/// Capabilities every bundled v3 client requires from the daemon.
71pub const REQUIRED_CLIENT_CAPABILITIES: &[&str] = &[
72 INFER_MODEL_IDENTITY_CAPABILITY,
73 MODELS_CATALOG_IDENTITY_CAPABILITY,
74];
75
76/// JSON-RPC application error returned when a handshake-gated method is called
77/// before this WebSocket session has completed `server.handshake`.
78///
79/// `session.auth` is deliberately allowed before negotiation because an
80/// auth-enabled daemon requires it as the connection's first frame.
81pub const PROTOCOL_HANDSHAKE_REQUIRED_ERROR_CODE: i32 = -32005;
82
83/// JSON-RPC application error returned when `server.handshake` receives a
84/// client protocol version other than [`PROTOCOL_VERSION`].
85pub const PROTOCOL_VERSION_MISMATCH_ERROR_CODE: i32 = -32006;
86
87/// JSON-RPC application error returned when a client names an unsupported
88/// mandatory capability during `server.handshake`.
89pub const PROTOCOL_CAPABILITY_MISMATCH_ERROR_CODE: i32 = -32008;
90
91/// JSON-RPC application error returned when an inference request's optimistic
92/// model-catalog precondition does not match the snapshot bound by the daemon.
93/// The request is rejected before any provider or local worker dispatch.
94pub const CATALOG_PRECONDITION_MISMATCH_ERROR_CODE: i32 = -32009;
95/// JSON-RPC application error returned when a globally reserved run id /
96/// idempotency key belongs to another authenticated client.
97pub const RUN_OWNERSHIP_CONFLICT_ERROR_CODE: i32 = -32010;
98/// JSON-RPC application error returned when a durable run JSONL contains a
99/// malformed newline-terminated record. Partial records are never returned.
100pub const RUN_TRACE_CORRUPTION_ERROR_CODE: i32 = -32011;
101
102/// Stable message prefix paired with
103/// [`PROTOCOL_HANDSHAKE_REQUIRED_ERROR_CODE`]. Hosts may use the numeric code
104/// for typed handling and surface this text as an actionable fallback.
105pub const PROTOCOL_HANDSHAKE_REQUIRED_MESSAGE_PREFIX: &str = "protocol handshake required:";
106
107/// Stable message prefix paired with [`PROTOCOL_VERSION_MISMATCH_ERROR_CODE`].
108pub const PROTOCOL_VERSION_MISMATCH_MESSAGE_PREFIX: &str = "protocol version mismatch:";
109
110/// Stable prefix paired with [`PROTOCOL_CAPABILITY_MISMATCH_ERROR_CODE`].
111pub const PROTOCOL_CAPABILITY_MISMATCH_MESSAGE_PREFIX: &str = "protocol capability mismatch:";
112
113/// Stable prefix paired with [`CATALOG_PRECONDITION_MISMATCH_ERROR_CODE`].
114pub const CATALOG_PRECONDITION_MISMATCH_MESSAGE_PREFIX: &str = "catalog precondition mismatch:";
115/// Stable prefix paired with [`RUN_OWNERSHIP_CONFLICT_ERROR_CODE`].
116pub const RUN_OWNERSHIP_CONFLICT_MESSAGE_PREFIX: &str = "run ownership conflict:";
117/// Stable prefix paired with [`RUN_TRACE_CORRUPTION_ERROR_CODE`].
118pub const RUN_TRACE_CORRUPTION_MESSAGE_PREFIX: &str = "run trace corruption:";
119
120/// Negotiate supported required/optional capabilities. Unsupported mandatory
121/// entries are returned sorted and deduplicated; unsupported optional entries
122/// are ignored. A successful result is also sorted and deduplicated.
123pub fn negotiate_capabilities(
124 required: &[String],
125 optional: &[String],
126) -> Result<Vec<String>, Vec<String>> {
127 use std::collections::BTreeSet;
128
129 let supported: BTreeSet<&str> = SUPPORTED_CAPABILITIES.iter().copied().collect();
130 let missing: Vec<String> = required
131 .iter()
132 .filter(|capability| !supported.contains(capability.as_str()))
133 .cloned()
134 .collect::<BTreeSet<_>>()
135 .into_iter()
136 .collect();
137 if !missing.is_empty() {
138 return Err(missing);
139 }
140
141 Ok(required
142 .iter()
143 .chain(optional)
144 .filter(|capability| supported.contains(capability.as_str()))
145 .cloned()
146 .collect::<BTreeSet<_>>()
147 .into_iter()
148 .collect())
149}
150
151/// JSON-RPC application error returned when something in FRONT of the model
152/// declined the request's *content* — a managed gateway's content filter, a
153/// provider's moderation layer — rather than the model answering or the call
154/// crashing.
155///
156/// This is deliberately NOT `-32603 internal error`. A refusal is a
157/// deterministic ruling on that content, not a fault, and collapsing the two
158/// costs three different consumers (Parslee-ai/car#796):
159///
160/// - a **benchmark** can score a refusal as a refusal instead of counting it as
161/// a crash — an adversarial-safety suite drives this path on purpose, and it
162/// cannot measure anything if the blocked cases are indistinguishable from
163/// broken ones;
164/// - a **retry loop** stops instead of burning its budget re-sending a decision
165/// that will never change;
166/// - an **operator** can tell a content ruling from a misconfiguration.
167///
168/// The **numeric code is the contract.** [`CONTENT_REFUSED_MESSAGE_PREFIX`] is
169/// the paired fallback for consumers that only ever see the message text.
170pub const CONTENT_REFUSED_ERROR_CODE: i32 = -32007;
171
172/// Stable message prefix paired with [`CONTENT_REFUSED_ERROR_CODE`]. Consumers
173/// that only see the flattened message string (an FFI client rendering
174/// `"{code} {message}"`, a log line) can match on this prefix; anything that can
175/// read the JSON-RPC error object should match the code instead.
176pub const CONTENT_REFUSED_MESSAGE_PREFIX: &str = "content refused:";
177
178/// Typed terminal observation returned by `infer.cancel` and
179/// `infer.deadline`. Confirmation is deliberately evidence-bearing: only an
180/// exact backend acknowledgement may produce a `*_confirmed` variant.
181#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
182#[serde(rename_all = "snake_case")]
183pub enum InferenceControlStatus {
184 AlreadyTerminal,
185 CancelledConfirmed,
186 TerminationUnconfirmed,
187 DeadlineExceededConfirmed,
188 DeadlineExceededUnconfirmed,
189 Unknown,
190}
191
192#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
193pub struct InferenceControlResponse {
194 pub inference_id: String,
195 pub status: InferenceControlStatus,
196}
197
198pub mod daemon;
199pub use daemon::{method_accepts_host_authority, HOST_MANAGEMENT_METHODS};
200
201/// Compute a deterministic, content-derived run id (EPIC B / B7).
202///
203/// `runs.start` already treats a caller-supplied `idempotency_key` as the
204/// run id, so "same key → same run". This is the canonical way to *derive*
205/// that key from the run's content, so two independent devices (or a
206/// retried / replayed start) that issue the same logical run compute the
207/// **same** id without coordinating — the prerequisite for the multi-device
208/// idempotency keys and execution-lease fencing in B5.
209///
210/// The id is `run-<hex>` where `<hex>` is the first 32 hex chars of
211/// `SHA-256(agent_id ‖ "\x1f" ‖ intent ‖ "\x1f" ‖ salt)`. `salt`
212/// distinguishes otherwise-identical logical runs (e.g. a date bucket, a
213/// scheduler occurrence id, or a user-supplied nonce); pass `""` when the
214/// `(agent_id, intent)` pair alone identifies the run. Pure and stable
215/// across builds/platforms — pass the result as `runs.start`'s
216/// `idempotency_key`.
217pub fn deterministic_run_id(agent_id: &str, intent: &str, salt: &str) -> String {
218 use sha2::{Digest, Sha256};
219 let mut hasher = Sha256::new();
220 hasher.update(agent_id.as_bytes());
221 hasher.update(b"\x1f");
222 hasher.update(intent.as_bytes());
223 hasher.update(b"\x1f");
224 hasher.update(salt.as_bytes());
225 let digest = hasher.finalize();
226 let hex: String = digest.iter().take(16).map(|b| format!("{b:02x}")).collect();
227 format!("run-{hex}")
228}
229
230#[cfg(test)]
231mod run_id_tests {
232 use super::deterministic_run_id;
233
234 #[test]
235 fn same_inputs_same_id() {
236 let a = deterministic_run_id("agent-1", "summarize inbox", "2026-06-30");
237 let b = deterministic_run_id("agent-1", "summarize inbox", "2026-06-30");
238 assert_eq!(a, b, "deterministic: identical inputs → identical id");
239 assert!(a.starts_with("run-"));
240 assert_eq!(a.len(), 4 + 32);
241 }
242
243 #[test]
244 fn distinct_inputs_distinct_ids() {
245 let base = deterministic_run_id("agent-1", "intent", "s");
246 assert_ne!(base, deterministic_run_id("agent-2", "intent", "s"));
247 assert_ne!(base, deterministic_run_id("agent-1", "other", "s"));
248 assert_ne!(base, deterministic_run_id("agent-1", "intent", "s2"));
249 }
250
251 #[test]
252 fn no_field_separator_collision() {
253 // The 0x1f separator prevents ("ab","c") colliding with ("a","bc").
254 assert_ne!(
255 deterministic_run_id("ab", "c", ""),
256 deterministic_run_id("a", "bc", "")
257 );
258 }
259}
260
261#[cfg(test)]
262mod capability_negotiation_tests {
263 use super::*;
264
265 #[test]
266 fn protocol_v3_advertises_catalog_and_inference_identity() {
267 assert_eq!(PROTOCOL_VERSION, 3);
268 assert!(SUPPORTED_CAPABILITIES.contains(&MODELS_CATALOG_IDENTITY_CAPABILITY));
269 assert!(SUPPORTED_CAPABILITIES.contains(&INFER_MODEL_IDENTITY_CAPABILITY));
270 assert!(SUPPORTED_CAPABILITIES.contains(&INFER_CANCEL_CAPABILITY));
271 assert!(SUPPORTED_CAPABILITIES.contains(&INFER_DEADLINE_CAPABILITY));
272 assert!(!REQUIRED_CLIENT_CAPABILITIES.contains(&INFER_CANCEL_CAPABILITY));
273 assert!(!REQUIRED_CLIENT_CAPABILITIES.contains(&INFER_DEADLINE_CAPABILITY));
274 }
275
276 #[test]
277 fn callback_state_is_an_optional_v3_capability() {
278 assert!(SUPPORTED_CAPABILITIES.contains(&TOOLS_CALLBACK_STATE_CAPABILITY));
279 assert!(!REQUIRED_CLIENT_CAPABILITIES.contains(&TOOLS_CALLBACK_STATE_CAPABILITY));
280 }
281
282 #[test]
283 fn feedback_is_an_optional_v3_capability() {
284 assert!(SUPPORTED_CAPABILITIES.contains(&FEEDBACK_CAPABILITY));
285 assert!(!REQUIRED_CLIENT_CAPABILITIES.contains(&FEEDBACK_CAPABILITY));
286 }
287
288 #[test]
289 fn exact_agent_tool_overrides_are_an_optional_v3_capability() {
290 assert!(SUPPORTED_CAPABILITIES.contains(&AGENT_TOOL_OVERRIDES_CAPABILITY));
291 assert!(!REQUIRED_CLIENT_CAPABILITIES.contains(&AGENT_TOOL_OVERRIDES_CAPABILITY));
292 }
293
294 #[test]
295 fn run_resume_is_optional_strict_and_carries_no_caller_owner_identity() {
296 assert!(SUPPORTED_CAPABILITIES.contains(&RUNS_RESUME_CAPABILITY));
297 assert!(!REQUIRED_CLIENT_CAPABILITIES.contains(&RUNS_RESUME_CAPABILITY));
298 let request: RunResumeRequest =
299 serde_json::from_value(serde_json::json!({"run_id":"run-1"})).unwrap();
300 assert_eq!(request.run_id, "run-1");
301 assert_eq!(
302 serde_json::to_value(&request).unwrap(),
303 serde_json::json!({"run_id":"run-1"})
304 );
305
306 for caller_supplied_credential in [
307 serde_json::json!({"run_id":"run-1", "agent_id":"caller-controlled"}),
308 serde_json::json!({"run_id":"run-1", "client_id":"caller-controlled"}),
309 serde_json::json!({"run_id":"run-1", "idempotency_key":"caller-controlled"}),
310 serde_json::json!({"run_id":"run-1", "owner_token":"caller-controlled"}),
311 ] {
312 assert!(
313 serde_json::from_value::<RunResumeRequest>(caller_supplied_credential).is_err()
314 );
315 }
316
317 let response = RunResumeResponse {
318 run_id: "run-1".into(),
319 agent_id: "agent-1".into(),
320 client_id: "client-new".into(),
321 resumed_from_client_id: "client-old".into(),
322 };
323 assert_eq!(
324 serde_json::to_value(response).unwrap(),
325 serde_json::json!({
326 "run_id":"run-1",
327 "agent_id":"agent-1",
328 "client_id":"client-new",
329 "resumed_from_client_id":"client-old"
330 })
331 );
332 }
333
334 #[test]
335 fn inference_control_statuses_have_stable_typed_wire_names() {
336 let cases = [
337 (InferenceControlStatus::AlreadyTerminal, "already_terminal"),
338 (
339 InferenceControlStatus::CancelledConfirmed,
340 "cancelled_confirmed",
341 ),
342 (
343 InferenceControlStatus::TerminationUnconfirmed,
344 "termination_unconfirmed",
345 ),
346 (
347 InferenceControlStatus::DeadlineExceededConfirmed,
348 "deadline_exceeded_confirmed",
349 ),
350 (
351 InferenceControlStatus::DeadlineExceededUnconfirmed,
352 "deadline_exceeded_unconfirmed",
353 ),
354 (InferenceControlStatus::Unknown, "unknown"),
355 ];
356 for (status, expected) in cases {
357 assert_eq!(serde_json::to_value(status).unwrap(), expected);
358 }
359 }
360
361 #[test]
362 fn unknown_mandatory_capability_fails_loud() {
363 let error = negotiate_capabilities(
364 &["future.mandatory.v1".to_string()],
365 &[MODELS_CATALOG_IDENTITY_CAPABILITY.to_string()],
366 )
367 .expect_err("an unsupported mandatory capability must reject the handshake");
368
369 assert_eq!(error, vec!["future.mandatory.v1"]);
370 }
371
372 #[test]
373 fn negotiated_capabilities_are_sorted_deduplicated_and_optional_safe() {
374 let negotiated = negotiate_capabilities(
375 &[
376 INFER_MODEL_IDENTITY_CAPABILITY.to_string(),
377 MODELS_CATALOG_IDENTITY_CAPABILITY.to_string(),
378 INFER_MODEL_IDENTITY_CAPABILITY.to_string(),
379 ],
380 &[
381 "future.optional.v1".to_string(),
382 MODELS_CATALOG_IDENTITY_CAPABILITY.to_string(),
383 ],
384 )
385 .unwrap();
386
387 assert_eq!(
388 negotiated,
389 vec![
390 INFER_MODEL_IDENTITY_CAPABILITY.to_string(),
391 MODELS_CATALOG_IDENTITY_CAPABILITY.to_string(),
392 ]
393 );
394 }
395
396 #[test]
397 fn run_pagination_capability_is_negotiable() {
398 let capability = "runs.pagination.v1".to_string();
399 assert_eq!(
400 negotiate_capabilities(std::slice::from_ref(&capability), &[]),
401 Ok(vec![capability])
402 );
403 }
404
405 #[test]
406 fn run_page_requests_require_explicit_cursor_and_limit() {
407 assert!(serde_json::from_value::<RunListRequest>(serde_json::json!({
408 "agent_id": "agent-a"
409 }))
410 .is_err());
411 assert!(
412 serde_json::from_value::<RunGetTraceRequest>(serde_json::json!({
413 "run_id": "run-a"
414 }))
415 .is_err()
416 );
417 assert!(
418 serde_json::from_value::<RunSubscribeRequest>(serde_json::json!({
419 "run_id": "run-a"
420 }))
421 .is_err()
422 );
423 }
424}
425
426use car_ir::ActionProposal;
427use chrono::{DateTime, Utc};
428use serde::{Deserialize, Serialize};
429use serde_json::Value;
430use std::collections::HashMap;
431
432/// Tool definition sent by client during registration.
433///
434/// Mirrors the caller-settable fields of `car_ir::ToolSchema` over the wire so
435/// the validator, caching, and rate-limiting layers see the same values the
436/// in-process engine does. `ToolSchema.source` is deliberately absent: the
437/// runtime assigns `user_defined` to client registrations rather than trusting
438/// a caller to claim `builtin`. New optional fields are added with serde defaults so
439/// pre-v0.5.x clients (which only sent `name` / `description` /
440/// `parameters`) still parse cleanly.
441#[derive(Debug, Clone, Serialize, Deserialize)]
442pub struct ToolDefinition {
443 pub name: String,
444 #[serde(default)]
445 pub description: String,
446 /// JSON Schema for parameters. Empty object = schemaless (legacy
447 /// behavior — validator skips type checks).
448 #[serde(default)]
449 pub parameters: Value,
450 /// JSON Schema for return value (optional).
451 #[serde(default, skip_serializing_if = "Option::is_none")]
452 pub returns: Option<Value>,
453 /// Marks the tool as safe to cache/retry.
454 #[serde(default)]
455 pub idempotent: bool,
456 /// If set, results are cached with this TTL in seconds.
457 #[serde(default, skip_serializing_if = "Option::is_none")]
458 pub cache_ttl_secs: Option<u64>,
459 /// If set, rate-limited to this many calls per interval.
460 #[serde(default, skip_serializing_if = "Option::is_none")]
461 pub rate_limit: Option<ToolRateLimit>,
462}
463
464/// Mirror of `car_ir::ToolRateLimit` over the wire.
465#[derive(Debug, Clone, Serialize, Deserialize)]
466pub struct ToolRateLimit {
467 pub max_calls: u32,
468 pub interval_secs: f64,
469}
470
471// --- Client → Server requests ---
472
473/// Initialize a session.
474#[derive(Debug, Clone, Serialize, Deserialize)]
475pub struct SessionInitRequest {
476 pub client_id: String,
477 #[serde(default)]
478 pub tools: Vec<ToolDefinition>,
479 #[serde(default)]
480 pub policies: Vec<PolicyDefinition>,
481}
482
483/// Policy definition from client.
484#[derive(Debug, Clone, Serialize, Deserialize)]
485pub struct PolicyDefinition {
486 pub name: String,
487 pub rule: String, // deny_tool, deny_tool_param, require_state, etc.
488 #[serde(default)]
489 pub target: String,
490 #[serde(default)]
491 pub key: String,
492 #[serde(default)]
493 pub value: Value,
494 #[serde(default)]
495 pub pattern: String,
496}
497
498/// Submit a proposal for execution.
499#[derive(Debug, Clone, Serialize, Deserialize)]
500pub struct ProposalSubmitRequest {
501 pub proposal: ActionProposal,
502}
503
504/// Verify a proposal without executing.
505#[derive(Debug, Clone, Serialize, Deserialize)]
506pub struct VerifyRequest {
507 pub proposal: ActionProposal,
508 #[serde(default)]
509 pub initial_state: HashMap<String, Value>,
510}
511
512// --- Server → Client callbacks ---
513
514/// Server asks client to execute a tool.
515#[derive(Debug, Clone, Serialize, Deserialize)]
516pub struct ToolExecuteRequest {
517 pub action_id: String,
518 pub tool: String,
519 pub parameters: Value,
520 #[serde(default)]
521 pub timeout_ms: Option<u64>,
522 #[serde(default)]
523 pub attempt: u32,
524 /// Daemon-side callback-routing id (the JSON-RPC `id` of this
525 /// `tools.execute` request, e.g. `"cb-1"`), surfaced into the params so
526 /// the host can key a per-call abort registry on it. When this call is
527 /// reaped (the daemon's callback wait expires), the daemon emits a
528 /// `tools.cancel` notification carrying the SAME `request_id` so the host
529 /// kills the in-flight child instead of orphaning it (Parslee-ai/car#264).
530 ///
531 /// **Correlate by `request_id`, not `action_id`** — `action_id` is empty
532 /// for legacy `execute()` callers and is not unique across concurrent or
533 /// retried attempts. `#[serde(default)]` so pre-#264 hosts still parse the
534 /// payload (they just won't get the cancel correlation key).
535 #[serde(default)]
536 pub request_id: String,
537 /// The Runtime execution session this call belongs to, stamped by the
538 /// **daemon** rather than assembled by the client (Parslee-ai/car#904).
539 ///
540 /// Correlation was previously the host's problem, and the conventions
541 /// available for it are fragile in exactly the situation that needs them:
542 /// `action_id` is client-authored and explicitly not unique across
543 /// concurrent or retried attempts, and a submit-time map keyed on it
544 /// inherits that. An agent keeping per-mission receipts had to thread
545 /// identity through its own scheme, and the naive one (a process-global
546 /// run id) lets a later mission's artifact inherit an earlier mission's
547 /// receipts.
548 ///
549 /// The executor already had this value and threw it away — it reached
550 /// `execute_with_action_in_session` as an unused `_session_id` parameter.
551 /// Stamping it costs nothing and makes attribution server-side fact
552 /// instead of client-side convention.
553 ///
554 /// `None` for callers with no session: the legacy `execute()` path, and
555 /// in-process executors that never had one.
556 #[serde(default, skip_serializing_if = "Option::is_none")]
557 pub session_id: Option<String>,
558}
559
560/// Fire-and-forget `tools.cancel` notification (Parslee-ai/car#264).
561///
562/// Emitted server → client when a `tools.execute` callback is reaped (the
563/// daemon's per-call wait expired) so the host can abort the in-flight child
564/// (e.g. a `claude -p` / `codex exec` driven by `drive_cli`) instead of leaving
565/// it orphaned. A notification (no `id`, no response expected): the daemon has
566/// already given up on the call and is not waiting on the host's acknowledgment.
567///
568/// Correlation is by `request_id` (the `tools.execute` routing id), NOT
569/// `action_id` — see [`ToolExecuteRequest::request_id`].
570#[derive(Debug, Clone, Serialize, Deserialize)]
571pub struct ToolCancelRequest {
572 /// The reaped call's routing id — matches the `request_id` the host saw on
573 /// the originating `tools.execute`.
574 pub request_id: String,
575 /// The originating proposal `Action.id`, for host-side logging/telemetry.
576 /// May be empty (legacy `execute()` callers don't carry one).
577 #[serde(default)]
578 pub action_id: String,
579 /// Why the call was cancelled — currently always a callback-timeout reason
580 /// string. Advisory; the host should abort regardless of the reason.
581 #[serde(default)]
582 pub reason: String,
583}
584
585fn is_false(value: &bool) -> bool {
586 !*value
587}
588
589/// Client returns tool execution result.
590#[derive(Debug, Clone, Serialize, Deserialize)]
591pub struct ToolExecuteResponse {
592 pub action_id: String,
593 #[serde(default)]
594 pub output: Option<Value>,
595 #[serde(default)]
596 pub error: Option<String>,
597 /// The callback explicitly classified this error as unrecoverable.
598 ///
599 /// Additive and false by default so responses from older clients retain
600 /// their ordinary proposal-scoped failure behavior. This bit is execution
601 /// evidence, not [`car_ir::FailureBehavior`] policy, and is never inferred
602 /// from [`Self::error`] text.
603 #[serde(default, skip_serializing_if = "is_false")]
604 pub terminal: bool,
605}
606
607// --- Server → Client notifications ---
608
609/// Execution event notification (streaming).
610#[derive(Debug, Clone, Serialize, Deserialize)]
611pub struct ExecutionEvent {
612 pub kind: String, // matches EventKind values
613 #[serde(default)]
614 pub action_id: Option<String>,
615 #[serde(default)]
616 pub proposal_id: Option<String>,
617 #[serde(default)]
618 pub data: HashMap<String, Value>,
619}
620
621// --- Host UI protocol ---
622
623/// OS-host-visible agent status.
624#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
625#[serde(rename_all = "snake_case")]
626pub enum HostAgentStatus {
627 Idle,
628 Running,
629 WaitingForApproval,
630 Paused,
631 Completed,
632 Errored,
633 Stopped,
634}
635
636/// Host-visible display hints for an agent.
637#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
638pub struct HostAgentDisplay {
639 #[serde(default, skip_serializing_if = "Option::is_none")]
640 pub label: Option<String>,
641 #[serde(default, skip_serializing_if = "Option::is_none")]
642 pub icon: Option<String>,
643 #[serde(default, skip_serializing_if = "Option::is_none")]
644 pub accent: Option<String>,
645}
646
647/// Agent entry visible to menu bar, tray, or terminal host clients.
648#[derive(Debug, Clone, Serialize, Deserialize)]
649pub struct HostAgent {
650 pub id: String,
651 pub name: String,
652 #[serde(default)]
653 pub kind: String,
654 #[serde(default)]
655 pub capabilities: Vec<String>,
656 #[serde(default, skip_serializing_if = "Option::is_none")]
657 pub project: Option<String>,
658 #[serde(default, skip_serializing_if = "Option::is_none")]
659 pub session_id: Option<String>,
660 pub status: HostAgentStatus,
661 #[serde(default, skip_serializing_if = "Option::is_none")]
662 pub current_task: Option<String>,
663 #[serde(default, skip_serializing_if = "Option::is_none")]
664 pub pid: Option<u32>,
665 #[serde(default)]
666 pub display: HostAgentDisplay,
667 pub updated_at: DateTime<Utc>,
668 #[serde(default)]
669 pub metadata: Value,
670}
671
672/// Request to register an agent with the OS host surface.
673#[derive(Debug, Clone, Serialize, Deserialize)]
674pub struct RegisterHostAgentRequest {
675 #[serde(default, skip_serializing_if = "Option::is_none")]
676 pub id: Option<String>,
677 pub name: String,
678 #[serde(default)]
679 pub kind: String,
680 #[serde(default)]
681 pub capabilities: Vec<String>,
682 #[serde(default, skip_serializing_if = "Option::is_none")]
683 pub project: Option<String>,
684 #[serde(default, skip_serializing_if = "Option::is_none")]
685 pub pid: Option<u32>,
686 #[serde(default)]
687 pub display: HostAgentDisplay,
688 #[serde(default)]
689 pub metadata: Value,
690}
691
692/// Request to update an agent's host-visible status.
693#[derive(Debug, Clone, Serialize, Deserialize)]
694pub struct SetHostAgentStatusRequest {
695 pub agent_id: String,
696 pub status: HostAgentStatus,
697 #[serde(default, skip_serializing_if = "Option::is_none")]
698 pub current_task: Option<String>,
699 #[serde(default, skip_serializing_if = "Option::is_none")]
700 pub message: Option<String>,
701 #[serde(default)]
702 pub payload: Value,
703}
704
705#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
706#[serde(rename_all = "snake_case")]
707pub enum HostApprovalStatus {
708 Pending,
709 Resolved,
710}
711
712/// Approval request visible to the OS host surface.
713///
714/// `client_id` is the WS session that raised the approval. When
715/// `Some(x)`, only session `x` may call `host.resolve_approval` on
716/// it — added 2026-05 after a security audit found unrestricted
717/// resolve let one client approve another's pending request. When
718/// `None` the approval is system-raised (the high-risk-method
719/// approval gate uses this so the local UI session can resolve
720/// approvals raised by *other* sessions' dispatch attempts) and
721/// any authenticated session may resolve it.
722#[derive(Debug, Clone, Serialize, Deserialize)]
723pub struct HostApprovalRequest {
724 pub id: String,
725 #[serde(default, skip_serializing_if = "Option::is_none")]
726 pub agent_id: Option<String>,
727 #[serde(default, skip_serializing_if = "Option::is_none")]
728 pub client_id: Option<String>,
729 pub action: String,
730 pub details: Value,
731 #[serde(default)]
732 pub options: Vec<String>,
733 pub status: HostApprovalStatus,
734 pub created_at: DateTime<Utc>,
735 #[serde(default, skip_serializing_if = "Option::is_none")]
736 pub resolved_at: Option<DateTime<Utc>>,
737 #[serde(default, skip_serializing_if = "Option::is_none")]
738 pub resolution: Option<String>,
739}
740
741/// Request to create an approval prompt in the host surface.
742#[derive(Debug, Clone, Serialize, Deserialize)]
743pub struct CreateHostApprovalRequest {
744 /// The agent the approval is raised for.
745 ///
746 /// **Advisory from an agent-bound caller.** `host.request_approval` replaces
747 /// this with the session's authenticated agent binding when the session has
748 /// one, so an agent cannot attribute its request to another agent or leave
749 /// it unattributed. A client with no binding — the operator surface holding
750 /// the daemon-wide token — keeps what it sends, because raising an approval
751 /// on an agent's behalf is exactly what those clients do.
752 ///
753 /// The stamped value is what the requester-vs-resolver comparison in
754 /// `car_server_types::host::Resolver` reads, so its trustworthiness is the
755 /// check's trustworthiness.
756 #[serde(default, skip_serializing_if = "Option::is_none")]
757 pub agent_id: Option<String>,
758 pub action: String,
759 #[serde(default)]
760 pub details: Value,
761 #[serde(default)]
762 pub options: Vec<String>,
763 /// When `true`, the approval is created as system-level: it has
764 /// no `client_id` owner and any authenticated session may resolve
765 /// it. This is the right mode for agent-requested approvals where
766 /// "the user" (via CarHost or `car-host approve`) is the resolver,
767 /// not the requesting agent itself. The previous default (always
768 /// session-owned by the requester) locked the approval to the
769 /// agent's WS connection, which broke as soon as the agent
770 /// reconnected — the new session got a fresh client_id and could
771 /// no longer resolve its own pending approval, AND CarHost (a
772 /// different session) couldn't either.
773 ///
774 /// Defaults to `false` for backward compatibility: existing
775 /// callers that don't set this field keep the strict per-session
776 /// ownership semantics.
777 #[serde(default)]
778 pub system_level: bool,
779}
780
781/// Request to resolve an approval.
782#[derive(Debug, Clone, Serialize, Deserialize)]
783pub struct ResolveHostApprovalRequest {
784 pub approval_id: String,
785 pub resolution: String,
786}
787
788// --- iMessage approval-transport config surface (`messaging.*`) ---
789//
790// The host/local-auth-gated config channel for the iMessage approval
791// transport (Unit 3). These are the ONLY allowlist/config-mutation path
792// in the system; the daemon's WS handlers reject any caller that is not
793// `session.is_host` or presenting the per-launch local auth token. An
794// inbound iMessage carries neither, so it can never mutate config.
795
796/// Result of `messaging.config.get` / `messaging.config.set` — the
797/// current (or post-mutation) view of the transport config. The active
798/// pairing code is intentionally NOT echoed here (it is surfaced only via
799/// `messaging.pairing.status`, mirroring the local-UI-rooted invariant).
800#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
801pub struct MessagingConfigView {
802 /// Which channel this view describes (stable string key: `"imessage"` /
803 /// `"slack"`). Echoed so a per-channel round-trip can confirm WHICH channel
804 /// the flags belong to. Defaults to `"imessage"` for the back-compat
805 /// surface.
806 #[serde(default = "default_channel_key")]
807 pub channel: String,
808 /// Master opt-in flag (default `false`).
809 pub enabled: bool,
810 /// Approver handles permitted to resolve approvals over this channel.
811 pub allowlisted_handles: Vec<String>,
812 /// Whether a pairing is currently in flight (a code has been minted
813 /// and not yet consumed). The code value itself is not exposed here.
814 pub pairing_active: bool,
815}
816
817/// Back-compat default for `MessagingConfigView::channel` — iMessage.
818fn default_channel_key() -> String {
819 "imessage".to_string()
820}
821
822/// Params for `messaging.config.set`. All fields optional — only the
823/// supplied fields mutate (a `null`/absent field leaves that part of the
824/// config unchanged). `add_handles` / `remove_handles` apply after
825/// `allowlisted_handles` when both are present.
826#[derive(Debug, Clone, Default, Serialize, Deserialize)]
827pub struct MessagingConfigSetRequest {
828 /// Which channel this mutation targets (stable string key: `"imessage"` /
829 /// `"slack"`). **Absent ⇒ iMessage** — back-compat for the #403 surface and
830 /// bindings, which have no `channel` field. (The full FFI/doc parity for the
831 /// explicit `channel` field — `.d.ts`/`.pyi`/websocket-protocol — lands in
832 /// Unit 6; the server-side optional field is added here so the per-channel
833 /// WS round-trip works. The wire value stays a plain string tagged by
834 /// channel — no typed identity struct across FFI.)
835 #[serde(default, skip_serializing_if = "Option::is_none")]
836 pub channel: Option<String>,
837 /// When present, set the master opt-in flag.
838 #[serde(default, skip_serializing_if = "Option::is_none")]
839 pub enabled: Option<bool>,
840 /// When present, REPLACE the entire allowlist with these handles.
841 #[serde(default, skip_serializing_if = "Option::is_none")]
842 pub allowlisted_handles: Option<Vec<String>>,
843 /// When present, add each handle to the allowlist (idempotent).
844 #[serde(default)]
845 pub add_handles: Vec<String>,
846 /// When present, remove each handle from the allowlist (idempotent).
847 #[serde(default)]
848 pub remove_handles: Vec<String>,
849 /// Slack bot token (`xoxb-`) to provision. When BOTH `bot_token` and
850 /// `app_token` are present (Slack channel only), the daemon writes them to
851 /// the OS keychain (MC-9) and persists only a keychain *reference* into the
852 /// config — the bearer value never lands in `messaging.json` nor echoes back
853 /// in the response. This is a host-gated, write-only provisioning input; an
854 /// inbound message cannot reach this surface (MC-6). Absent ⇒ no token
855 /// change (back-compat for the enable/allowlist-only callers).
856 #[serde(default, skip_serializing_if = "Option::is_none")]
857 pub bot_token: Option<String>,
858 /// Slack app-level token (`xapp-`) to provision. See [`Self::bot_token`] —
859 /// both must be present to trigger provisioning.
860 #[serde(default, skip_serializing_if = "Option::is_none")]
861 pub app_token: Option<String>,
862 /// Slack post-channel id (`C0123…` / `D024…`) — the conversation the
863 /// outbound approval prompt posts into (Slack channel only). Unlike the
864 /// tokens this is CONFIGURATION, not a secret: the daemon persists it IN
865 /// `messaging.json` (host-gated), never the keychain. Set on the same
866 /// `messaging.config.set { channel: "slack", … }` call as the tokens.
867 /// Absent ⇒ no post-channel change (back-compat).
868 #[serde(default, skip_serializing_if = "Option::is_none")]
869 pub slack_channel: Option<String>,
870}
871
872/// Result of `messaging.pairing.start` — the freshly minted, high-entropy
873/// pairing code to display ONLY in the local UI, plus the post-mint view.
874#[derive(Debug, Clone, Serialize, Deserialize)]
875pub struct MessagingPairingStartResponse {
876 /// The minted pairing code. Shown only in local UI; the paired device
877 /// texts it back to prove control of its handle.
878 pub pairing_code: String,
879 /// Post-mint config view (`pairing_active` is now `true`).
880 pub config: MessagingConfigView,
881}
882
883/// Result of `messaging.pairing.status` — whether a pairing is in flight
884/// and, when so, the active code (host/local-auth gated read only, so the
885/// local UI can re-display the code after a reload).
886#[derive(Debug, Clone, Default, Serialize, Deserialize)]
887pub struct MessagingPairingStatusResponse {
888 /// Whether a pairing code is currently active.
889 pub pairing_active: bool,
890 /// The active pairing code, when one is in flight. Returned only over
891 /// the host/local-auth-gated surface — never over any inbound channel.
892 #[serde(default, skip_serializing_if = "Option::is_none")]
893 pub pairing_code: Option<String>,
894}
895
896/// Result of `messaging.status` — the real runtime liveness of a channel's
897/// approval transport, computed daemon-side (U2). The host UI renders a SINGLE
898/// readiness state from this object rather than re-deriving "is it on" from
899/// scattered permission widgets (the scatter that produced the confusing pane).
900///
901/// Readiness order (the pane resolves the FIRST failing condition):
902/// `enabled` → `watcher_running` → `fda_readable` → `paired` → Ready.
903/// `last_send_*` / `last_error` drive "last delivered" + a surfaced error.
904#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
905pub struct MessagingStatusView {
906 /// Which channel this status describes (`"imessage"` / `"slack"`).
907 #[serde(default = "default_channel_key")]
908 pub channel: String,
909 /// Master opt-in flag for this channel (condition 1).
910 pub enabled: bool,
911 /// Whether at least one handle is paired/allowlisted (condition 2).
912 pub paired: bool,
913 /// Whether this channel's watcher loop is currently spawned (condition 3 —
914 /// the invisible-restart fix; `true` once U1 has spawned it).
915 pub watcher_running: bool,
916 /// Whether the daemon can read the Messages database (Full Disk Access —
917 /// condition 4). Probed daemon-side (the daemon is the reader). For non-
918 /// iMessage channels this is `true` (no chat.db dependency).
919 pub fda_readable: bool,
920 /// Unix-epoch milliseconds of the most recent recorded send (success OR
921 /// failure). `None` until the first send. Drives "Last delivered: `<time>`".
922 #[serde(default, skip_serializing_if = "Option::is_none")]
923 pub last_send_at_ms: Option<i64>,
924 /// Whether the most recent recorded send succeeded. `None` until the first
925 /// send; `Some(false)` for a hard error OR a soft `sent:false`.
926 #[serde(default, skip_serializing_if = "Option::is_none")]
927 pub last_send_ok: Option<bool>,
928 /// The most recent send FAILURE reason (hard error or soft `sent:false`).
929 /// `None` when the last send succeeded or none has happened. Surfaced in the
930 /// pane so a swallowed failure becomes visible.
931 #[serde(default, skip_serializing_if = "Option::is_none")]
932 pub last_error: Option<String>,
933}
934
935/// Result of `messaging.test_send` — the synchronous outcome of the on-demand
936/// self-test (U4). `ok:true` means the labeled test message was delivered to
937/// the paired handle; `ok:false` carries an actionable `error` (channel off, no
938/// paired handle, Automation denied, recipient-not-found). The self-test mints
939/// NO approval/pairing mapping and resolves nothing.
940#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
941pub struct MessagingTestSendResponse {
942 /// Whether the test message was delivered.
943 pub ok: bool,
944 /// Actionable failure reason when `ok == false`; `None` on success.
945 #[serde(default, skip_serializing_if = "Option::is_none")]
946 pub error: Option<String>,
947}
948
949/// Host event emitted to subscribed OS host clients.
950#[derive(Debug, Clone, Serialize, Deserialize)]
951pub struct HostEvent {
952 pub id: String,
953 /// Monotonic daemon-local ordering token. A reconnect snapshot carries
954 /// the sequence it observed, so a client can reconcile it PER
955 /// CONVERSATION KEY: the snapshot's verdict stands for every key that no
956 /// live event with a strictly larger sequence has already spoken for on
957 /// that socket.
958 #[serde(default)]
959 pub sequence: u64,
960 pub timestamp: DateTime<Utc>,
961 pub kind: String,
962 #[serde(default, skip_serializing_if = "Option::is_none")]
963 pub agent_id: Option<String>,
964 pub message: String,
965 #[serde(default)]
966 pub payload: Value,
967}
968
969/// One browser wait returned by `host.subscribe.pending_signins`.
970#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
971pub struct BrowserSignInSnapshot {
972 pub conversation_id: String,
973 pub standing_session: bool,
974 pub message: String,
975}
976
977impl BrowserSignInSnapshot {
978 pub fn new(conversation_id: Option<&str>, message: impl Into<String>) -> Self {
979 Self {
980 conversation_id: conversation_id.unwrap_or("").to_string(),
981 standing_session: conversation_id.is_none(),
982 message: message.into(),
983 }
984 }
985}
986
987/// User-owned device registered by a native host app.
988///
989/// This is deliberately status/capability metadata, not a raw remote-exec
990/// surface. The daemon can tell the flagship assistant which personal devices
991/// are present and what consumer-safe surfaces they advertise; individual
992/// privacy-heavy capabilities still need dedicated, policy-gated RPCs.
993#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
994pub struct HostDevice {
995 pub id: String,
996 pub name: String,
997 pub platform: String,
998 #[serde(default)]
999 pub capabilities: Vec<String>,
1000 #[serde(default)]
1001 pub status: String,
1002 #[serde(default, skip_serializing_if = "Option::is_none")]
1003 pub session_id: Option<String>,
1004 pub updated_at: DateTime<Utc>,
1005 #[serde(default)]
1006 pub metadata: Value,
1007}
1008
1009#[derive(Debug, Clone, Serialize, Deserialize)]
1010pub struct RegisterHostDeviceRequest {
1011 #[serde(default, skip_serializing_if = "Option::is_none")]
1012 pub id: Option<String>,
1013 pub name: String,
1014 pub platform: String,
1015 #[serde(default)]
1016 pub capabilities: Vec<String>,
1017 #[serde(default)]
1018 pub status: Option<String>,
1019 #[serde(default)]
1020 pub metadata: Value,
1021}
1022
1023#[derive(Debug, Clone, Serialize, Deserialize)]
1024pub struct UpdateHostDeviceRequest {
1025 pub device_id: String,
1026 #[serde(default, skip_serializing_if = "Option::is_none")]
1027 pub name: Option<String>,
1028 #[serde(default, skip_serializing_if = "Option::is_none")]
1029 pub platform: Option<String>,
1030 #[serde(default, skip_serializing_if = "Option::is_none")]
1031 pub capabilities: Option<Vec<String>>,
1032 #[serde(default, skip_serializing_if = "Option::is_none")]
1033 pub status: Option<String>,
1034 #[serde(default, skip_serializing_if = "Option::is_none")]
1035 pub metadata: Option<Value>,
1036}
1037
1038/// Manifest-lock relationship for the daemon serving this WS.
1039/// Returned inside [`HostIdentity`].
1040#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1041#[serde(rename_all = "lowercase")]
1042pub enum HostManifestRole {
1043 /// This daemon holds the exclusive `<manifest>.lock` — agents.*
1044 /// mutations route through here.
1045 Owner,
1046 /// Another `car-server` on the host owns the lock; this daemon
1047 /// runs in observe-only mode (Parslee-ai/car-releases#44).
1048 Observer,
1049 /// No manifest is configured at all — `HOME` unset or the
1050 /// embedder didn't install one.
1051 None,
1052}
1053
1054/// Daemon-identifying metadata returned inside [`HostSnapshot`].
1055/// Lets multi-daemon hosts (`car-server install` plus an ad-hoc
1056/// eval daemon, etc.) tell which daemon they connected to and
1057/// whether THIS one owns the supervisor lock or is observe-only.
1058/// Closes the observability gap from Parslee-ai/car-releases#44.
1059///
1060/// Stable on the wire across the connection's lifetime — emitted
1061/// once on subscribe rather than as a periodic event because the
1062/// fields are immutable for the daemon's lifetime (the manifest
1063/// role flips only on daemon restart, which would close this WS
1064/// anyway).
1065#[derive(Debug, Clone, Serialize, Deserialize)]
1066pub struct HostIdentity {
1067 /// `CARGO_PKG_VERSION` from the daemon binary at build time.
1068 pub version: String,
1069 /// `std::process::id()` of the daemon — operators correlating
1070 /// log lines + `ps`/`lsof` output need this.
1071 pub pid: u32,
1072 /// Absolute path to the lifecycle-agent manifest this daemon
1073 /// supervises (or observes). `None` when no manifest is
1074 /// configured (`HOME` unset; embedder didn't install one).
1075 /// Lossy-encoded on non-UTF-8 paths — operators on path
1076 /// layouts that round-trip through this field should normalize
1077 /// upstream.
1078 pub manifest_path: Option<String>,
1079 pub manifest_role: HostManifestRole,
1080 /// Parslee cloud account bound to this daemon, when the local user
1081 /// has completed `car auth login`. This is advisory identity for
1082 /// cloud-backed features; the local WS auth token still gates access
1083 /// to the daemon process.
1084 #[serde(default, skip_serializing_if = "Option::is_none")]
1085 pub parslee: Option<ParsleeIdentity>,
1086}
1087
1088/// Parslee cloud identity associated with the local CAR user.
1089#[derive(Debug, Clone, Serialize, Deserialize)]
1090pub struct ParsleeIdentity {
1091 pub account_id: String,
1092 #[serde(default, skip_serializing_if = "Option::is_none")]
1093 pub email: Option<String>,
1094 #[serde(default, skip_serializing_if = "Option::is_none")]
1095 pub display_name: Option<String>,
1096 #[serde(default, skip_serializing_if = "Option::is_none")]
1097 pub active_organization: Option<String>,
1098 #[serde(default, skip_serializing_if = "Option::is_none")]
1099 pub organization_name: Option<String>,
1100}
1101
1102/// Snapshot returned by `host.subscribe`.
1103#[derive(Debug, Clone, Serialize, Deserialize)]
1104pub struct HostSnapshot {
1105 pub subscribed: bool,
1106 #[serde(default)]
1107 pub agents: Vec<HostAgent>,
1108 #[serde(default)]
1109 pub devices: Vec<HostDevice>,
1110 #[serde(default)]
1111 pub approvals: Vec<HostApprovalRequest>,
1112 #[serde(default)]
1113 pub events: Vec<HostEvent>,
1114 /// Authoritative browser waits at `event_sequence`. Clients replace
1115 /// local attention from this on reconnect unless they have already
1116 /// applied a live event with a larger sequence.
1117 #[serde(default)]
1118 pub pending_signins: Vec<BrowserSignInSnapshot>,
1119 #[serde(default)]
1120 pub event_sequence: u64,
1121 /// Daemon-identifying metadata — added 2026-05 to surface
1122 /// observe-only mode (Parslee-ai/car-releases#44) and let
1123 /// multi-daemon hosts tell which daemon they're talking to.
1124 #[serde(default, skip_serializing_if = "Option::is_none")]
1125 pub identity: Option<HostIdentity>,
1126}
1127
1128// --- Run lifecycle (agent run tracing, U1) ---
1129//
1130// A "run" is one `runs.start` / `runs.complete` bracket around an agent
1131// loop, identified by a durable `run_id` (a uuid) that is independent of
1132// the ephemeral, server-assigned WS `client_id`. U1 introduces only the
1133// run boundary + terminal-outcome carriers; the per-turn `RunTurn` /
1134// `CliOutcome` / `VerifierVerdict` model lands in U2.
1135//
1136// `RunStarted` and `RunEnded` are the two durable run records U1 emits.
1137// They serialize as tagged JSON (`{ "kind": "started", ... }` /
1138// `{ "kind": "ended", ... }`) so U2/U3 can extend the record set
1139// (adding a `Turn` variant) without breaking the on-wire shape.
1140
1141/// How a run reached its terminal state.
1142///
1143/// `Outcome` carries the harness-reported `AgentOutcome` from
1144/// `runs.complete`. `Incomplete` is written daemon-side when a harness
1145/// disconnects without ever reporting an outcome (past the short grace
1146/// window) — R5. It is deliberately distinct from any `OutcomeStatus`
1147/// so the dashboard can render "the harness vanished" differently from
1148/// "the agent gave up".
1149#[derive(Debug, Clone, Serialize, Deserialize)]
1150#[serde(tag = "kind", rename_all = "snake_case")]
1151pub enum RunTermination {
1152 /// The harness called `runs.complete` with a terminal outcome.
1153 Outcome {
1154 /// Convenience copy of `outcome.status`, surfaced top-level so
1155 /// list views can render the terminal banner without parsing
1156 /// the full `AgentOutcome`.
1157 status: car_ir::OutcomeStatus,
1158 outcome: car_ir::AgentOutcome,
1159 },
1160 /// The connection dropped mid-run with no `runs.complete` — the
1161 /// daemon wrote this marker. No `AgentOutcome` is available.
1162 Incomplete,
1163 /// CAR confirmed that all controlled work stopped and durably committed
1164 /// the caller's body-free cancellation identity.
1165 Cancelled {
1166 cancellation: RunCancellationIdentity,
1167 },
1168}
1169
1170/// Body-free identity durably bound into a cancelled terminal. The free-text
1171/// reason is deliberately represented only by its digest.
1172#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1173pub struct RunCancellationIdentity {
1174 pub receipt_version: u32,
1175 pub run_id: String,
1176 pub idempotency_key: String,
1177 pub reason_digest: String,
1178 pub principal: String,
1179 pub action_id: Option<String>,
1180 pub request_id: Option<String>,
1181}
1182
1183#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1184#[serde(rename_all = "snake_case")]
1185pub enum RunCancellationStatus {
1186 CancelledConfirmed,
1187 AlreadyTerminal,
1188 TerminationUnconfirmed,
1189}
1190
1191/// Strict `runs.cancel` request. Unknown fields are rejected to keep the
1192/// idempotency preimage closed and reviewable.
1193#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1194#[serde(deny_unknown_fields)]
1195pub struct RunCancelRequest {
1196 pub run_id: String,
1197 pub idempotency_key: String,
1198 pub reason: String,
1199}
1200
1201/// Deterministic, body-free cancellation receipt.
1202#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1203pub struct RunCancelResponse {
1204 pub receipt_version: u32,
1205 pub run_id: String,
1206 pub idempotency_key: String,
1207 pub reason_digest: String,
1208 pub principal: String,
1209 pub status: RunCancellationStatus,
1210 pub terminal_digest: Option<String>,
1211 pub action_id: Option<String>,
1212 pub request_id: Option<String>,
1213 pub receipt_digest: String,
1214}
1215
1216/// First durable row for one cancellation key. It contains no reason/body.
1217#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1218pub struct RunCancellationRequested {
1219 pub receipt_version: u32,
1220 pub run_id: String,
1221 pub idempotency_key: String,
1222 pub reason_digest: String,
1223 pub principal: String,
1224 pub action_id: Option<String>,
1225 pub request_id: Option<String>,
1226}
1227
1228/// A run began — recorded when `runs.start` mints the `run_id`.
1229#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1230pub struct RunStarted {
1231 pub run_id: String,
1232 /// WebSocket connection that authenticated and opened this run. Optional
1233 /// only for replay compatibility with pre-0.51 rows.
1234 #[serde(default, skip_serializing_if = "Option::is_none")]
1235 pub client_id: Option<String>,
1236 /// The owning agent. Resolved from `session.auth {agent_id}`,
1237 /// `CAR_AGENT_ID`, or (one-shot fallback) a deterministic id
1238 /// synthesized from the agent's name.
1239 pub agent_id: String,
1240 /// What the agent was asked to do (free text from the harness).
1241 pub intent: String,
1242 /// The outcome the agent is steering toward, when supplied.
1243 #[serde(default, skip_serializing_if = "Option::is_none")]
1244 pub outcome_description: Option<String>,
1245 pub started_at: DateTime<Utc>,
1246}
1247
1248/// A run reached a terminal state — recorded on `runs.complete` (with a
1249/// reported outcome) or on a mid-run disconnect (as `Incomplete`).
1250#[derive(Debug, Clone, Serialize, Deserialize)]
1251pub struct RunEnded {
1252 pub run_id: String,
1253 /// Must match `RunStarted.client_id` for CAR-owned 0.51+ writes. Optional
1254 /// only for replay compatibility with historical rows.
1255 #[serde(default, skip_serializing_if = "Option::is_none")]
1256 pub client_id: Option<String>,
1257 pub agent_id: String,
1258 pub termination: RunTermination,
1259 /// Lowercase SHA-256 of the RFC 8785/JCS serialization of `termination`.
1260 /// This binds the terminal journal event, RPC acknowledgment, and durable
1261 /// row to one exact CAR run outcome; it is not an artifact/content digest.
1262 #[serde(default, skip_serializing_if = "Option::is_none")]
1263 pub completion_digest: Option<String>,
1264 pub ended_at: DateTime<Utc>,
1265}
1266
1267// --- Per-turn run trace (agent run tracing, U2) ---
1268//
1269// A "turn" is one submitted proposal (no inference chain-of-thought
1270// capture). The recorder (`car-server-core/src/run_trace.rs`) joins the
1271// submitted proposal's `actions[i]` to the resulting `ActionResult`s by
1272// `action_id` and emits one `RunTurn` per action, tagged with the
1273// session's current `run_id`. The recorder is tool-agnostic — it always
1274// records `tool` / `parameters` / `output` — and applies a thin, optional
1275// classifier for Bulldozer's `drive_cli` / `check_outcome` tools to fill
1276// `cli_outcome` / `verifier_verdict`. Those return-shape field names
1277// (`output_tail` / `exit_code` / `timed_out` / `passed`) are the agent's
1278// contract, not the runtime's; the classifier keys on them.
1279
1280/// How a CLI-driving action (e.g. Bulldozer's `drive_cli`) terminated.
1281///
1282/// `Exited { code }` is the normal case — the process ran and returned an
1283/// exit code. `Killed` is a signal death (the tool surfaced a `signal`
1284/// with no numeric `exit_code`). `Timeout` is the tool's own
1285/// `timed_out` flag. `KTD7`: this is one of the orthogonal, multi-valued
1286/// per-turn outcome fields — distinct from the run-level `OutcomeStatus`
1287/// — so a timed-out drive never gets mis-rendered as a run failure.
1288#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1289#[serde(tag = "kind", rename_all = "snake_case")]
1290pub enum CliOutcome {
1291 /// The process exited with a numeric code (0 = success).
1292 Exited { code: i64 },
1293 /// The process was killed by a signal (no numeric exit code).
1294 Killed,
1295 /// The tool reported `timed_out = true`.
1296 Timeout,
1297}
1298
1299/// The verifier verdict for a turn — Bulldozer's `check_outcome` result.
1300///
1301/// "Verifier" here is the agent's `check_outcome` tool result (its
1302/// `passed` field), NOT the runtime's static `verifyProposal` gate. A
1303/// turn with `Fail` is the healthy re-prod case (drove another turn),
1304/// not a run failure — KTD7 / R11. `NotRun` covers a turn that never
1305/// reached the verifier (a `drive_cli`-only turn, a timeout, or a
1306/// policy-rejected action).
1307#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1308#[serde(rename_all = "snake_case")]
1309pub enum VerifierVerdict {
1310 /// `check_outcome` returned `passed = true`.
1311 Pass,
1312 /// `check_outcome` returned `passed = false` (healthy re-prod — amber).
1313 Fail,
1314 /// The verifier did not run for this turn.
1315 NotRun,
1316}
1317
1318/// A policy rejection captured on a turn (R2 / R11). When an action is
1319/// `ActionStatus::Rejected`, the tool body never ran, so `cli_outcome`
1320/// is forced to `not-run` and the rejection is recorded here.
1321#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1322pub struct PolicyRejection {
1323 /// The rule that fired — the verbatim `ActionResult.error` string
1324 /// (e.g. `policy 'no-destructive': param 'prompt' matches 'rm -rf'`).
1325 pub rule: String,
1326 /// The blocked parameter name, best-effort extracted from the
1327 /// rejection reason (the `param '<name>'` token a `deny_tool_param`
1328 /// rejection carries). `None` when the reason has no param token.
1329 #[serde(default, skip_serializing_if = "Option::is_none")]
1330 pub param: Option<String>,
1331}
1332
1333/// One captured turn of a run — one action of one submitted proposal.
1334///
1335/// The recorder always fills `index` / `prompt` / `tool` / `parameters`
1336/// / `output` (tool-agnostic). `cli_outcome` / `verifier_verdict` /
1337/// `policy_rejected` are the thin Bulldozer classifier's enrichment and
1338/// are `None` / `NotRun` for any other tool. Multi-valued and orthogonal
1339/// to the run-level `OutcomeStatus` (KTD7).
1340#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1341pub struct RunTurn {
1342 /// 0-based position of this action within the run's ordered turn
1343 /// stream (monotonic across proposals in the run).
1344 pub index: usize,
1345 /// The exact submitted proposal that produced this turn. Missing only
1346 /// for legacy or externally narrated turns that did not supply identity.
1347 #[serde(default, skip_serializing_if = "Option::is_none")]
1348 pub proposal_id: Option<String>,
1349 /// The exact submitted action that produced this turn. Missing only for
1350 /// legacy or externally narrated turns that did not supply identity.
1351 #[serde(default, skip_serializing_if = "Option::is_none")]
1352 pub action_id: Option<String>,
1353 /// Exact lifecycle status returned for this action. Missing when no
1354 /// matching result exists or for legacy/external turns that omit it.
1355 #[serde(default, skip_serializing_if = "Option::is_none")]
1356 pub action_status: Option<car_ir::ActionStatus>,
1357 /// Exact executor-reported duration for this action, in milliseconds.
1358 #[serde(default, skip_serializing_if = "Option::is_none")]
1359 pub action_duration_ms: Option<f64>,
1360 /// Exact executor result timestamp. This is a completion observation,
1361 /// not a synthesized start time.
1362 #[serde(default, skip_serializing_if = "Option::is_none")]
1363 pub action_completed_at: Option<DateTime<Utc>>,
1364 /// Exact predecessor action IDs from the executor's dependency graph.
1365 /// `Some([])` means a known root; `None` means dependency identity is
1366 /// unavailable (for example, a legacy journal entry).
1367 #[serde(default, skip_serializing_if = "Option::is_none")]
1368 pub depends_on: Option<Vec<String>>,
1369 /// The submitted action's state-dependency keys, verbatim. `Some([])`
1370 /// is explicitly known-empty; `None` means legacy/unknown metadata.
1371 #[serde(default, skip_serializing_if = "Option::is_none")]
1372 pub state_dependencies: Option<Vec<String>>,
1373 /// The prompt handed to the driven CLI — the action's `prompt`
1374 /// parameter when present (`drive_cli`). `None` for tools that take
1375 /// no `prompt`.
1376 #[serde(default, skip_serializing_if = "Option::is_none")]
1377 pub prompt: Option<String>,
1378 /// The tool name from the submitted action (`drive_cli`,
1379 /// `check_outcome`, or any other). `None` for non-`ToolCall` actions.
1380 #[serde(default, skip_serializing_if = "Option::is_none")]
1381 pub tool: Option<String>,
1382 /// The full action parameters as submitted — tool-agnostic capture so
1383 /// non-Bulldozer agents still get a usable trail.
1384 #[serde(default, skip_serializing_if = "Value::is_null")]
1385 pub parameters: Value,
1386 /// The tool's returned output value (the `ActionResult.output`).
1387 #[serde(default, skip_serializing_if = "Option::is_none")]
1388 pub output: Option<Value>,
1389 /// The classified CLI outcome for a `drive_cli` turn; `None` for
1390 /// non-driving tools.
1391 #[serde(default, skip_serializing_if = "Option::is_none")]
1392 pub cli_outcome: Option<CliOutcome>,
1393 /// The verifier verdict — `Pass`/`Fail` from a `check_outcome` turn,
1394 /// `NotRun` otherwise.
1395 pub verifier_verdict: VerifierVerdict,
1396 /// A policy rejection, when this action was `Rejected`.
1397 #[serde(default, skip_serializing_if = "Option::is_none")]
1398 pub policy_rejected: Option<PolicyRejection>,
1399}
1400
1401/// One durable run record. U1 ships `Started` / `Ended`; U2 adds the
1402/// per-turn `Turn` variant. Tagged on `record` so adding a variant is
1403/// forward-compatible on the wire.
1404#[derive(Debug, Clone, Serialize, Deserialize)]
1405#[serde(tag = "record", rename_all = "snake_case")]
1406pub enum RunRecord {
1407 Started(RunStarted),
1408 Ended(RunEnded),
1409 Turn(RunTurn),
1410 CancellationRequested(RunCancellationRequested),
1411 CancellationResult(RunCancelResponse),
1412}
1413
1414/// `runs.start` request params.
1415#[derive(Debug, Clone, Serialize, Deserialize)]
1416pub struct RunStartRequest {
1417 /// Owning agent id. Optional on the wire: when absent the daemon
1418 /// resolves from `session.auth {agent_id}`, then `CAR_AGENT_ID`,
1419 /// then falls back to a deterministic id synthesized from
1420 /// `agent_name`. With none of these available, `runs.start` is
1421 /// rejected.
1422 #[serde(default, skip_serializing_if = "Option::is_none")]
1423 pub agent_id: Option<String>,
1424 /// Agent display name — the one-shot fallback source for a
1425 /// deterministic `agent_id` when nothing else resolves.
1426 #[serde(default, skip_serializing_if = "Option::is_none")]
1427 pub agent_name: Option<String>,
1428 pub intent: String,
1429 #[serde(default, skip_serializing_if = "Option::is_none")]
1430 pub outcome_description: Option<String>,
1431 /// Optional caller-supplied run id for a globally reserved,
1432 /// same-WebSocket idempotent start. An exact retry on the connection that
1433 /// owns the live nonterminal run returns that binding. A terminal run, a
1434 /// run owned by another connection, or any durable occurrence from a
1435 /// prior connection is not adopted: `runs.start` returns JSON-RPC
1436 /// `-32010` (`run ownership conflict:`). After an unacknowledged start and
1437 /// disconnect, CAR releases the key only when no `RunStarted` boundary
1438 /// reached durable storage; otherwise it reconciles one historical
1439 /// `Incomplete` occurrence and keeps the original key owned. Absent ⇒ the
1440 /// daemon mints a fresh random `run_id`.
1441 #[serde(default, skip_serializing_if = "Option::is_none")]
1442 pub idempotency_key: Option<String>,
1443}
1444
1445/// `runs.start` response.
1446#[derive(Debug, Clone, Serialize, Deserialize)]
1447pub struct RunStartResponse {
1448 pub run_id: String,
1449 pub agent_id: String,
1450 /// Server-assigned authenticated WebSocket identity persisted on every
1451 /// active lifecycle journal event and durable boundary row.
1452 pub client_id: String,
1453}
1454
1455/// Strict `runs.resume` request. The caller supplies no owner identity or
1456/// secret: the daemon derives both from the authenticated WebSocket session.
1457#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1458#[serde(deny_unknown_fields)]
1459pub struct RunResumeRequest {
1460 pub run_id: String,
1461}
1462
1463/// `runs.resume` response naming the daemon-minted replacement socket and the
1464/// stale socket it atomically fenced.
1465#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1466pub struct RunResumeResponse {
1467 pub run_id: String,
1468 pub agent_id: String,
1469 pub client_id: String,
1470 pub resumed_from_client_id: String,
1471}
1472
1473/// `runs.complete` request params.
1474#[derive(Debug, Clone, Serialize, Deserialize)]
1475pub struct RunCompleteRequest {
1476 pub run_id: String,
1477 pub outcome: car_ir::AgentOutcome,
1478}
1479
1480/// `runs.complete` response.
1481#[derive(Debug, Clone, Serialize, Deserialize)]
1482pub struct RunCompleteResponse {
1483 pub run_id: String,
1484 pub ok: bool,
1485 /// Same canonical terminal digest persisted in `RunEnded` and emitted in
1486 /// the terminal `run_completed` journal event.
1487 pub completion_digest: String,
1488}
1489
1490/// `runs.record_turns` request params — a WS-only batch append of
1491/// client-narrated turns to a run the calling connection owns. The agent
1492/// builds full `RunTurn`s itself (out-of-pipeline capture: its work
1493/// happens inside its own subprocess, never through `proposal.submit`),
1494/// then pushes them here in batches. The daemon owns the turn `index`
1495/// (re-stamped under the `runs` lock — the client's `index` values are
1496/// ignored), the per-field/per-turn byte caps, the batch-size cap, and
1497/// the per-run turn ceiling; it appends through the same
1498/// `record_run_turns` path the proposal recorder uses, so persistence and
1499/// `runs.trace.event` fanout are identical. Turn content beyond size is
1500/// intentional pass-through — the daemon validates size and ownership,
1501/// never semantics.
1502#[derive(Debug, Clone, Serialize, Deserialize)]
1503pub struct RunRecordTurnsRequest {
1504 pub run_id: String,
1505 /// The batch of turns to append, in order. Each is appended as a
1506 /// [`RunRecord::Turn`]. On the wire a turn may omit `index` (the daemon
1507 /// owns it — re-stamped under the `runs` lock; any sent value is
1508 /// ignored) and `verifier_verdict` (defaults to `NotRun`). Must be
1509 /// non-empty.
1510 pub turns: Vec<RunTurn>,
1511}
1512
1513/// `runs.record_turns` response.
1514///
1515/// On a healthy append `ok` is `true`, `base_index` is the daemon-stamped
1516/// 0-based position of the FIRST turn in the batch, and `count` is the
1517/// number appended (the stamped indices are `base_index .. base_index +
1518/// count`). On a non-fatal rejection (`ok: false`) nothing is appended and
1519/// `dropped` carries the machine-readable reason the agent treats as
1520/// "stop sending for this run": `run_not_found` (no such run, or the
1521/// caller isn't entitled — uniform with the read path's not-found, never
1522/// an owner oracle), `run_terminal` (the run already reported / was swept
1523/// terminal), `run_turn_limit` (the per-run turn ceiling was reached), or
1524/// `turn_too_large` (a turn could not be bounded under the per-turn byte
1525/// cap even after its free-form fields were replaced — a misbehaving
1526/// client; the whole batch is dropped, never partially admitted).
1527#[derive(Debug, Clone, Serialize, Deserialize)]
1528pub struct RunRecordTurnsResponse {
1529 pub run_id: String,
1530 /// 0-based index of the first appended turn. `0` when nothing was
1531 /// appended (`ok: false`).
1532 pub base_index: usize,
1533 /// Number of turns appended. `0` when `ok: false`.
1534 pub count: usize,
1535 pub ok: bool,
1536 /// The machine-readable drop reason when `ok` is `false`; omitted on a
1537 /// healthy append.
1538 #[serde(default, skip_serializing_if = "Option::is_none")]
1539 pub dropped: Option<String>,
1540}
1541
1542// --- Live run-trace subscription (agent run tracing, U4) ---
1543//
1544// `runs.subscribe {run_id}` returns a snapshot of the run's turns so far
1545// plus a `cursor` (the count of records the snapshot covers), then the
1546// daemon pushes one `runs.trace.event` notification per record appended
1547// AFTER that cursor. The snapshot + the subscriber registration happen
1548// atomically under the same lock the recorder holds when it appends, so
1549// no record in the snapshot/register window is dropped (gap) or
1550// double-delivered (dup) — R7. The notification is WS-only (no FFI
1551// method); a CarHost re-issues `runs.subscribe {run_id}` after a
1552// reconnect and gap-fills via the cursor (R8). Authorization (R16):
1553// only the run's owning agent connection or the CarHost host-client may
1554// subscribe.
1555
1556/// Coarse live status of a run for the subscribe snapshot and each
1557/// `runs.trace.event`. Distinct from the run-level `OutcomeStatus`
1558/// carried inside a terminal `RunTermination::Outcome` — this is the
1559/// "is the run still open?" signal the live client folds into its view.
1560/// Mirrors `RunStore::RunStatus` but lives in `car-proto` so the wire
1561/// shape doesn't depend on the server-core crate.
1562#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1563#[serde(rename_all = "snake_case")]
1564pub enum RunLiveStatus {
1565 /// No terminal record yet — the run is still being written.
1566 InProgress,
1567 /// `runs.complete` reported a terminal `AgentOutcome`.
1568 Completed,
1569 /// The harness disconnected without reporting an outcome (R5).
1570 Incomplete,
1571 /// Cancellation was requested but CAR could not confirm controlled work
1572 /// stopped. The run is quarantined and remains nonterminal.
1573 CancellationPending,
1574 /// CAR durably confirmed cancellation.
1575 Cancelled,
1576}
1577
1578/// `runs.subscribe` request params.
1579#[derive(Debug, Clone, Serialize, Deserialize)]
1580pub struct RunSubscribeRequest {
1581 pub run_id: String,
1582 /// Zero-based turn cursor. Unlike `runs.get_trace`, lifecycle records do
1583 /// not advance this cursor.
1584 pub cursor: usize,
1585 /// Maximum turns returned by this catch-up page.
1586 pub limit: usize,
1587}
1588
1589/// `runs.subscribe` response — one bounded catch-up page before the live
1590/// `runs.trace.event` stream starts.
1591///
1592/// `turns` contains ordered `RunTurn` records beginning at `cursor`.
1593/// A partial page has `subscribed = false` and a `next_cursor`; the client
1594/// must request each continuation. The page that reaches `live_cursor`
1595/// atomically registers the subscriber and returns `subscribed = true`, so
1596/// turns appended at the catch-up boundary cannot be skipped. The
1597/// `RunStarted` data is already conveyed by `agent_id` + the request's
1598/// `run_id`, and the terminal disposition by `status`, so this response
1599/// carries turns only. Every record in `turns` is a `RunRecord::Turn`.
1600#[derive(Debug, Clone, Serialize, Deserialize)]
1601pub struct RunSubscribeResponse {
1602 pub run_id: String,
1603 pub agent_id: String,
1604 /// Ordered `RunRecord::Turn` records beginning at `cursor`.
1605 pub turns: Vec<RunRecord>,
1606 /// Echo of the requested turn cursor.
1607 pub cursor: usize,
1608 /// Echo of the applied page limit.
1609 pub limit: usize,
1610 /// Present only while more catch-up turns remain. The client must request
1611 /// that cursor before it can become live-subscribed.
1612 #[serde(default, skip_serializing_if = "Option::is_none")]
1613 pub next_cursor: Option<usize>,
1614 /// Exact turn-count boundary observed while this page was selected.
1615 pub live_cursor: usize,
1616 /// True only when the page reached `live_cursor` and registration happened
1617 /// atomically under the same run lock. Terminal traces use this as a
1618 /// caught-up marker but do not retain a future notification sink.
1619 pub subscribed: bool,
1620 pub status: RunLiveStatus,
1621}
1622
1623/// `runs.unsubscribe` request params.
1624#[derive(Debug, Clone, Serialize, Deserialize)]
1625pub struct RunUnsubscribeRequest {
1626 pub run_id: String,
1627}
1628
1629/// `runs.unsubscribe` response.
1630#[derive(Debug, Clone, Serialize, Deserialize)]
1631pub struct RunUnsubscribeResponse {
1632 pub run_id: String,
1633 /// `true` if a subscription for this `(connection, run_id)` existed
1634 /// and was removed; `false` if there was nothing to remove.
1635 pub removed: bool,
1636}
1637
1638/// One `runs.trace.event` server → client notification (agent run
1639/// tracing, U4). Pushed to every `(host_client, run_id)` subscriber.
1640///
1641/// `record` is the appended `RunRecord`:
1642/// - `Turn` — emitted after `record_run_turns` appends it to the run's
1643/// in-memory buffer, under the same lock that holds the snapshot/
1644/// register window closed (the gap/dup-free contract). `cursor` is the
1645/// run's turn count immediately AFTER this turn was appended (1-based),
1646/// so a subscriber at turn-cursor `n` expects the next `Turn` event's
1647/// `cursor` to be `n + 1` — a mismatch means a gap (re-subscribe, R8).
1648/// - `Started` — emitted on `runs.start`; a lifecycle marker. `cursor`
1649/// carries the run's current turn count (0 at start) and does not
1650/// advance the turn stream.
1651/// - `Ended` — emitted on `runs.complete` / disconnect-`Incomplete`;
1652/// carries the final turn count in `cursor` and the terminal `status`.
1653#[derive(Debug, Clone, Serialize, Deserialize)]
1654pub struct RunTraceEvent {
1655 pub run_id: String,
1656 pub agent_id: String,
1657 pub record: RunRecord,
1658 /// The run's turn count after this record was processed. Advances by
1659 /// one per `Turn`; unchanged on `Started`/`Ended`.
1660 pub cursor: usize,
1661 pub status: RunLiveStatus,
1662}
1663
1664// --- Replay read RPCs (agent run tracing, U5) ---
1665//
1666// `runs.list {agent_id}` and `runs.get_trace {run_id, cursor?}` are the
1667// WS-only replay reads CarHost uses to list an agent's runs and fetch a
1668// completed run's full trace. They read the disk store (`RunStore`), so
1669// they work after a daemon restart / `client_id` churn — `run_id` /
1670// `agent_id` are the durable keys. Both are authorization-gated (R16):
1671// `runs.list` authorizes the caller for `agent_id` first (the param is
1672// not a transparent key — an unentitled id is rejected, not enumerated);
1673// `runs.get_trace` resolves the run's owning `agent_id` from disk and
1674// authorizes against it.
1675//
1676// Only the *request* params are typed here. The responses are built
1677// inline in the handler (mirroring `agents.tail_log`'s `{ lines }` shape)
1678// because they carry the run-store's `RunSummary` type, which lives in
1679// `car-server-core` — `car-proto` must not depend on it. The exact JSON
1680// response shapes are documented in `docs/websocket-protocol.md` and
1681// asserted in `car-server-core/tests/run_trace_replay.rs`:
1682//
1683// runs.list → { agent_id, runs: [RunSummary] } (newest-first)
1684// runs.get_trace → { run_id, agent_id, records: [RunRecord], cursor }
1685// or { run_id, not_found: true } for an unknown run.
1686//
1687// `RunSummary` = { run_id, agent_id, intent, started_at, ended_at?,
1688// status, turn_count }; `RunRecord` is the tagged Started/Turn/Ended
1689// union defined above. The `cursor` echoes the request's `cursor` (0 when
1690// omitted) — the index `records` begins at, for paged fetches of large
1691// runs.
1692
1693/// `runs.list` request params — list an agent's runs newest-first.
1694#[derive(Debug, Clone, Serialize, Deserialize)]
1695pub struct RunListRequest {
1696 /// The agent whose runs to list. Authorization-gated (R16): the
1697 /// caller must own this agent (`session.auth {agent_id}`) or be the
1698 /// CarHost host-client. Not a transparent key — an unentitled id is
1699 /// rejected, never enumerated.
1700 pub agent_id: String,
1701 /// Opaque numeric keyset. `0` starts at the current newest row; a returned
1702 /// `next_cursor` resumes strictly after the prior page even if a new run is
1703 /// inserted at the head.
1704 pub cursor: usize,
1705 /// Maximum rows to return. Must be between 1 and the server hard maximum.
1706 pub limit: usize,
1707}
1708
1709/// `runs.get_trace` request params — fetch one bounded page of a run's ordered trace.
1710#[derive(Debug, Clone, Serialize, Deserialize)]
1711pub struct RunGetTraceRequest {
1712 /// The run to fetch. The owning `agent_id` is resolved from the disk
1713 /// store and the caller is authorized against it (R16).
1714 pub run_id: String,
1715 /// Optional start index into the run's ordered `RunRecord` stream —
1716 /// the first record returned. Omitted / `0` starts at the beginning;
1717 /// a non-zero cursor continues a large run from that offset. The
1718 /// response echoes the applied cursor.
1719 pub cursor: usize,
1720 /// Maximum records to return. Must be between 1 and the server hard maximum.
1721 pub limit: usize,
1722}
1723
1724// --- Response types ---
1725
1726#[derive(Debug, Clone, Serialize, Deserialize)]
1727pub struct SessionInitResponse {
1728 pub session_id: String,
1729 pub tools_registered: usize,
1730 pub policies_registered: usize,
1731}
1732
1733#[derive(Debug, Clone, Serialize, Deserialize)]
1734pub struct VerifyResponse {
1735 pub valid: bool,
1736 pub issues: Vec<VerifyIssueProto>,
1737 pub simulated_state: HashMap<String, Value>,
1738 /// Parallelizable execution batches (DAG levels), action IDs.
1739 /// Defaulted for backward-compatible deserialization of older
1740 /// daemons that omitted it.
1741 #[serde(default)]
1742 pub execution_levels: Vec<Vec<String>>,
1743 /// Undeclared write conflicts: (action1, action2, key).
1744 #[serde(default)]
1745 pub conflicts: Vec<(String, String, String)>,
1746 /// Evidence bundle: the verifier's declared scope — checks run,
1747 /// assumptions, untested regions, residual risks, coverage
1748 /// confidence (survey "Code as Agent Harness" §5.2.2). Carried as
1749 /// opaque JSON so car-proto stays decoupled from car-verify; shape
1750 /// mirrors `car_verify::VerificationEvidence`.
1751 #[serde(default)]
1752 pub evidence: Value,
1753}
1754
1755#[derive(Debug, Clone, Serialize, Deserialize)]
1756pub struct VerifyIssueProto {
1757 pub action_id: String,
1758 pub severity: String,
1759 pub message: String,
1760 /// Which kind of check produced the finding: `"decision_procedure"` |
1761 /// `"heuristic"` | `"sampled"` — the string form of
1762 /// `car_verify::EvidenceTier`, carried as a `String` so car-proto stays
1763 /// decoupled from car-verify (same reason `evidence` is an opaque `Value`).
1764 ///
1765 /// Orthogonal to `severity`, which says how bad the finding would be rather
1766 /// than how it was derived. Defaulted so a newer client can still
1767 /// deserialize an older daemon's response, where it arrives empty.
1768 #[serde(default)]
1769 pub tier: String,
1770}
1771
1772#[cfg(test)]
1773mod tests {
1774 use super::*;
1775
1776 #[test]
1777 fn tool_definition_roundtrip() {
1778 let td = ToolDefinition {
1779 name: "search".to_string(),
1780 description: "Search the web".to_string(),
1781 parameters: serde_json::json!({"type": "object", "properties": {"query": {"type": "string"}}}),
1782 returns: None,
1783 idempotent: false,
1784 cache_ttl_secs: None,
1785 rate_limit: None,
1786 };
1787 let json = serde_json::to_string(&td).unwrap();
1788 let rt: ToolDefinition = serde_json::from_str(&json).unwrap();
1789 assert_eq!(rt.name, "search");
1790 }
1791
1792 #[test]
1793 fn tool_definition_back_compat_pre_v05_clients() {
1794 // Pre-v0.5 clients only sent these three fields. The new
1795 // optional fields must default cleanly so the wire stays
1796 // backward-compatible.
1797 let legacy = r#"{"name":"read","description":"","parameters":{}}"#;
1798 let td: ToolDefinition = serde_json::from_str(legacy).unwrap();
1799 assert_eq!(td.name, "read");
1800 assert!(td.returns.is_none());
1801 assert!(!td.idempotent);
1802 assert!(td.cache_ttl_secs.is_none());
1803 assert!(td.rate_limit.is_none());
1804 }
1805
1806 #[test]
1807 fn tool_execute_request_roundtrip() {
1808 let req = ToolExecuteRequest {
1809 action_id: "a1".to_string(),
1810 tool: "search".to_string(),
1811 parameters: serde_json::json!({"query": "rust"}),
1812 timeout_ms: Some(5000),
1813 attempt: 1,
1814 request_id: "cb-7".to_string(),
1815 session_id: Some("sess-7".to_string()),
1816 };
1817 let json = serde_json::to_string(&req).unwrap();
1818 let rt: ToolExecuteRequest = serde_json::from_str(&json).unwrap();
1819 assert_eq!(rt.tool, "search");
1820 assert_eq!(rt.timeout_ms, Some(5000));
1821 assert_eq!(rt.request_id, "cb-7");
1822 assert_eq!(rt.session_id.as_deref(), Some("sess-7"));
1823 }
1824
1825 /// Parslee-ai/car#904 — the correlation field must be additive in both
1826 /// directions, because the daemon and the host upgrade independently.
1827 #[test]
1828 fn tool_execute_request_session_id_is_additive_both_ways() {
1829 // A pre-#904 daemon sends no `session_id`; a new host must still parse.
1830 let legacy =
1831 r#"{"action_id":"a1","tool":"x","parameters":{},"attempt":1,"request_id":"cb-1"}"#;
1832 let rt: ToolExecuteRequest = serde_json::from_str(legacy).unwrap();
1833 assert_eq!(rt.session_id, None);
1834
1835 // A sessionless caller must not put a null on the wire: `execute()` and
1836 // in-process executors legitimately have no session, and emitting
1837 // `"session_id": null` would make every such payload differ from the
1838 // pre-#904 shape for no gain.
1839 let sessionless = ToolExecuteRequest {
1840 action_id: "a1".to_string(),
1841 tool: "x".to_string(),
1842 parameters: serde_json::json!({}),
1843 timeout_ms: None,
1844 attempt: 1,
1845 request_id: "cb-1".to_string(),
1846 session_id: None,
1847 };
1848 let json = serde_json::to_string(&sessionless).unwrap();
1849 assert!(
1850 !json.contains("session_id"),
1851 "a sessionless call must omit the key entirely, got: {json}"
1852 );
1853 }
1854
1855 #[test]
1856 fn tool_execute_request_request_id_defaults_for_pre264_hosts() {
1857 // Pre-#264 payloads (no request_id) must still parse — the field
1858 // defaults to empty so an older host gets a usable callback, just
1859 // without the cancel correlation key.
1860 let legacy = r#"{"action_id":"a1","tool":"x","parameters":{},"attempt":1}"#;
1861 let rt: ToolExecuteRequest = serde_json::from_str(legacy).unwrap();
1862 assert_eq!(rt.request_id, "");
1863 assert_eq!(rt.tool, "x");
1864 }
1865
1866 #[test]
1867 fn tool_cancel_request_roundtrip() {
1868 let c = ToolCancelRequest {
1869 request_id: "cb-3".to_string(),
1870 action_id: "a2".to_string(),
1871 reason: "tool 'drive_cli' callback timed out (185s)".to_string(),
1872 };
1873 let json = serde_json::to_string(&c).unwrap();
1874 let rt: ToolCancelRequest = serde_json::from_str(&json).unwrap();
1875 assert_eq!(rt.request_id, "cb-3");
1876 assert_eq!(rt.action_id, "a2");
1877 assert!(rt.reason.contains("timed out"));
1878 // action_id + reason default when omitted (only request_id required).
1879 let minimal: ToolCancelRequest = serde_json::from_str(r#"{"request_id":"cb-9"}"#).unwrap();
1880 assert_eq!(minimal.request_id, "cb-9");
1881 assert_eq!(minimal.action_id, "");
1882 assert_eq!(minimal.reason, "");
1883 }
1884
1885 #[test]
1886 fn tool_execute_response_success() {
1887 let resp = ToolExecuteResponse {
1888 action_id: "a1".to_string(),
1889 output: Some(Value::from("results")),
1890 error: None,
1891 terminal: false,
1892 };
1893 let json = serde_json::to_string(&resp).unwrap();
1894 assert!(json.contains("results"));
1895 assert!(!json.contains("terminal"));
1896 }
1897
1898 #[test]
1899 fn tool_execute_response_error() {
1900 let resp = ToolExecuteResponse {
1901 action_id: "a1".to_string(),
1902 output: None,
1903 error: Some("timeout".to_string()),
1904 terminal: true,
1905 };
1906 let json = serde_json::to_string(&resp).unwrap();
1907 assert!(json.contains("timeout"));
1908 assert!(json.contains(r#""terminal":true"#));
1909
1910 let legacy: ToolExecuteResponse =
1911 serde_json::from_str(r#"{"action_id":"a1","error":"legacy callback failure"}"#)
1912 .unwrap();
1913 assert!(!legacy.terminal);
1914 }
1915
1916 #[test]
1917 fn session_init_request() {
1918 let req = SessionInitRequest {
1919 client_id: "client-1".to_string(),
1920 tools: vec![ToolDefinition {
1921 name: "read".to_string(),
1922 description: "Read file".to_string(),
1923 parameters: serde_json::json!({}),
1924 returns: None,
1925 idempotent: false,
1926 cache_ttl_secs: None,
1927 rate_limit: None,
1928 }],
1929 policies: vec![],
1930 };
1931 let json = serde_json::to_string(&req).unwrap();
1932 let rt: SessionInitRequest = serde_json::from_str(&json).unwrap();
1933 assert_eq!(rt.tools.len(), 1);
1934 }
1935
1936 #[test]
1937 fn verify_request() {
1938 let req = VerifyRequest {
1939 proposal: ActionProposal {
1940 id: "p1".to_string(),
1941 source: "test".to_string(),
1942 actions: vec![],
1943 timestamp: chrono::Utc::now(),
1944 context: HashMap::new(),
1945 },
1946 initial_state: [("x".to_string(), Value::from(1))].into(),
1947 };
1948 let json = serde_json::to_string(&req).unwrap();
1949 assert!(json.contains("p1"));
1950 }
1951
1952 #[test]
1953 fn run_start_request_resolves_optional_agent_id() {
1954 // The harness may omit agent_id (daemon resolves it) and may
1955 // supply agent_name as the one-shot fallback source.
1956 let wire = r#"{"intent":"ship the feature","agent_name":"Bulldozer"}"#;
1957 let req: RunStartRequest = serde_json::from_str(wire).unwrap();
1958 assert_eq!(req.intent, "ship the feature");
1959 assert_eq!(req.agent_id, None);
1960 assert_eq!(req.agent_name.as_deref(), Some("Bulldozer"));
1961 assert_eq!(req.outcome_description, None);
1962 }
1963
1964 #[test]
1965 fn run_record_started_ended_roundtrip() {
1966 let started = RunRecord::Started(RunStarted {
1967 run_id: "run-1".to_string(),
1968 client_id: Some("client-1".to_string()),
1969 agent_id: "agent-1".to_string(),
1970 intent: "do the thing".to_string(),
1971 outcome_description: Some("the thing is done".to_string()),
1972 started_at: chrono::Utc::now(),
1973 });
1974 let json = serde_json::to_string(&started).unwrap();
1975 // Tagged on `record` so U2 can add a `Turn` variant without
1976 // breaking the wire.
1977 assert!(json.contains("\"record\":\"started\""));
1978 let rt: RunRecord = serde_json::from_str(&json).unwrap();
1979 match rt {
1980 RunRecord::Started(s) => assert_eq!(s.run_id, "run-1"),
1981 other => panic!("expected Started, got {other:?}"),
1982 }
1983
1984 let ended = RunRecord::Ended(RunEnded {
1985 run_id: "run-1".to_string(),
1986 client_id: Some("client-1".to_string()),
1987 agent_id: "agent-1".to_string(),
1988 termination: RunTermination::Outcome {
1989 status: car_ir::OutcomeStatus::Success,
1990 outcome: car_ir::AgentOutcome::success("done"),
1991 },
1992 completion_digest: Some("abc123".to_string()),
1993 ended_at: chrono::Utc::now(),
1994 });
1995 let json = serde_json::to_string(&ended).unwrap();
1996 assert!(json.contains("\"record\":\"ended\""));
1997 assert!(json.contains("\"kind\":\"outcome\""));
1998 let rt: RunRecord = serde_json::from_str(&json).unwrap();
1999 match rt {
2000 RunRecord::Ended(e) => match e.termination {
2001 RunTermination::Outcome { status, .. } => {
2002 assert_eq!(status, car_ir::OutcomeStatus::Success)
2003 }
2004 other => panic!("expected Outcome, got {other:?}"),
2005 },
2006 other => panic!("expected Ended, got {other:?}"),
2007 }
2008 }
2009
2010 #[test]
2011 fn run_termination_incomplete_serializes_distinctly() {
2012 let term = RunTermination::Incomplete;
2013 let json = serde_json::to_string(&term).unwrap();
2014 assert_eq!(json, r#"{"kind":"incomplete"}"#);
2015 }
2016
2017 #[test]
2018 fn historical_run_records_replay_without_client_binding_fields() {
2019 let started: RunRecord = serde_json::from_str(
2020 r#"{"record":"started","run_id":"old","agent_id":"agent","intent":"go","started_at":"2026-01-02T03:04:05Z"}"#,
2021 )
2022 .expect("historical started row");
2023 let ended: RunRecord = serde_json::from_str(
2024 r#"{"record":"ended","run_id":"old","agent_id":"agent","termination":{"kind":"incomplete"},"ended_at":"2026-01-02T03:05:05Z"}"#,
2025 )
2026 .expect("historical ended row");
2027
2028 match started {
2029 RunRecord::Started(row) => assert!(row.client_id.is_none()),
2030 other => panic!("expected started, got {other:?}"),
2031 }
2032 match ended {
2033 RunRecord::Ended(row) => {
2034 assert!(row.client_id.is_none());
2035 assert!(row.completion_digest.is_none());
2036 }
2037 other => panic!("expected ended, got {other:?}"),
2038 }
2039 }
2040
2041 #[test]
2042 fn cli_outcome_tagged_variants_roundtrip() {
2043 let exited = CliOutcome::Exited { code: 0 };
2044 let json = serde_json::to_string(&exited).unwrap();
2045 assert_eq!(json, r#"{"kind":"exited","code":0}"#);
2046 assert_eq!(serde_json::from_str::<CliOutcome>(&json).unwrap(), exited);
2047
2048 assert_eq!(
2049 serde_json::to_string(&CliOutcome::Killed).unwrap(),
2050 r#"{"kind":"killed"}"#
2051 );
2052 assert_eq!(
2053 serde_json::to_string(&CliOutcome::Timeout).unwrap(),
2054 r#"{"kind":"timeout"}"#
2055 );
2056 assert_eq!(
2057 serde_json::from_str::<CliOutcome>(r#"{"kind":"timeout"}"#).unwrap(),
2058 CliOutcome::Timeout
2059 );
2060 }
2061
2062 #[test]
2063 fn verifier_verdict_serializes_snake_case() {
2064 assert_eq!(
2065 serde_json::to_string(&VerifierVerdict::Pass).unwrap(),
2066 r#""pass""#
2067 );
2068 assert_eq!(
2069 serde_json::to_string(&VerifierVerdict::Fail).unwrap(),
2070 r#""fail""#
2071 );
2072 assert_eq!(
2073 serde_json::to_string(&VerifierVerdict::NotRun).unwrap(),
2074 r#""not_run""#
2075 );
2076 assert_eq!(
2077 serde_json::from_str::<VerifierVerdict>(r#""not_run""#).unwrap(),
2078 VerifierVerdict::NotRun
2079 );
2080 }
2081
2082 #[test]
2083 fn policy_rejection_omits_none_param() {
2084 let pr = PolicyRejection {
2085 rule: "policy 'x': denied".to_string(),
2086 param: None,
2087 };
2088 let json = serde_json::to_string(&pr).unwrap();
2089 assert!(
2090 !json.contains("param"),
2091 "None param must be omitted: {json}"
2092 );
2093 let with_param = PolicyRejection {
2094 rule: "policy 'x': param 'prompt' matches 'rm -rf'".to_string(),
2095 param: Some("prompt".to_string()),
2096 };
2097 let json = serde_json::to_string(&with_param).unwrap();
2098 assert!(json.contains("\"param\":\"prompt\""));
2099 assert_eq!(
2100 serde_json::from_str::<PolicyRejection>(&json).unwrap(),
2101 with_param
2102 );
2103 }
2104
2105 #[test]
2106 fn run_record_turn_variant_roundtrip() {
2107 // The `turn` variant must serialize under the same `record` tag as
2108 // Started/Ended so U3/U4 consume one ordered RunRecord stream.
2109 let turn = RunRecord::Turn(RunTurn {
2110 index: 0,
2111 proposal_id: None,
2112 action_id: None,
2113 action_status: None,
2114 action_duration_ms: None,
2115 action_completed_at: None,
2116 depends_on: None,
2117 state_dependencies: None,
2118 prompt: Some("make the test pass".to_string()),
2119 tool: Some("drive_cli".to_string()),
2120 parameters: serde_json::json!({ "cli": "claude", "prompt": "make the test pass" }),
2121 output: Some(serde_json::json!({ "exit_code": 0, "output_tail": "done" })),
2122 cli_outcome: Some(CliOutcome::Exited { code: 0 }),
2123 verifier_verdict: VerifierVerdict::NotRun,
2124 policy_rejected: None,
2125 });
2126 let json = serde_json::to_string(&turn).unwrap();
2127 assert!(
2128 json.contains("\"record\":\"turn\""),
2129 "turn must tag on `record`: {json}"
2130 );
2131 match serde_json::from_str::<RunRecord>(&json).unwrap() {
2132 RunRecord::Turn(t) => {
2133 assert_eq!(t.index, 0);
2134 assert_eq!(t.tool.as_deref(), Some("drive_cli"));
2135 assert_eq!(t.cli_outcome, Some(CliOutcome::Exited { code: 0 }));
2136 assert_eq!(t.verifier_verdict, VerifierVerdict::NotRun);
2137 }
2138 other => panic!("expected Turn, got {other:?}"),
2139 }
2140 }
2141
2142 #[test]
2143 fn run_turn_minimal_omits_optional_fields() {
2144 // A generic, non-Bulldozer turn: no prompt, no cli/verifier
2145 // classification, no rejection — only the always-present fields
2146 // serialize plus the required verifier_verdict.
2147 let turn = RunTurn {
2148 index: 3,
2149 proposal_id: None,
2150 action_id: None,
2151 action_status: None,
2152 action_duration_ms: None,
2153 action_completed_at: None,
2154 depends_on: None,
2155 state_dependencies: None,
2156 prompt: None,
2157 tool: Some("search".to_string()),
2158 parameters: serde_json::json!({ "query": "rust" }),
2159 output: Some(Value::from("results")),
2160 cli_outcome: None,
2161 verifier_verdict: VerifierVerdict::NotRun,
2162 policy_rejected: None,
2163 };
2164 let json = serde_json::to_string(&turn).unwrap();
2165 assert!(!json.contains("prompt"));
2166 assert!(!json.contains("cli_outcome"));
2167 assert!(!json.contains("policy_rejected"));
2168 assert!(json.contains("\"verifier_verdict\":\"not_run\""));
2169 let rt: RunTurn = serde_json::from_str(&json).unwrap();
2170 assert_eq!(rt, turn);
2171 }
2172
2173 #[test]
2174 fn run_live_status_roundtrip() {
2175 // snake_case wire form the live subscribe/event share with the
2176 // store's RunStatus.
2177 assert_eq!(
2178 serde_json::to_string(&RunLiveStatus::InProgress).unwrap(),
2179 "\"in_progress\""
2180 );
2181 assert_eq!(
2182 serde_json::from_str::<RunLiveStatus>("\"completed\"").unwrap(),
2183 RunLiveStatus::Completed
2184 );
2185 assert_eq!(
2186 serde_json::from_str::<RunLiveStatus>("\"incomplete\"").unwrap(),
2187 RunLiveStatus::Incomplete
2188 );
2189 }
2190
2191 #[test]
2192 fn run_trace_event_wraps_record_and_cursor() {
2193 // The live notification carries the appended record plus the
2194 // post-append turn cursor and the run's live status.
2195 let ev = RunTraceEvent {
2196 run_id: "run-1".to_string(),
2197 agent_id: "agent-a".to_string(),
2198 record: RunRecord::Turn(RunTurn {
2199 index: 4,
2200 proposal_id: None,
2201 action_id: None,
2202 action_status: None,
2203 action_duration_ms: None,
2204 action_completed_at: None,
2205 depends_on: None,
2206 state_dependencies: None,
2207 prompt: Some("fix it".to_string()),
2208 tool: Some("drive_cli".to_string()),
2209 parameters: serde_json::json!({ "prompt": "fix it" }),
2210 output: Some(serde_json::json!({ "exit_code": 0 })),
2211 cli_outcome: Some(CliOutcome::Exited { code: 0 }),
2212 verifier_verdict: VerifierVerdict::NotRun,
2213 policy_rejected: None,
2214 }),
2215 cursor: 5,
2216 status: RunLiveStatus::InProgress,
2217 };
2218 let json = serde_json::to_string(&ev).unwrap();
2219 let back: RunTraceEvent = serde_json::from_str(&json).unwrap();
2220 assert_eq!(back.run_id, "run-1");
2221 assert_eq!(back.cursor, 5);
2222 assert_eq!(back.status, RunLiveStatus::InProgress);
2223 match back.record {
2224 RunRecord::Turn(t) => assert_eq!(t.index, 4),
2225 other => panic!("expected Turn, got {other:?}"),
2226 }
2227 }
2228
2229 #[test]
2230 fn run_subscribe_response_turns_only_snapshot() {
2231 let resp = RunSubscribeResponse {
2232 run_id: "run-1".to_string(),
2233 agent_id: "agent-a".to_string(),
2234 turns: vec![RunRecord::Turn(RunTurn {
2235 index: 0,
2236 proposal_id: None,
2237 action_id: None,
2238 action_status: None,
2239 action_duration_ms: None,
2240 action_completed_at: None,
2241 depends_on: None,
2242 state_dependencies: None,
2243 prompt: None,
2244 tool: Some("drive_cli".to_string()),
2245 parameters: Value::Null,
2246 output: None,
2247 cli_outcome: None,
2248 verifier_verdict: VerifierVerdict::NotRun,
2249 policy_rejected: None,
2250 })],
2251 cursor: 0,
2252 limit: 100,
2253 next_cursor: None,
2254 live_cursor: 1,
2255 subscribed: true,
2256 status: RunLiveStatus::InProgress,
2257 };
2258 let json = serde_json::to_string(&resp).unwrap();
2259 let back: RunSubscribeResponse = serde_json::from_str(&json).unwrap();
2260 assert_eq!(back.cursor, 0);
2261 assert_eq!(back.live_cursor, 1);
2262 assert!(back.subscribed);
2263 assert_eq!(back.turns.len(), 1);
2264 assert!(matches!(back.turns[0], RunRecord::Turn(_)));
2265 }
2266
2267 // FIX 1: the canonical harness builds the outcome itself and sends
2268 // `{status, summary, evidence, metrics, tools_called}` with NO `timestamp`
2269 // and an EXTRA `tools_called` field. RunCompleteRequest.outcome must
2270 // deserialize this shape, otherwise `runs.complete` fails and the run is
2271 // never marked ended (recorded Incomplete).
2272 #[test]
2273 fn run_complete_request_accepts_harness_outcome_shape() {
2274 let req_json = serde_json::json!({
2275 "run_id": "r1",
2276 "outcome": {
2277 "status": "success",
2278 "summary": "Created file",
2279 "evidence": [],
2280 "metrics": {
2281 "turns": 3,
2282 "tool_calls": 3,
2283 "actions_succeeded": 3,
2284 "actions_failed": 0
2285 },
2286 "tools_called": ["drive_cli", "check_outcome", "finish"]
2287 }
2288 });
2289
2290 let req: RunCompleteRequest =
2291 serde_json::from_value(req_json).expect("harness outcome shape must deserialize");
2292 assert_eq!(req.run_id, "r1");
2293 assert_eq!(req.outcome.status, car_ir::OutcomeStatus::Success);
2294 assert_eq!(req.outcome.summary, "Created file");
2295 assert_eq!(req.outcome.metrics.turns, 3);
2296 assert_eq!(req.outcome.metrics.tool_calls, 3);
2297 assert_eq!(req.outcome.metrics.actions_succeeded, 3);
2298 assert_eq!(req.outcome.metrics.actions_failed, 0);
2299 // omitted metrics fields default to zero
2300 assert_eq!(req.outcome.metrics.duration_ms, 0.0);
2301 assert_eq!(req.outcome.metrics.retries, 0);
2302 }
2303
2304 #[test]
2305 fn run_cancel_contract_is_strict_and_capability_gated() {
2306 let negotiated = negotiate_capabilities(
2307 &[RUNS_CANCEL_CAPABILITY.to_string()],
2308 &[RUNS_PAGINATION_CAPABILITY.to_string()],
2309 )
2310 .unwrap();
2311 assert_eq!(
2312 negotiated,
2313 vec![
2314 RUNS_CANCEL_CAPABILITY.to_string(),
2315 RUNS_PAGINATION_CAPABILITY.to_string()
2316 ]
2317 );
2318 let request: RunCancelRequest = serde_json::from_value(serde_json::json!({
2319 "run_id":"run-1","idempotency_key":"cancel-1","reason":"operator stop"
2320 }))
2321 .unwrap();
2322 assert_eq!(request.reason, "operator stop");
2323 assert!(
2324 serde_json::from_value::<RunCancelRequest>(serde_json::json!({
2325 "run_id":"run-1","idempotency_key":"cancel-1","reason":"stop","extra":true
2326 }))
2327 .is_err()
2328 );
2329 }
2330
2331 #[test]
2332 fn cancelled_termination_and_body_free_records_round_trip() {
2333 let identity = RunCancellationIdentity {
2334 receipt_version: 1,
2335 run_id: "run-1".into(),
2336 idempotency_key: "cancel-1".into(),
2337 reason_digest: "a".repeat(64),
2338 principal: "agent:daily-continuity-newsroom".into(),
2339 action_id: Some("editor".into()),
2340 request_id: Some("cb-7".into()),
2341 };
2342 let ended = RunRecord::Ended(RunEnded {
2343 run_id: "run-1".into(),
2344 client_id: Some("client-1".into()),
2345 agent_id: "daily-continuity-newsroom".into(),
2346 termination: RunTermination::Cancelled {
2347 cancellation: identity,
2348 },
2349 completion_digest: Some("b".repeat(64)),
2350 ended_at: Utc::now(),
2351 });
2352 let json = serde_json::to_string(&ended).unwrap();
2353 assert!(json.contains("\"kind\":\"cancelled\""));
2354 assert!(!json.contains("operator stop"));
2355 assert!(matches!(
2356 serde_json::from_str::<RunRecord>(&json).unwrap(),
2357 RunRecord::Ended(RunEnded {
2358 termination: RunTermination::Cancelled { .. },
2359 ..
2360 })
2361 ));
2362 }
2363}