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}