Skip to main content

smix_runner_wire/
lib.rs

1#![doc = include_str!("../README.md")]
2#![deny(missing_docs)]
3#![deny(rustdoc::broken_intra_doc_links)]
4
5//! smix-runner-wire — pure wire types for the SmixRunnerCore HTTP IPC.
6//!
7//! Stone, zero project coupling beyond the smix-screen / smix-selector
8//! upstream types it embeds (those are themselves stones).
9//!
10//! Pairs with [`smix-runner-client`](https://docs.rs/smix-runner-client)
11//! which provides the reqwest+tokio HTTP client; this crate is just the
12//! types so a consumer can:
13//!
14//! - Drive their own HTTP client (sync or async, custom transport)
15//! - Hand-roll a server side serving the same wire contract
16//! - Pin the wire-shape contract independently of the client implementation
17//!
18//! # Route surface
19//!
20//! 18 wire endpoints (see the `smix-runner-client` crate for the
21//! corresponding method names):
22//!
23//! - `GET /health` — bare 200/non-200
24//! - `GET /tree?include=…` → [`smix_screen::A11yNode`]
25//! - `GET /system-popups?include=…` → `Vec<`[`SystemPopup`]`>`
26//! - `POST /system-popup-action` `{popupId, buttonId}` → [`SystemPopupActionResponse`]
27//! - `POST /tap` → [`TapResult`]
28//! - `POST /tap-at-norm-coord` `{nx, ny}` → [`TapAtCoordResult`]
29//! - `POST /find` `{selector}` → `{exists}` or `{ok}`
30//! - `POST /fill` `{selector, text}` → [`RunnerKeyboardResult`]
31//! - `POST /clear` `{selector}` → [`RunnerKeyboardResult`]
32//! - `POST /press-key` `{key}` → [`RunnerKeyboardResult`]
33//! - `POST /scroll` `{selector, direction}` → `{matched, swipes}`
34//! - `POST /swipe-once` `{direction}` → `{ok}`
35//! - `POST /foreground` `{bundleId}` → `{ok}`
36//! - `POST /hide-keyboard` → `{ok}`
37//! - `POST /back` → `{ok}`
38//! - `POST /record/start` → `{ok}`
39//! - `GET /record/poll` → `{events: [`[`RecordedEvent`]`]}`
40//! - `POST /record/stop` → `{events: [`[`RecordedEvent`]`]}`
41
42#![doc(html_root_url = "https://docs.smix.dev/smix-runner-wire")]
43
44use serde::{Deserialize, Serialize};
45use smix_selector::Selector;
46use thiserror::Error;
47
48// -------------------- Errors --------------------------------------------
49
50/// Transport-level failure variants exposed by the HTTP client. The
51/// concrete `reqwest::Error` source lives in `smix-runner-client`; the
52/// wire stone exposes only the discriminator + endpoint context so
53/// non-HTTP transports can reuse the variants.
54#[derive(Debug, Error)]
55pub enum RunnerTransportErrorKind {
56    /// Network / transport-layer fetch error (timeout, DNS, TLS, etc.).
57    #[error("runner {endpoint} fetch failed")]
58    FetchFailed {
59        /// Endpoint path that failed (e.g. `"/tap"`).
60        endpoint: String,
61    },
62    /// Runner returned non-2xx HTTP status.
63    #[error("runner {endpoint} returned status {status}: {body}")]
64    NonSuccessStatus {
65        /// Endpoint path that returned the error.
66        endpoint: String,
67        /// HTTP status code.
68        status: u16,
69        /// Raw response body (may be truncated).
70        body: String,
71    },
72    /// Runner returned a body that wasn't valid JSON.
73    #[error("runner {endpoint} returned non-JSON body")]
74    NonJsonBody {
75        /// Endpoint path that returned a non-JSON body.
76        endpoint: String,
77    },
78    /// Runner returned valid JSON but it didn't match the expected schema.
79    #[error("runner {endpoint} returned malformed body: {detail}")]
80    MalformedBody {
81        /// Endpoint path that returned the malformed body.
82        endpoint: String,
83        /// Schema-mismatch detail (serde error message).
84        detail: String,
85    },
86    /// Runner is unreachable (refused / closed / not listening).
87    #[error("runner {endpoint} unreachable: {message}")]
88    Unreachable {
89        /// Endpoint path attempted.
90        endpoint: String,
91        /// Reason (e.g. "connection refused").
92        message: String,
93    },
94}
95
96// -------------------- Common wire types ---------------------------------
97
98/// `include` scope query param shared by `/tree` / `/tap` / `/fill` /
99/// `/clear` / `/find` / `/scroll` / `/system-popups`. URL-only.
100#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
101#[serde(rename_all = "camelCase")]
102pub struct RunnerIncludeOpts {
103    /// Optional include scope (e.g. system-popups → `AllWindows`).
104    #[serde(default, skip_serializing_if = "Option::is_none")]
105    pub include: Option<IncludeScope>,
106}
107
108/// `include` scope literal — currently only `all-windows` (system popups
109/// pierce the app frame).
110#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
111#[serde(rename_all = "kebab-case")]
112pub enum IncludeScope {
113    /// Include all windows (system popups, alerts) above the app frame.
114    AllWindows,
115}
116
117impl IncludeScope {
118    /// kebab-case wire string used in the query parameter value.
119    pub fn query_value(self) -> &'static str {
120        match self {
121            IncludeScope::AllWindows => "all-windows",
122        }
123    }
124}
125
126// -------------------- /tap wire shape -----------------------------------
127
128/// `POST /tap` mode discriminator.
129#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
130#[serde(rename_all = "camelCase")]
131pub enum TapMode {
132    /// Runner returns only the matched frame; host normalizes + injects
133    /// via `/tap-at-norm-coord`. This is the default.
134    Resolve,
135    /// Runner resolves AND taps (legacy host-HID-based path).
136    ResolveAndTap,
137    /// Runner resolves selector then synthesizes the touch event via the
138    /// XCTRunnerDaemonSession daemonProxy. This bypasses the XCUIElement
139    /// gesture recognizer chain so RN Pressable `RCTTouchHandler`
140    /// UIGestureRecognizer receives the touch and fires the JS-thread
141    /// `onPress` callback reliably.
142    DaemonProxySynthesize,
143}
144
145/// `POST /tap` per-stage timing in milliseconds.
146#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
147#[serde(rename_all = "camelCase")]
148pub struct TapStages {
149    /// Time spent resolving the selector against the a11y tree.
150    #[serde(rename = "resolveMs", default)]
151    pub resolve_ms: f64,
152    /// Time spent dispatching the tap event itself.
153    #[serde(rename = "tapCallMs", default)]
154    pub tap_call_ms: f64,
155    /// End-to-end wall-clock for the whole tap call.
156    #[serde(rename = "totalMs", default)]
157    pub total_ms: f64,
158    /// Time spent waiting for the element to exist (implicit wait).
159    #[serde(rename = "waitExistenceMs", default)]
160    pub wait_existence_ms: f64,
161    /// Time spent reading the matched element's frame.
162    #[serde(rename = "frameReadMs", default)]
163    pub frame_read_ms: f64,
164}
165
166/// `POST /tap` response body.
167///
168/// This struct is the wire contract; the Swift emitter
169/// (`TapRoute.success`) is gated against it by
170/// `tests/tap_route_shape.rs`, which failed to exist long enough for the
171/// runner to ship a nested-`matched` + snake_case body that this struct
172/// silently deserialized to all-`None`/zero.
173#[derive(Clone, Debug, Default, Serialize, Deserialize)]
174#[serde(rename_all = "camelCase")]
175pub struct TapResult {
176    /// Matched element's accessibility label (selector text echoed when
177    /// the label is empty).
178    #[serde(rename = "matchedLabel", default)]
179    pub matched_label: Option<String>,
180    /// Per-stage timing breakdown.
181    #[serde(default)]
182    pub stages: Option<TapStages>,
183    /// Matched element's geometric frame, when resolve succeeded.
184    #[serde(default)]
185    pub frame: Option<smix_screen::Rect>,
186    /// Application window frame (for normalizing the matched frame).
187    #[serde(rename = "appFrame", default)]
188    pub app_frame: Option<smix_screen::Rect>,
189}
190
191/// `POST /long-press` response body.
192///
193/// Bounds, not instants. `press(forDuration:)` does not report when the
194/// touch went down, so the runner reports what it can measure — the
195/// call's own span — reduced to the two bounds that hold whatever went
196/// on inside it. A reader who takes `latest_down_offset_ms` for "when
197/// it went down" will place frames inside a press they were not inside.
198///
199/// All fields default to zero, which reads as "no window can be
200/// established" rather than as a press at time zero.
201#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
202#[serde(rename_all = "camelCase")]
203pub struct PressResult {
204    /// Handler entry → the latest instant the touch could have gone down.
205    #[serde(default)]
206    pub latest_down_offset_ms: u64,
207    /// Handler entry → the earliest instant the touch could have lifted.
208    #[serde(default)]
209    pub earliest_up_offset_ms: u64,
210    /// Handler entry → handler return.
211    #[serde(default)]
212    pub handler_wall_ms: u64,
213}
214
215/// One named element containing the tapped point.
216///
217/// Named only: the same point sits inside dozens of anonymous
218/// full-screen layout containers, and the host matches selectors by
219/// identifier and label, so an unnamed container is something it can
220/// neither act on nor tell apart from the next one.
221#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
222#[serde(rename_all = "camelCase")]
223pub struct HitChainEntry {
224    /// Accessibility identifier; empty when the element has none.
225    #[serde(default)]
226    pub identifier: String,
227    /// Accessibility label; empty when the element has none.
228    #[serde(default)]
229    pub label: String,
230    /// The element's frame in the app's coordinate space.
231    pub frame: smix_screen::Rect,
232}
233
234/// `POST /tap-at-norm-coord` response.
235///
236/// The route used to answer with a bare `{ok}`, which meant "a touch
237/// was synthesised at that coordinate" and was read as "the element was
238/// tapped". Those are different claims, and a consumer watching taps
239/// succeed against a button whose counter never moved found out which
240/// one they were getting.
241///
242/// `chain` is every named element containing the point in the state the
243/// touch was delivered to, innermost first — not one element, because
244/// the innermost thing at a button's centre is usually the button's own
245/// label, and not after the touch, because a tap that opens a screen
246/// has the destination under that point by then. See
247/// `smix_driver::tap_landed_within` for what the host does with it and,
248/// more importantly, for what it still cannot see.
249#[derive(Clone, Debug, Serialize, Deserialize, Default)]
250#[serde(rename_all = "camelCase")]
251pub struct TapAtCoordResult {
252    /// Named elements containing the tapped point, innermost first.
253    ///
254    /// Empty when the runner is older than this field or when the point
255    /// landed outside every named element. The host cannot tell those
256    /// apart from the wire alone, which is why an empty chain is a
257    /// verdict of its own rather than a silent pass.
258    #[serde(default)]
259    pub chain: Vec<HitChainEntry>,
260}
261
262/// `POST /tap` request body.
263#[derive(Clone, Debug, Serialize, Deserialize)]
264#[serde(rename_all = "camelCase")]
265pub struct TapRequest {
266    /// Selector picking the tap target.
267    pub selector: Selector,
268    /// Resolve-only vs resolve-and-tap discriminator.
269    pub mode: TapMode,
270}
271
272/// `POST /tap-at-norm-coord` request body.
273#[derive(Clone, Copy, Debug, Serialize, Deserialize)]
274#[serde(rename_all = "camelCase")]
275pub struct TapAtNormCoordRequest {
276    /// Normalized x coordinate in `(0, 1)` (app-frame relative).
277    pub nx: f64,
278    /// Normalized y coordinate in `(0, 1)` (app-frame relative).
279    pub ny: f64,
280}
281
282// -------------------- /fill /clear /press-key wire shape ----------------
283
284/// Per-stage timing returned by `/fill` / `/clear` / `/press-key`.
285#[derive(Clone, Debug, Default, Serialize, Deserialize)]
286#[serde(rename_all = "camelCase")]
287pub struct KeyboardStages {
288    /// Selector resolve time.
289    #[serde(rename = "resolveMs", default)]
290    pub resolve_ms: f64,
291    /// Time waiting for keyboard appearance after the focus tap.
292    #[serde(rename = "keyboardWaitMs", default)]
293    pub keyboard_wait_ms: f64,
294    /// Time spent typing the characters.
295    #[serde(rename = "typingMs", default)]
296    pub typing_ms: f64,
297    /// End-to-end wall-clock for the whole keyboard operation.
298    #[serde(rename = "totalMs", default)]
299    pub total_ms: f64,
300}
301
302/// `POST /fill` / `POST /clear` / `POST /press-key` response body.
303#[derive(Clone, Debug, Default, Serialize, Deserialize)]
304#[serde(rename_all = "camelCase")]
305pub struct RunnerKeyboardResult {
306    /// Tree snapshot after the keyboard operation completed (optional).
307    #[serde(default)]
308    pub tree: Option<smix_screen::A11yNode>,
309    /// Per-stage timing breakdown (optional).
310    #[serde(default)]
311    pub stages: Option<KeyboardStages>,
312}
313
314// -------------------- /find wire shape -----------------------------------
315
316/// `POST /find` request body.
317#[derive(Clone, Debug, Serialize, Deserialize)]
318#[serde(rename_all = "camelCase")]
319pub struct FindRequest {
320    /// Selector to look up.
321    pub selector: Selector,
322}
323
324/// `POST /find` response body.
325#[derive(Clone, Debug, Default, Serialize, Deserialize)]
326#[serde(rename_all = "camelCase")]
327pub struct FindResponse {
328    /// Whether the selector matched any element.
329    #[serde(default)]
330    pub exists: bool,
331    /// Whether the resolve subsystem itself succeeded.
332    #[serde(default)]
333    pub ok: bool,
334}
335
336// -------------------- /scroll wire shape --------------------------------
337
338/// Reduced selector shape used by `/scroll` (text-or-id only; complex
339/// selectors are host-side-resolved before reaching the runner).
340#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
341#[serde(untagged)]
342pub enum RunnerScrollSelector {
343    /// Match by text content.
344    Text {
345        /// Text to match against.
346        text: String,
347    },
348    /// Match by accessibility identifier.
349    Id {
350        /// Identifier to match against.
351        id: String,
352    },
353}
354
355/// `POST /scroll` response body.
356#[derive(Clone, Debug, Default, Serialize, Deserialize)]
357#[serde(rename_all = "camelCase")]
358pub struct ScrollResponse {
359    /// Whether the selector matched after scrolling (None when unknown).
360    #[serde(default)]
361    pub matched: Option<bool>,
362    /// Number of swipe iterations performed.
363    #[serde(default)]
364    pub swipes: Option<u32>,
365}
366
367// -------------------- /system-popups wire shape -------------------------
368
369/// One system-popup discovered on the screen (alert / sheet / banner).
370#[derive(Clone, Debug, Serialize, Deserialize)]
371#[serde(rename_all = "camelCase")]
372pub struct SystemPopup {
373    /// Stable identifier for matching across polls.
374    pub id: String,
375    /// Discriminator (e.g. `"alert"` / `"sheet"` / `"banner"`).
376    #[serde(rename = "type")]
377    pub kind: String,
378    /// Originator (e.g. bundle id, system framework name).
379    pub source: String,
380    /// Popup title text.
381    #[serde(default)]
382    pub title: String,
383    /// Popup body text.
384    #[serde(default)]
385    pub body: String,
386    /// Buttons available on the popup.
387    #[serde(default)]
388    pub buttons: Vec<SystemPopupButton>,
389}
390
391/// One button on a [`SystemPopup`].
392#[derive(Clone, Debug, Serialize, Deserialize)]
393#[serde(rename_all = "camelCase")]
394pub struct SystemPopupButton {
395    /// Stable identifier for the button.
396    pub id: String,
397    /// Visible button label.
398    pub label: String,
399    /// Semantic role (`"cancel"` / `"destructive"` / `"default"`).
400    pub role: String,
401    /// Whether tapping this button performs a destructive action.
402    #[serde(default)]
403    pub dangerous: bool,
404    /// Optional outcome hint (e.g. `"grants location permission"`).
405    #[serde(rename = "outcomeHint", default)]
406    pub outcome_hint: Option<String>,
407}
408
409/// `POST /system-popup-action` request body.
410///
411/// `popupId` and `buttonId` round-trip from a prior `GET /system-popups`
412/// enumerate (`Popup.id` / `PopupButton.id` fields). The runner walks the
413/// same scan order on the act path, so callers do not need to maintain an
414/// out-of-band id map.
415#[derive(Clone, Debug, Serialize, Deserialize)]
416#[serde(rename_all = "camelCase")]
417pub struct SystemPopupActionRequest {
418    /// Popup id from a prior `Popup.id` enumerate.
419    pub popup_id: String,
420    /// Button id from a prior `PopupButton.id` enumerate.
421    pub button_id: String,
422}
423
424/// `POST /system-popup-action` response body.
425///
426/// `ok=true` ⇒ the runner found the popup + button and dispatched a
427/// daemonProxy touch; `ok=false` ⇒ neither side matched (either popup id
428/// missed, button id missed, or synthesize raised inside the runner).
429#[derive(Clone, Debug, Default, Serialize, Deserialize)]
430#[serde(rename_all = "camelCase")]
431pub struct SystemPopupActionResponse {
432    /// Whether the popup-button match + tap dispatch succeeded.
433    #[serde(default)]
434    pub ok: bool,
435    /// Wire-layer error discriminator ("not_found" / "bad_request" / etc.)
436    /// emitted by the runner on the non-2xx path. Absent on success.
437    #[serde(default, skip_serializing_if = "Option::is_none")]
438    pub error: Option<String>,
439}
440
441/// `GET /system-popups` response body.
442#[derive(Clone, Debug, Default, Serialize, Deserialize)]
443#[serde(rename_all = "camelCase")]
444pub struct SystemPopupsResponse {
445    /// All popups currently on screen (empty when none).
446    #[serde(default)]
447    pub popups: Vec<SystemPopup>,
448}
449
450// -------------------- /record/* wire shape ------------------------------
451
452/// One recorder event captured by `/record/start` → `/record/poll` flow.
453///
454/// The Swift `EventRecorder` emits the notification discriminator as
455/// `rawCode`, not `code`. Field names here mirror that schema exactly
456/// (`raw_code` + serde camelCase → `rawCode`); a mismatch deserializes
457/// silently to `0` rather than erroring, so the naming is load-bearing.
458///
459/// `extra` is a flatten catch-all for the enrich fields Swift splats onto
460/// the top level of `RecordedEvent` (`kind` / `frame` /
461/// `payloadDescription` / `elementType` / …), so new ones are accepted
462/// without a wire break.
463#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
464#[serde(rename_all = "camelCase")]
465pub struct RecordedEvent {
466    /// Numeric event-type discriminator (Swift `RecordedEvent.rawCode`),
467    /// carrying the raw kAXNotification int. Typical values: 1018
468    /// (kAXFirstResponderChangedNotification) / 1028
469    /// (kAXHIDEventReceivedNotification) / 4002 (kAXUserTestingNotification)
470    /// / 1006 (kAXAlertNotification) / 1021 (kAXPidStatusChangedNotification).
471    #[serde(default)]
472    pub raw_code: i32,
473    /// Capture-time timestamp in milliseconds.
474    #[serde(default)]
475    pub timestamp_ms: f64,
476    /// Free-form per-event enrich fields that the Swift side splats onto
477    /// the top level (`kind` / `frame` / `payloadDescription` /
478    /// `elementType` / `appBundleId` / `payloadClassName` / …).
479    /// Reconciliation reads only `raw_code` + `timestamp_ms` and never
480    /// depends on this; consumers wanting detail can read it on demand.
481    #[serde(flatten, default)]
482    pub extra: serde_json::Map<String, serde_json::Value>,
483}
484
485/// `GET /record/poll` / `POST /record/stop` response body.
486#[derive(Clone, Debug, Default, Serialize, Deserialize)]
487#[serde(rename_all = "camelCase")]
488pub struct RecordEventsResponse {
489    /// Events captured since the last poll (chronological).
490    #[serde(default)]
491    pub events: Vec<RecordedEvent>,
492}
493
494// -------------------- /session/* wire shape ------------------------------
495//
496// Session lifecycle addresses the "activation storm" root cause: without a
497// session the runner re-binds + `.activate()`s an `XCUIApplication` on every
498// request whose `App-Activate: true` header is set. Long-running gates (visual /
499// perf regression) accumulate thousands of activate calls, exhausting
500// XCTest process arbitration on iOS 26.5+ and crashing `test_runForever()`
501// mid-run. Sessions replace that with a one-shot lifecycle: open once,
502// runner caches the binding + activates at most on transition or via
503// explicit renew, close when the client is done.
504//
505// Wire compat: absent `Session-Id: <id>` header on any request falls
506// through to the legacy per-request `resolveApp()` path, now itself
507// rate-limited to at most one `.activate()` per 5 s per bundle-id (which
508// is enough to keep the recovery-from-drift semantic of the original
509// design without producing the storm).
510
511/// `POST /session/open` request body.
512#[derive(Clone, Debug, Serialize, Deserialize)]
513#[serde(rename_all = "camelCase")]
514pub struct SessionOpenRequest {
515    /// Bundle id (iOS) / package name (Android) the session is bound to.
516    /// Empty string means "use the runner's boot-time default", which is
517    /// almost always `com.apple.Preferences` — usable for testing but
518    /// probably not what the client wants.
519    #[serde(default)]
520    pub bundle_id: String,
521    /// If true, the runner calls `.activate()` once as part of the open,
522    /// synchronously, before returning. Idiomatic for gates that want
523    /// the target app foregrounded before the first `/tap` fires.
524    #[serde(default)]
525    pub activate: bool,
526}
527
528/// `POST /session/open` response body.
529///
530/// The returned `session_id` becomes the value of the `Session-Id`
531/// request header on every subsequent request that should share this
532/// session's cached app binding.
533#[derive(Clone, Debug, Serialize, Deserialize)]
534#[serde(rename_all = "camelCase")]
535pub struct SessionOpenResponse {
536    /// Opaque token; treat as a stable string identifier. UUID today.
537    pub session_id: String,
538    /// Whether the runner issued an initial `.activate()` on open.
539    /// Mirrors the request's `activate` field unless the runner
540    /// rate-limited it (e.g. same bundle-id was activated within the
541    /// last 5 s from a prior session).
542    #[serde(default)]
543    pub activated_once: bool,
544    /// Server-side epoch millis at open. Consumers pair this with
545    /// downstream `sessionUptimeMs` sidecar fields to reconstruct
546    /// session timelines.
547    #[serde(default)]
548    pub server_time_ms: u64,
549}
550
551/// `POST /session/close` request body.
552#[derive(Clone, Debug, Serialize, Deserialize)]
553#[serde(rename_all = "camelCase")]
554pub struct SessionCloseRequest {
555    /// Session to close. Absent / unknown / already-closed sessions
556    /// return 200 with `ok=true` — idempotent.
557    pub session_id: String,
558}
559
560/// `POST /session/close` response body.
561#[derive(Clone, Debug, Default, Serialize, Deserialize)]
562#[serde(rename_all = "camelCase")]
563pub struct SessionCloseResponse {
564    /// True unless the runner failed to remove the session cache entry.
565    /// Not a "session was known" flag — closing an unknown session is
566    /// idempotent success.
567    pub ok: bool,
568}
569
570/// `POST /session/renew-activation` request body.
571///
572/// Client-side escape hatch when the client detects target-app drift
573/// (e.g. `/tree` returned `snapshot_unavailable`, or a foreground steal
574/// by SpringBoard). The runner re-issues `.activate()` on the cached
575/// binding subject to the same 5 s / bundle-id rate limit.
576#[derive(Clone, Debug, Serialize, Deserialize)]
577#[serde(rename_all = "camelCase")]
578pub struct SessionRenewActivationRequest {
579    /// Session to renew. Unknown session → 404.
580    pub session_id: String,
581}
582
583/// `POST /session/renew-activation` response body.
584#[derive(Clone, Debug, Default, Serialize, Deserialize)]
585#[serde(rename_all = "camelCase")]
586pub struct SessionRenewActivationResponse {
587    /// True on success (session known + not-rate-limited).
588    pub ok: bool,
589    /// Whether the runner actually issued `.activate()` this call, or
590    /// suppressed it because the previous activation was within the
591    /// rate-limit window. `ok=true, activated=false` is a valid outcome
592    /// meaning "session is fresh enough, no-op".
593    #[serde(default)]
594    pub activated: bool,
595}
596
597/// Extended `GET /health` response body.
598///
599/// The body is purely additive over the legacy bare-200 response.
600/// Wire schema versions this build speaks.
601///
602/// The schema is versioned apart from the runner, because they are not the
603/// same thing. A runner that has been up since before a CLI upgrade, and an
604/// SDK released on its own cadence, both talk to a wire whose shape may not
605/// have moved at all. Tying them to the runner's semver made every version
606/// difference an error, including the ones that did not matter.
607///
608/// Add a version here when the shape changes, and keep the old one for as
609/// long as it is still spoken.
610pub const WIRE_SCHEMA_SUPPORTED: &[u32] = &[1, 2];
611
612/// The newest schema both ends speak, or `None` when they share none.
613///
614/// Highest-common rather than exact-match: two builds that both know schema
615/// 2 agree on schema 2, whatever their versions say.
616#[must_use]
617pub fn negotiate_wire_schema(ours: &[u32], theirs: &[u32]) -> Option<u32> {
618    ours.iter().filter(|v| theirs.contains(v)).copied().max()
619}
620
621/// What a runner says about the wire it speaks.
622#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
623#[serde(rename_all = "camelCase")]
624pub struct WireSchemaInfo {
625    /// Every schema the runner speaks.
626    #[serde(default)]
627    pub supports: Vec<u32>,
628    /// The one it settled on with the client that asked, when the client
629    /// said what it speaks. Absent from a runner too old to have been asked.
630    #[serde(default)]
631    pub negotiated: Option<u32>,
632}
633
634/// Consumers that ignore the body get identical behavior; consumers
635/// that parse the JSON gain liveness observability.
636#[derive(Clone, Debug, Default, Serialize, Deserialize)]
637#[serde(rename_all = "camelCase")]
638pub struct HealthResponse {
639    /// Runner alive.
640    pub ok: bool,
641    /// Runner semver (matches the smix-cli that shipped it).
642    #[serde(default)]
643    pub runner_version: String,
644    /// Runner uptime in milliseconds.
645    #[serde(default)]
646    pub uptime_ms: u64,
647    /// Epoch millis of the runner's most recent processed request
648    /// (any route). 0 if the runner has served no requests yet.
649    #[serde(default)]
650    pub last_request_at_ms: u64,
651    /// Currently-open session count.
652    #[serde(default)]
653    pub sessions_open: u32,
654    /// Total `.activate()` calls issued since runner boot.
655    #[serde(default)]
656    pub activations_total: u64,
657    /// SimRenderServer pid + alive flag observed by the runner's
658    /// sim-health sensor. `alive = false` after
659    /// `com.apple.display.captureservice` internal assertion trips.
660    #[serde(default)]
661    pub sim_render_server: HealthProcessInfo,
662    /// xcodebuild test-host pid + alive flag + total restart count
663    /// (auto-restart on `** TEST INTERRUPTED **`).
664    #[serde(default)]
665    pub xcodebuild_test_host: HealthTestHostInfo,
666    /// The wire this runner speaks. Empty from a runner that predates the
667    /// question — which means "unknown", not "none".
668    #[serde(default)]
669    pub wire_schema: WireSchemaInfo,
670}
671
672/// Pid + alive flag for a watched process. Additive to
673/// [`HealthResponse`].
674#[derive(Clone, Debug, Default, Serialize, Deserialize)]
675#[serde(rename_all = "camelCase")]
676pub struct HealthProcessInfo {
677    /// True when the runner last observed the process alive.
678    #[serde(default)]
679    pub alive: bool,
680    /// Last-known pid (0 if never observed).
681    #[serde(default)]
682    pub pid: u32,
683}
684
685/// xcodebuild test-host health with restart accounting.
686#[derive(Clone, Debug, Default, Serialize, Deserialize)]
687#[serde(rename_all = "camelCase")]
688pub struct HealthTestHostInfo {
689    /// True when the runner-side supervisor last saw xcodebuild alive.
690    #[serde(default)]
691    pub alive: bool,
692    /// Last-known pid (0 if unrecorded).
693    #[serde(default)]
694    pub pid: u32,
695    /// Number of times the supervisor has restarted the test-host.
696    #[serde(default)]
697    pub restart_count: u32,
698}
699
700/// `POST /session/close-all` — backs `smix runner cycle`. Runner-side
701/// clears every open session and returns the count that was cleared.
702#[derive(Clone, Debug, Default, Serialize, Deserialize)]
703#[serde(rename_all = "camelCase")]
704pub struct SessionCloseAllResponse {
705    /// True on success (idempotent — closing an empty session table
706    /// is not an error).
707    pub ok: bool,
708    /// Number of sessions closed.
709    #[serde(default)]
710    pub closed: u32,
711}
712
713/// `POST /session/relaunch-app` request body.
714///
715/// Instructs the runner to `terminate()` + `launch()` the session's
716/// cached `XCUIApplication` binding IN PLACE — same test-host process,
717/// same session id, same XCUITest binding. Used to recover from a
718/// downstream app crash without cycling the entire runner.
719#[derive(Clone, Debug, Serialize, Deserialize)]
720#[serde(rename_all = "camelCase")]
721pub struct SessionRelaunchAppRequest {
722    /// Session whose cached binding will be relaunched.
723    pub session_id: String,
724}
725
726/// `POST /session/relaunch-app` response body.
727#[derive(Clone, Debug, Default, Serialize, Deserialize)]
728#[serde(rename_all = "camelCase")]
729pub struct SessionRelaunchAppResponse {
730    /// True on success (session known + relaunch cycle completed).
731    pub ok: bool,
732    /// Wall-clock milliseconds the terminate+launch cycle took.
733    #[serde(default)]
734    pub wall_ms: u64,
735}
736
737/// Request body shared by `POST /session/terminate-app` and
738/// `POST /session/launch-app`.
739///
740/// Carries launch-argument / launch-environment injection so consumers
741/// can bypass scaffolding like Expo dev-launcher server pickers that
742/// vanish on clearAppData (SDK 57 stops auto-navigating on URL scheme).
743// Not `#[non_exhaustive]` — consumers construct these directly. Wire
744// evolution is handled by `#[serde(default)]` on each field, so
745// adding a field is source-compatible for anyone using
746// `..Default::default()` in their struct literal (idiomatic pattern
747// in the SDK). The response variant DOES carry `#[non_exhaustive]`
748// because consumers only READ it, not construct.
749#[derive(Clone, Debug, Default, Serialize, Deserialize)]
750#[serde(rename_all = "camelCase")]
751pub struct SessionAppLifecycleRequest {
752    /// Session whose cached `XCUIApplication` binding will be acted on.
753    pub session_id: String,
754    /// launchArguments passed to `XCUIApplication.launch()`. Empty vec
755    /// leaves whatever the runner already had cached on
756    /// `entry.app.launchArguments` (empty by default).
757    /// Ignored by `/session/terminate-app`.
758    #[serde(default)]
759    pub args: Vec<String>,
760    /// launchEnvironment passed to `XCUIApplication.launch()`. Empty map
761    /// leaves the runner's cached environment untouched. Ignored by
762    /// `/session/terminate-app`.
763    #[serde(default)]
764    pub env: std::collections::BTreeMap<String, String>,
765    /// After `.launch()` dispatches, poll `XCUIApplication.state` at
766    /// 250 ms cadence until `.runningForeground` (or timeout). Prevents
767    /// callers from firing a subsequent terminateApp before the process
768    /// has signalled launchd ready — the root cause of
769    /// `bug_type: 309 exec_terminated_before_ready` `.ips` writes.
770    /// `None` = fire-and-return semantics; `Some(0)` = wait one
771    /// iteration then return terminal state (useful for tests).
772    /// Default: `Some(15000)` at the SDK layer.
773    #[serde(default)]
774    pub wait_for_foreground_ms: Option<u64>,
775    /// After `.runningForeground` is observed (or immediately when
776    /// `wait_for_foreground_ms == None`), poll the a11y tree at 500 ms
777    /// cadence looking for ≥ `minIdentifierCount` descendants with a
778    /// non-empty `accessibilityIdentifier` NOT in the interactive-probe
779    /// ignore list. Fires `launchAppReachedInteractive` when found; on
780    /// timeout fires `launchAppTimedOutBeforeInteractive`. Both counters
781    /// live on [`SessionLifecycleCounters`].
782    ///
783    /// `None` = fire-and-return (no interactive polling). Consumer config
784    /// `.smix/config.yaml interactiveProbe: { minIdentifierCount, ignore: [...] }`
785    /// forwards to the runner via `TEST_RUNNER_SMIX_INTERACTIVE_PROBE_JSON`
786    /// at boot; defaults are `minIdentifierCount: 3` and
787    /// `ignore: ["SplashScreenLogo"]`.
788    #[serde(default)]
789    pub wait_for_interactive_ms: Option<u64>,
790}
791
792/// Response for `POST /session/terminate-app` and
793/// `POST /session/launch-app`.
794#[derive(Clone, Debug, Default, Serialize, Deserialize)]
795#[serde(rename_all = "camelCase")]
796#[non_exhaustive]
797pub struct SessionAppLifecycleResponse {
798    /// True on success.
799    pub ok: bool,
800    /// Wall-clock milliseconds the terminate or launch took.
801    #[serde(default)]
802    pub wall_ms: u64,
803    /// Wall-clock milliseconds spent inside the wait-for-foreground
804    /// loop. Zero when the caller passed `wait_for_foreground_ms: 0`
805    /// or when the runner does not implement the wait.
806    #[serde(default)]
807    pub waited_ms: u64,
808    /// Final observed `XCUIApplication.state` at the moment the runner
809    /// returned. Wire-encoded as the raw `Int` value XCTest uses:
810    /// `0` unknown, `1` notRunning, `2` runningBackgroundSuspended,
811    /// `3` runningBackground, `4` runningForeground. Zero when the
812    /// caller opted out or the runner does not implement the wait.
813    #[serde(default)]
814    pub terminal_state: u8,
815    /// Set when the runner observed cooperative
816    /// termination (`XCUIApplication.terminate()` observed
817    /// `.notRunning` before timeout). `false` on `terminate-app` when
818    /// the runner had to fall back to a hard signal.
819    /// Only meaningful on `terminate-app` responses; `false` on
820    /// `launch-app`.
821    #[serde(default)]
822    pub terminated_cooperatively: bool,
823    /// Set on `launch-app` responses when the
824    /// interactive-probe (≥ `minIdentifierCount` non-empty ax-ids
825    /// outside the ignore list) fired inside `wait_for_interactive_ms`.
826    /// `false` when the caller didn't set `wait_for_interactive_ms`
827    /// OR when polling timed out without observing the interactive
828    /// fingerprint.
829    #[serde(default)]
830    pub reached_interactive: bool,
831    /// Sample of up to 8 `accessibilityIdentifier`
832    /// values observed at the moment `reached_interactive` fired.
833    /// Non-normative; useful for consumers debugging "did my
834    /// interactive probe fire on the right screen". Empty when
835    /// `reached_interactive == false` or the caller didn't set
836    /// `wait_for_interactive_ms`.
837    #[serde(default)]
838    pub interactive_named_ids: Vec<String>,
839}
840
841/// One entry in `POST /session/list` response.
842#[derive(Clone, Debug, Serialize, Deserialize)]
843#[serde(rename_all = "camelCase")]
844pub struct SessionSummary {
845    /// Session id.
846    pub session_id: String,
847    /// Bundle id the session's XCUIApplication is bound to.
848    pub bundle_id: String,
849    /// Epoch millis of open time.
850    #[serde(default)]
851    pub opened_at_ms: u64,
852    /// Epoch millis of last `.activate()` on this session (renew or
853    /// open). Distinct from the runner-internal `lastAccessedAt` used
854    /// by the idle-close sweep.
855    #[serde(default)]
856    pub last_activated_at_ms: u64,
857    /// Last non-empty interactive-probe sample of named accessibility
858    /// ids seen for this session's app. The runner sent this on every
859    /// list/diagnostic entry (and this crate's own docs referenced it)
860    /// while the struct lacked the field, so serde silently dropped it.
861    #[serde(default)]
862    pub interactive_named_ids: Vec<String>,
863}
864
865/// `POST /session/list` response body.
866#[derive(Clone, Debug, Default, Serialize, Deserialize)]
867#[serde(rename_all = "camelCase")]
868pub struct SessionListResponse {
869    /// Every session currently known to the runner.
870    #[serde(default)]
871    pub sessions: Vec<SessionSummary>,
872}
873
874/// One entry in the subprocess ring buffer.
875#[derive(Clone, Debug, Serialize, Deserialize)]
876#[serde(rename_all = "camelCase")]
877pub struct SubprocessRecord {
878    /// argv passed to the invoked binary.
879    pub argv: Vec<String>,
880    /// Exit code; `None` when the process failed to spawn.
881    #[serde(default)]
882    pub exit_code: Option<i32>,
883    /// Wall-clock milliseconds.
884    #[serde(default)]
885    pub wall_ms: u64,
886    /// First 256 bytes of stderr.
887    #[serde(default)]
888    pub stderr_head: String,
889    /// Epoch millis when the invocation completed.
890    #[serde(default)]
891    pub timestamp_ms: u64,
892}
893
894/// App-alive cache observability counters.
895/// Always emitted (even when the runner was booted without an
896/// `appAliveCache` — `wired: false` sentinel + all-zero counters).
897/// Consumers use `wired` to distinguish "workload never fired the
898/// re-probe path" from "runner version predates this observability".
899#[derive(Clone, Copy, Debug, Default, Serialize, Deserialize)]
900#[serde(rename_all = "camelCase")]
901#[non_exhaustive]
902pub struct AliveCacheCounters {
903    /// `false` sentinel — either the runner omits the field entirely
904    /// or it was booted without a cache. When `true`, the field values
905    /// reflect an actual live cache.
906    #[serde(default)]
907    pub wired: bool,
908    /// Total calls to `AppAliveCache::markDead`.
909    #[serde(default)]
910    pub mark_dead_total: u64,
911    /// Total calls to `AppAliveCache::markAlive` (explicit + re-probe-success).
912    #[serde(default)]
913    pub mark_alive_total: u64,
914    /// `isSuppressed` returned true — a call was short-circuited.
915    #[serde(default)]
916    pub suppress_hit_total: u64,
917    /// `isSuppressed` returned false — a call was allowed through.
918    #[serde(default)]
919    pub suppress_miss_total: u64,
920    /// Re-probe Task spawned.
921    #[serde(default)]
922    pub reprobe_attempted_total: u64,
923    /// Re-probe observed target return to running.
924    #[serde(default)]
925    pub reprobe_succeeded_total: u64,
926    /// Re-probe was invalidated mid-flight by external markAlive.
927    #[serde(default)]
928    pub reprobe_invalidated_early: u64,
929    /// Re-probe ran all 6 iterations without recovery.
930    #[serde(default)]
931    pub reprobe_exhausted_window: u64,
932}
933
934/// Cumulative session lifecycle counters.
935///
936/// Unlike the instantaneous `sessions: Vec<SessionSummary>` view,
937/// these are advanced on every mutation and survive `close`. The split
938/// `terminate_app_via_xcuiapplication` / `terminate_app_via_fallback`
939/// counters answer "did clearAppData actually use the cooperative
940/// pathway or fall back to SIGKILL".
941#[derive(Clone, Copy, Debug, Default, Serialize, Deserialize)]
942#[serde(rename_all = "camelCase")]
943#[non_exhaustive]
944pub struct SessionLifecycleCounters {
945    /// Total `POST /session/open` outcomes.
946    #[serde(default)]
947    pub opened_total: u64,
948    /// Total `POST /session/close` outcomes (idempotent — includes closes
949    /// of unknown sessions).
950    #[serde(default)]
951    pub closed_total: u64,
952    /// Total `POST /session/relaunch-app` outcomes.
953    #[serde(default)]
954    pub relaunch_app_total: u64,
955    /// Total `POST /session/terminate-app` outcomes.
956    #[serde(default)]
957    pub terminate_app_total: u64,
958    /// Terminate calls that observed the target `.notRunning` before
959    /// the XCUIApplication timeout — cooperative pathway succeeded.
960    #[serde(default)]
961    pub terminate_app_via_xcuiapplication: u64,
962    /// Terminate calls where XCUIApplication timed out and the runner
963    /// fell back to a hard-kill pathway. `> 0` is the smoking gun for
964    /// a non-cooperative teardown.
965    #[serde(default)]
966    pub terminate_app_via_fallback: u64,
967    /// Total `POST /session/launch-app` outcomes.
968    #[serde(default)]
969    pub launch_app_total: u64,
970    /// Launch calls that observed `.runningForeground` inside the
971    /// requested `wait_for_foreground_ms` window.
972    #[serde(default)]
973    pub launch_app_reached_foreground: u64,
974    /// Launch calls that timed out waiting for foreground.
975    #[serde(default)]
976    pub launch_app_timed_out_before_foreground: u64,
977    /// Total `resetAppData` verbs dispatched (URL-scheme JS-wipe
978    /// pattern, separate from `clearAppData`'s container-wipe pathway).
979    #[serde(default)]
980    pub reset_app_data_total: u64,
981    /// resetAppData calls where the completion
982    /// signal (log-line pattern match on the metro log tail) never
983    /// arrived inside the timeout window. `> 0` = the URL fired but
984    /// the app didn't emit the expected `[dev] reset-complete` line.
985    #[serde(default)]
986    pub reset_app_data_timed_out: u64,
987    /// Launch calls that observed the
988    /// interactive fingerprint (`≥ minIdentifierCount` non-ignored
989    /// ax-ids in the a11y tree) inside `wait_for_interactive_ms`.
990    /// Distinct from `launch_app_reached_foreground` which only
991    /// tracks process state, not tree probeability.
992    #[serde(default)]
993    pub launch_app_reached_interactive: u64,
994    /// Launch calls that timed out waiting for the interactive
995    /// fingerprint. Non-zero = "process foreground but tree unusable"
996    /// — typically a splash screen whose descendants are all unnamed.
997    #[serde(default)]
998    pub launch_app_timed_out_before_interactive: u64,
999}
1000
1001/// Per-flow attempt attribution. Populated by
1002/// `smix run --retry <N>` (default 1) as each flow completes; carries
1003/// through `/diagnostic/dump` so consumers can attribute a `.ips` to
1004/// a specific retry rather than a whole batch.
1005#[derive(Clone, Debug, Default, Serialize, Deserialize)]
1006#[serde(rename_all = "camelCase")]
1007#[non_exhaustive]
1008pub struct FlowAttemptRecord {
1009    /// Flow name (yaml basename or explicit id).
1010    #[serde(default)]
1011    pub flow_name: String,
1012    /// Ordered attempts. Retry #0 is the first try; retry #1 is
1013    /// second; etc.
1014    #[serde(default)]
1015    pub attempts: Vec<FlowAttempt>,
1016}
1017
1018/// One attempt within a flow (see [`FlowAttemptRecord`]).
1019#[derive(Clone, Debug, Default, Serialize, Deserialize)]
1020#[serde(rename_all = "camelCase")]
1021#[non_exhaustive]
1022pub struct FlowAttempt {
1023    /// Zero-based retry index. `0` = first try.
1024    #[serde(default)]
1025    pub attempt_index: u32,
1026    /// Overall outcome: "ok" / "timeout" / "error" / "crashed".
1027    #[serde(default)]
1028    pub status: String,
1029    /// Free-form error class code when non-ok (e.g., "TIMEOUT",
1030    /// "DRIVER_ERROR", "APP_UNAVAILABLE"). Empty on success.
1031    #[serde(default)]
1032    pub error_class: Option<String>,
1033    /// If a `.ips` file appeared under `~/Library/Logs/DiagnosticReports/`
1034    /// during this attempt's window, its filename. Consumers grep for
1035    /// this to tie a crash-report to a specific retry.
1036    #[serde(default)]
1037    pub ips_generated: Option<String>,
1038    /// Wall-clock milliseconds this attempt ran.
1039    #[serde(default)]
1040    pub wall_ms: u64,
1041}
1042
1043/// `POST /diagnostic/dump` response body.
1044///
1045/// Snapshot of the runner's runtime state: recent subprocess
1046/// invocations, currently-open sessions, sim health state, supervisor
1047/// pid, plus the always-emitted app-alive cache + cumulative session
1048/// lifecycle counters that answer "is the observability instrumented?"
1049/// as a numeric question, not a grep for a log line that may or may
1050/// not survive a supervisor cycle.
1051#[derive(Clone, Debug, Default, Serialize, Deserialize)]
1052#[serde(rename_all = "camelCase")]
1053#[non_exhaustive]
1054pub struct DiagnosticDumpResponse {
1055    /// Recent `xcrun simctl` invocations recorded by the runner's
1056    /// subprocess ring buffer. Ordered oldest → newest, capped at 128.
1057    #[serde(default)]
1058    pub recent_subprocesses: Vec<SubprocessRecord>,
1059    /// Every session currently known to the runner (mirrors
1060    /// `/session/list`; embedded here for one-shot dump).
1061    #[serde(default)]
1062    pub sessions: Vec<SessionSummary>,
1063    /// Coarse sim-health classification at dump time.
1064    #[serde(default)]
1065    pub sim_health: String,
1066    /// Pid of the supervisor sidecar, when spawned via
1067    /// `smix runner up --supervise`.
1068    #[serde(default)]
1069    pub supervisor_pid: Option<u32>,
1070    /// Runner wall-clock uptime in milliseconds.
1071    #[serde(default)]
1072    pub uptime_ms: u64,
1073    /// Always-present. `wired=false` when the runner didn't have an
1074    /// `AppAliveCache` wired at boot (opt-out) or doesn't implement
1075    /// this observability. When `wired=true`, the counter
1076    /// fields reflect an actual live cache — even all-zero is
1077    /// meaningful ("workload didn't fire the re-probe path").
1078    #[serde(default)]
1079    pub alive_cache: AliveCacheCounters,
1080    /// Cumulative session lifecycle counters. Advance on every
1081    /// mutation, survive close, and are persisted alongside the
1082    /// session-persistence file so `smix runner cycle` doesn't wipe
1083    /// them.
1084    #[serde(default)]
1085    pub session_counters: SessionLifecycleCounters,
1086    /// Trailing N lines from the external
1087    /// metro log the CLI was told to tail (via `--metro-log <path>`
1088    /// on `smix run` or `smix diagnostic dump`). Empty when the CLI
1089    /// was not given a log path OR when the file was unreadable at
1090    /// dump time. Default N is 200 lines; consumer overrides with
1091    /// `--metro-log-tail-lines <N>`. Purely additive — a consumer
1092    /// ignoring this field sees no behavior change.
1093    #[serde(default)]
1094    pub metro_log_tail: Vec<String>,
1095    /// Retry-attribution roll-up. Populated by
1096    /// `smix run` when it has driven at least one flow. Each entry
1097    /// carries per-attempt status + errorClass + any `.ips` generated
1098    /// so consumers can tell "flow X passed on retry 2 after retry 1
1099    /// crashed" from "flow X passed on the first try". Empty when no
1100    /// flow context available at dump time.
1101    #[serde(default)]
1102    pub recent_flows: Vec<FlowAttemptRecord>,
1103    /// Most-recent `interactiveNamedIds` sample observed
1104    /// across all `launchApp` completions since runner boot. Survives
1105    /// session teardown so post-batch triage can see WHICH ax-ids
1106    /// triggered `reachedInteractive` on the last observed launch,
1107    /// not just the count. Empty when no launch this run.
1108    ///
1109    /// Per-session values remain on `sessions[n].interactiveNamedIds`
1110    /// but go with the session at teardown. This top-level field is
1111    /// the last-values-standing for post-mortem observation.
1112    #[serde(default)]
1113    pub last_interactive_named_ids: Vec<String>,
1114}
1115
1116/// Session state exposed to SDK consumers via the `X-Sim-Health`
1117/// response header on every runner response.
1118///
1119/// Purely additive — consumers that ignore the header are unaffected;
1120/// consumers that read it get `Degraded` / `Dead` / `Cycling`
1121/// transitions without polling.
1122#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
1123#[non_exhaustive]
1124pub enum SimHealthWireState {
1125    /// All watched signals inside envelope.
1126    #[serde(rename = "healthy")]
1127    Healthy,
1128    /// At least one signal degraded (screenshot p95 slow, /health
1129    /// stale, etc.).
1130    #[serde(rename = "degraded")]
1131    Degraded,
1132    /// Runner is mid-cycle (supervisor auto-restart in progress).
1133    /// Callers should back off until the next `Healthy` transition.
1134    #[serde(rename = "cycling")]
1135    Cycling,
1136    /// Runner or a watched subprocess (SimRenderServer / xcodebuild)
1137    /// is gone. Callers should bail.
1138    #[serde(rename = "dead")]
1139    Dead,
1140}
1141
1142impl SimHealthWireState {
1143    /// Parse a case-insensitive wire string; returns `None` on
1144    /// unknown values so unrecognized future states don't panic.
1145    pub fn from_header(value: &str) -> Option<Self> {
1146        match value.trim().to_ascii_lowercase().as_str() {
1147            "healthy" => Some(Self::Healthy),
1148            "degraded" => Some(Self::Degraded),
1149            "cycling" => Some(Self::Cycling),
1150            "dead" => Some(Self::Dead),
1151            _ => None,
1152        }
1153    }
1154
1155    /// Header string emitted on the runner side.
1156    pub fn as_header(&self) -> &'static str {
1157        match self {
1158            Self::Healthy => "healthy",
1159            Self::Degraded => "degraded",
1160            Self::Cycling => "cycling",
1161            Self::Dead => "dead",
1162        }
1163    }
1164}