Skip to main content

leviath_cli/commands/serve/
types.rs

1//! Shared types: ServerEvent, AppState, request/response structs, error types.
2
3use std::collections::HashMap;
4use std::path::PathBuf;
5use std::sync::Arc;
6
7use clap::Args;
8use serde::{Deserialize, Serialize};
9use tokio::sync::broadcast;
10
11use crate::config::Config;
12
13// ─── CLI ─────────────────────────────────────────────────────────────────────
14
15#[derive(Args)]
16pub struct ServeArgs {
17    /// Port to listen on
18    #[arg(short, long, default_value = "3000")]
19    pub port: u16,
20
21    /// Host to bind to
22    #[arg(short = 'H', long, default_value = "127.0.0.1")]
23    pub host: String,
24
25    /// Allow browser requests from this origin (e.g. `http://localhost:5173`).
26    ///
27    /// Defaults to **none**: the API is for programmatic clients, which are not
28    /// subject to CORS at all, so a browser-facing default of `*` gave nothing
29    /// to the normal case and widened the surface for the unusual one. A
30    /// dashboard served from another origin sets this explicitly.
31    ///
32    /// `*` is still accepted and still means "any origin". It is now a decision
33    /// someone typed rather than what you get by not thinking about it.
34    #[arg(long)]
35    pub cors: Option<String>,
36
37    /// API token clients must present (`Authorization: Bearer <token>`, or
38    /// `?token=` for WebSockets). Overrides the LEVIATH_API_TOKEN env var; the
39    /// server refuses to start if neither is set.
40    ///
41    /// Prefer the environment variable: an argument is visible in `ps` to every
42    /// local user for the lifetime of the process.
43    #[arg(long)]
44    pub token: Option<String>,
45
46    /// Enable the MCP administration endpoints (`POST`/`DELETE
47    /// /api/mcp/servers`).
48    ///
49    /// **Off by default, because they are remote code execution by
50    /// construction.** Adding an MCP server writes a `command` and `args` into
51    /// `~/.leviath/config.toml`, and Leviath then spawns exactly that - so any
52    /// token holder could run an arbitrary process, persistently, for every
53    /// future run. The rest of the API can only run agents the user already
54    /// installed; this one adds new executables to the machine.
55    #[arg(long)]
56    pub allow_admin: bool,
57
58    /// Restrict agent working directories to this root.
59    ///
60    /// Without it, `POST /api/agents` accepts any `workdir` - including `/` -
61    /// so a token holder can point a tool-executing agent at the whole
62    /// filesystem. Set this to the directory the API is meant to work in.
63    #[arg(long)]
64    pub workdir_root: Option<PathBuf>,
65
66    /// Refuse `"yolo": true` on spawn requests, so an API caller cannot waive
67    /// every approval prompt for an agent running on the host.
68    #[arg(long)]
69    pub no_remote_yolo: bool,
70}
71
72// ─── Shared state ────────────────────────────────────────────────────────────
73
74/// Events broadcast to WebSocket subscribers.
75#[derive(Debug, Clone, Serialize)]
76#[serde(tag = "type", rename_all = "snake_case")]
77pub enum ServerEvent {
78    AgentStatus {
79        agent_id: String,
80        run_id: String,
81        status: String,
82        stage: String,
83        iteration: usize,
84        #[serde(default)]
85        tool_calls: usize,
86        accepts_messages: bool,
87    },
88    ContextUpdate {
89        agent_id: String,
90        run_id: String,
91        total_tokens: usize,
92        max_tokens: usize,
93    },
94    Log {
95        agent_id: String,
96        run_id: String,
97        line: String,
98    },
99    InteractionNeeded {
100        agent_id: String,
101        run_id: String,
102        request: serde_json::Value,
103    },
104    AgentSpawned {
105        agent_id: String,
106        run_id: String,
107        parent_id: Option<String>,
108        blueprint: String,
109    },
110    AgentCompleted {
111        agent_id: String,
112        run_id: String,
113        status: String,
114        result: Option<String>,
115    },
116    Tokens {
117        agent_id: String,
118        run_id: String,
119        prompt_tokens: usize,
120        completion_tokens: usize,
121        #[serde(default)]
122        cached_tokens: usize,
123        #[serde(default)]
124        cache_write_tokens: usize,
125    },
126    /// A world event with no dedicated WebSocket translation (stage
127    /// transitions, tool call start/finish, and whatever the runtime adds
128    /// next), forwarded verbatim. `event` is the runtime's own serde-tagged
129    /// [`WorldEvent`](leviath_runtime::host::WorldEvent) JSON, so clients get
130    /// new event kinds without a server release.
131    World { event: serde_json::Value },
132}
133
134impl ServerEvent {
135    /// The run id this event belongs to, for per-run subscription filtering.
136    /// `World` events read it from the wrapped JSON (every runtime event
137    /// carries one; an absent field filters as the empty string).
138    pub fn run_id(&self) -> &str {
139        match self {
140            ServerEvent::AgentStatus { run_id, .. }
141            | ServerEvent::ContextUpdate { run_id, .. }
142            | ServerEvent::Log { run_id, .. }
143            | ServerEvent::InteractionNeeded { run_id, .. }
144            | ServerEvent::AgentSpawned { run_id, .. }
145            | ServerEvent::AgentCompleted { run_id, .. }
146            | ServerEvent::Tokens { run_id, .. } => run_id,
147            ServerEvent::World { event } => event
148                .get("run_id")
149                .and_then(|v| v.as_str())
150                .unwrap_or_default(),
151        }
152    }
153}
154
155#[derive(Clone)]
156pub struct AppState {
157    pub(super) config: Arc<Config>,
158    pub(super) event_tx: broadcast::Sender<ServerEvent>,
159    /// Client for the shared-world daemon's control socket. Agent actions
160    /// (spawn/cancel/message/interactions) go through this; read endpoints still
161    /// observe the runs dir the daemon persists to.
162    pub(super) control: leviath_runtime::control_socket::ControlClient,
163    /// Paths + seams for the MCP management endpoints.
164    pub(super) mcp: super::mcp::McpAdmin,
165    /// The spawn-request restrictions from [`ServeArgs`], resolved once at
166    /// startup so every handler reads the same decision.
167    pub(super) limits: Arc<ServeLimits>,
168}
169
170/// What this server refuses regardless of who is asking.
171///
172/// A valid API token proves the caller is allowed to *use* the server; it does
173/// not mean they should be able to reconfigure the machine or point an agent at
174/// the filesystem root. These are the operator's answers to that, fixed at
175/// startup rather than negotiable per request.
176///
177/// `--allow-admin` is deliberately absent: it decides whether a route is
178/// *mounted at all*, so it is consumed once at router construction rather than
179/// carried here for a handler to consult. An unmounted route 404s; a mounted one
180/// guarded by a field is one refactor away from being reachable.
181#[derive(Debug, Clone, Default)]
182pub(super) struct ServeLimits {
183    /// `--workdir-root`: the directory agent workdirs must sit under.
184    pub(super) workdir_root: Option<PathBuf>,
185    /// `--no-remote-yolo`: whether a spawn request may set `"yolo": true`.
186    pub(super) no_remote_yolo: bool,
187    /// `[security] allow_local_network`: whether a completion webhook may point
188    /// at loopback, private or link-local addresses.
189    pub(super) allow_local_network: bool,
190}
191
192impl ServeLimits {
193    /// Check a requested agent workdir against `--workdir-root`.
194    ///
195    /// Uses the same symlink-aware containment the file tools use, so a symlink
196    /// under the root cannot be used to point an agent outside it.
197    /// Check a requested completion webhook against the same SSRF policy every
198    /// model-supplied URL goes through.
199    ///
200    /// The URL arrives in a `POST /api/agents` body, is persisted, and is POSTed
201    /// to when the run finishes - from inside the trust boundary, and with
202    /// retries. Unchecked, `"callback_url": "http://169.254.169.254/…"` made the
203    /// daemon a repeatable request primitive against the cloud metadata service
204    /// and anything else on the local network, on behalf of a caller that
205    /// `--workdir-root` and `--no-remote-yolo` exist to keep at arm's length.
206    ///
207    /// `allow_local_network` mirrors the config setting: an operator who
208    /// deliberately points webhooks at a service on the same host can, and
209    /// everyone else cannot.
210    pub(super) fn check_callback_url(&self, url: &str) -> Result<(), String> {
211        let parsed = url
212            .parse::<url::Url>()
213            .map_err(|e| format!("callback_url is not a URL: {e}"))?;
214        leviath_core::check_url(&parsed, self.allow_local_network)
215            .map_err(|e| format!("callback_url is not allowed: {e}"))
216    }
217
218    pub(super) fn check_workdir(&self, workdir: &std::path::Path) -> Result<(), String> {
219        let Some(root) = &self.workdir_root else {
220            return Ok(());
221        };
222        match leviath_core::resolves_within(workdir, root) {
223            true => Ok(()),
224            false => Err(format!(
225                "workdir '{}' is outside the configured --workdir-root '{}'",
226                workdir.display(),
227                root.display()
228            )),
229        }
230    }
231}
232
233// ─── Error response ─────────────────────────────────────────────────────────
234
235#[derive(Debug, Serialize)]
236pub(super) struct ErrorResponse {
237    pub(super) error: String,
238}
239
240/// Build a `(status, JSON error)` response tuple.
241pub(super) fn err(
242    code: axum::http::StatusCode,
243    message: String,
244) -> (axum::http::StatusCode, axum::response::Json<ErrorResponse>) {
245    (code, axum::response::Json(ErrorResponse { error: message }))
246}
247
248// ─── Blueprint types ────────────────────────────────────────────────────────
249
250#[derive(Debug, Serialize)]
251pub(super) struct BlueprintInfo {
252    pub(super) name: String,
253    pub(super) version: String,
254    pub(super) description: String,
255    pub(super) path: String,
256    pub(super) stages: Vec<String>,
257}
258
259#[derive(Deserialize)]
260pub(super) struct CreateBlueprintReq {
261    pub(super) name: String,
262    pub(super) manifest: String,
263}
264
265#[derive(Deserialize)]
266pub(super) struct UpdateBlueprintReq {
267    pub(super) manifest: String,
268}
269
270#[derive(Deserialize)]
271pub(super) struct ValidateBlueprintReq {
272    pub(super) manifest: String,
273}
274
275#[derive(Serialize, Deserialize)]
276pub(super) struct ValidateResponse {
277    pub(super) valid: bool,
278    #[serde(skip_serializing_if = "Option::is_none")]
279    pub(super) errors: Option<Vec<String>>,
280    /// Lint findings that do not make the blueprint invalid: fields left to a
281    /// default, a stage that can block on a human, a broad `[read_paths]`
282    /// entry. Absent when there are none.
283    #[serde(default, skip_serializing_if = "Option::is_none")]
284    pub(super) warnings: Option<Vec<String>>,
285}
286
287impl ValidateResponse {
288    /// The response for a manifest that did not parse or did not validate.
289    pub(super) fn invalid(errors: Vec<String>) -> Self {
290        Self {
291            valid: false,
292            errors: Some(errors),
293            warnings: None,
294        }
295    }
296}
297
298// ─── Agent types ────────────────────────────────────────────────────────────
299
300#[derive(Default, Deserialize)]
301pub(super) struct SpawnAgentReq {
302    pub(super) blueprint: String,
303    pub(super) task: String,
304    pub(super) model: Option<String>,
305    /// Override the blueprint's max sub-agent tree depth.
306    pub(super) max_depth: Option<usize>,
307    /// Approve every tool call for this run.
308    #[serde(default)]
309    pub(super) yolo: bool,
310    /// Tools to allow outright for this run.
311    #[serde(default)]
312    pub(super) allow: Vec<String>,
313    /// Refuse this run's `seed = { command = ... }` regions, which would
314    /// otherwise execute at spawn before any approval prompt.
315    #[serde(default)]
316    pub(super) no_seed_commands: bool,
317    pub(super) workdir: Option<String>,
318    /// Literal seed content for named caller-input regions, keyed by region name.
319    #[serde(default)]
320    pub(super) regions: HashMap<String, String>,
321    #[serde(default)]
322    pub(super) metadata: HashMap<String, String>,
323    pub(super) callback_url: Option<String>,
324    /// Optional shared secret; when set, completion webhooks carry an
325    /// `X-Leviath-Signature: sha256=<hex>` HMAC of the body keyed on this secret.
326    pub(super) callback_secret: Option<String>,
327}
328
329#[derive(Serialize, Debug)]
330pub(super) struct SpawnAgentResp {
331    pub(super) agent_id: String,
332    pub(super) run_id: String,
333}
334
335#[derive(Deserialize)]
336pub(super) struct ListAgentsQuery {
337    pub(super) status: Option<String>,
338}
339
340#[derive(Serialize)]
341pub(super) struct AgentResultResp {
342    pub(super) run_id: String,
343    pub(super) status: String,
344    pub(super) output: String,
345    pub(super) error: Option<String>,
346    pub(super) prompt_tokens: usize,
347    pub(super) completion_tokens: usize,
348}
349
350#[derive(Deserialize)]
351pub(super) struct LogsQuery {
352    pub(super) tail: Option<u64>,
353}
354
355// ─── Tree types ─────────────────────────────────────────────────────────────
356
357#[derive(Serialize)]
358pub(super) struct AgentTreeNode {
359    pub(super) run_id: String,
360    pub(super) agent_name: String,
361    pub(super) status: String,
362    pub(super) stage: String,
363    pub(super) iteration: usize,
364    pub(super) prompt_tokens: usize,
365    pub(super) completion_tokens: usize,
366    pub(super) children: Vec<AgentTreeNode>,
367}
368
369#[derive(Debug, Serialize)]
370pub(super) struct TreeStatusNode {
371    pub(super) run_id: String,
372    pub(super) agent_name: String,
373    pub(super) status: String,
374    pub(super) stage: String,
375    pub(super) prompt_tokens: usize,
376    pub(super) completion_tokens: usize,
377    pub(super) subtree_prompt_tokens: usize,
378    pub(super) subtree_completion_tokens: usize,
379    pub(super) children: Vec<TreeStatusNode>,
380}
381
382// ─── Interaction types ──────────────────────────────────────────────────────
383
384#[derive(Deserialize)]
385pub(super) struct SubmitInteractionReq {
386    pub(super) request_id: String,
387    pub(super) value: Option<String>,
388    pub(super) choice_index: Option<usize>,
389    pub(super) approved: Option<bool>,
390    pub(super) scope: Option<String>,
391}
392
393#[derive(Deserialize)]
394pub(super) struct SendMessageReq {
395    pub(super) message: String,
396    #[serde(default)]
397    pub(super) target_region: Option<String>,
398}
399
400// ─── Config types ───────────────────────────────────────────────────────────
401
402#[derive(Serialize, Deserialize)]
403pub(super) struct RedactedConfig {
404    pub(super) default_provider: String,
405    pub(super) has_anthropic_key: bool,
406    pub(super) has_openai_key: bool,
407    pub(super) has_google_key: bool,
408    pub(super) has_openrouter_key: bool,
409    pub(super) ollama_base_url: Option<String>,
410    pub(super) agent_paths: Vec<PathBuf>,
411    pub(super) mcp_server_count: usize,
412}
413
414/// Body of `PUT /api/config` (admin-only). Every field is optional; a present
415/// field is written, an absent one is left untouched. Mirrors what `lev setup`
416/// writes, so a newcomer can configure providers entirely from the browser.
417#[derive(Debug, Default, Deserialize)]
418pub(super) struct WriteConfigReq {
419    pub(super) default_provider: Option<String>,
420    pub(super) default_model: Option<String>,
421    pub(super) anthropic_key: Option<String>,
422    pub(super) openai_key: Option<String>,
423    pub(super) google_key: Option<String>,
424    pub(super) openrouter_key: Option<String>,
425    pub(super) ollama_base_url: Option<String>,
426}
427
428/// Body of `POST /api/config/validate` — a format-only key check (no network,
429/// no persistence), mirroring the `lev setup` wizard's inline validation.
430#[derive(Debug, Deserialize)]
431pub(super) struct ValidateKeyReq {
432    pub(super) provider: String,
433    pub(super) key: String,
434}
435
436#[derive(Debug, Serialize, Deserialize)]
437pub(super) struct ValidateKeyResp {
438    pub(super) valid: bool,
439    #[serde(skip_serializing_if = "Option::is_none")]
440    pub(super) message: Option<String>,
441}
442
443#[derive(Serialize)]
444pub(super) struct ModelEntry {
445    pub(super) id: String,
446    pub(super) provider: String,
447    pub(super) display_name: Option<String>,
448    pub(super) max_context_tokens: usize,
449    pub(super) max_output_tokens: usize,
450    pub(super) supports_tools: bool,
451}
452
453#[cfg(test)]
454mod tests {
455    use super::*;
456
457    #[test]
458    fn server_event_agent_status_serialization() {
459        let event = ServerEvent::AgentStatus {
460            agent_id: "coder".to_string(),
461            run_id: "run-123".to_string(),
462            status: "running".to_string(),
463            stage: "implement".to_string(),
464            iteration: 5,
465            tool_calls: 12,
466            accepts_messages: true,
467        };
468        let json = serde_json::to_string(&event).unwrap();
469        assert!(json.contains("\"type\":\"agent_status\""));
470        assert!(json.contains("\"agent_id\":\"coder\""));
471        assert!(json.contains("\"iteration\":5"));
472        assert!(json.contains("\"tool_calls\":12"));
473    }
474
475    #[test]
476    fn server_event_tokens_serialization() {
477        let event = ServerEvent::Tokens {
478            agent_id: "coder".to_string(),
479            run_id: "run-123".to_string(),
480            prompt_tokens: 5000,
481            completion_tokens: 1200,
482            cached_tokens: 200,
483            cache_write_tokens: 100,
484        };
485        let json = serde_json::to_string(&event).unwrap();
486        assert!(json.contains("\"type\":\"tokens\""));
487        assert!(json.contains("\"prompt_tokens\":5000"));
488        assert!(json.contains("\"cached_tokens\":200"));
489        assert!(json.contains("\"cache_write_tokens\":100"));
490    }
491
492    #[test]
493    fn server_event_agent_spawned_serialization() {
494        let event = ServerEvent::AgentSpawned {
495            agent_id: "coder".to_string(),
496            run_id: "run-456".to_string(),
497            parent_id: Some("run-123".to_string()),
498            blueprint: "coder".to_string(),
499        };
500        let json = serde_json::to_string(&event).unwrap();
501        assert!(json.contains("\"type\":\"agent_spawned\""));
502        assert!(json.contains("\"parent_id\":\"run-123\""));
503    }
504
505    #[test]
506    fn server_event_agent_completed_serialization() {
507        let event = ServerEvent::AgentCompleted {
508            agent_id: "coder".to_string(),
509            run_id: "run-123".to_string(),
510            status: "complete".to_string(),
511            result: Some("success".to_string()),
512        };
513        let json = serde_json::to_string(&event).unwrap();
514        assert!(json.contains("\"type\":\"agent_completed\""));
515    }
516
517    #[test]
518    fn server_event_context_update_serialization() {
519        let event = ServerEvent::ContextUpdate {
520            agent_id: "coder".to_string(),
521            run_id: "run-123".to_string(),
522            total_tokens: 10000,
523            max_tokens: 200000,
524        };
525        let json = serde_json::to_string(&event).unwrap();
526        assert!(json.contains("\"type\":\"context_update\""));
527        assert!(json.contains("\"total_tokens\":10000"));
528    }
529
530    #[test]
531    fn server_event_interaction_needed_serialization() {
532        let event = ServerEvent::InteractionNeeded {
533            agent_id: "coder".to_string(),
534            run_id: "run-123".to_string(),
535            request: serde_json::json!({"prompt": "approve?"}),
536        };
537        let json = serde_json::to_string(&event).unwrap();
538        assert!(json.contains("\"type\":\"interaction_needed\""));
539    }
540
541    #[test]
542    fn server_event_log_serialization() {
543        let event = ServerEvent::Log {
544            agent_id: "coder".to_string(),
545            run_id: "run-123".to_string(),
546            line: "doing work".to_string(),
547        };
548        let json = serde_json::to_string(&event).unwrap();
549        assert!(json.contains("\"type\":\"log\""));
550        assert!(json.contains("\"line\":\"doing work\""));
551    }
552
553    #[test]
554    fn validate_response_serde_roundtrip() {
555        let resp = ValidateResponse {
556            valid: true,
557            errors: None,
558            warnings: None,
559        };
560        let json = serde_json::to_string(&resp).unwrap();
561        // Neither list appears at all when there is nothing in it.
562        assert_eq!(json, r#"{"valid":true}"#);
563        let parsed: ValidateResponse = serde_json::from_str(&json).unwrap();
564        assert!(parsed.valid);
565        assert!(parsed.errors.is_none());
566        assert!(parsed.warnings.is_none());
567    }
568
569    #[test]
570    fn validate_response_with_errors_roundtrip() {
571        let resp = ValidateResponse::invalid(vec!["bad field".to_string()]);
572        let json = serde_json::to_string(&resp).unwrap();
573        let parsed: ValidateResponse = serde_json::from_str(&json).unwrap();
574        assert!(!parsed.valid);
575        assert_eq!(parsed.errors.unwrap().len(), 1);
576        assert!(parsed.warnings.is_none());
577    }
578
579    /// A blueprint can be valid and still have something worth saying about it.
580    #[test]
581    fn validate_response_with_warnings_roundtrip() {
582        let resp = ValidateResponse {
583            valid: true,
584            errors: None,
585            warnings: Some(vec!["stage 'main': no max_iterations".to_string()]),
586        };
587        let json = serde_json::to_string(&resp).unwrap();
588        let parsed: ValidateResponse = serde_json::from_str(&json).unwrap();
589        assert!(parsed.valid);
590        assert_eq!(parsed.warnings.unwrap().len(), 1);
591    }
592
593    #[test]
594    fn redacted_config_serde_roundtrip() {
595        let config = RedactedConfig {
596            default_provider: "anthropic".to_string(),
597            has_anthropic_key: true,
598            has_openai_key: false,
599            has_google_key: false,
600            has_openrouter_key: false,
601            ollama_base_url: None,
602            agent_paths: vec![],
603            mcp_server_count: 0,
604        };
605        let json = serde_json::to_string(&config).unwrap();
606        let parsed: RedactedConfig = serde_json::from_str(&json).unwrap();
607        assert_eq!(parsed.default_provider, "anthropic");
608        assert!(parsed.has_anthropic_key);
609        assert!(!parsed.has_openai_key);
610    }
611
612    #[test]
613    fn error_response_serialization() {
614        let err = ErrorResponse {
615            error: "not found".to_string(),
616        };
617        let json = serde_json::to_string(&err).unwrap();
618        assert!(json.contains("\"error\":\"not found\""));
619    }
620
621    #[test]
622    fn server_event_run_id_covers_every_variant() {
623        let cases: Vec<(ServerEvent, &str)> = vec![
624            (
625                ServerEvent::AgentStatus {
626                    agent_id: "a".to_string(),
627                    run_id: "r1".to_string(),
628                    status: "active".to_string(),
629                    stage: "s".to_string(),
630                    iteration: 0,
631                    tool_calls: 0,
632                    accepts_messages: false,
633                },
634                "r1",
635            ),
636            (
637                ServerEvent::ContextUpdate {
638                    agent_id: "a".to_string(),
639                    run_id: "r2".to_string(),
640                    total_tokens: 1,
641                    max_tokens: 2,
642                },
643                "r2",
644            ),
645            (
646                ServerEvent::Log {
647                    agent_id: "a".to_string(),
648                    run_id: "r3".to_string(),
649                    line: "l".to_string(),
650                },
651                "r3",
652            ),
653            (
654                ServerEvent::InteractionNeeded {
655                    agent_id: "a".to_string(),
656                    run_id: "r4".to_string(),
657                    request: serde_json::Value::Null,
658                },
659                "r4",
660            ),
661            (
662                ServerEvent::AgentSpawned {
663                    agent_id: "a".to_string(),
664                    run_id: "r5".to_string(),
665                    parent_id: None,
666                    blueprint: "b".to_string(),
667                },
668                "r5",
669            ),
670            (
671                ServerEvent::AgentCompleted {
672                    agent_id: "a".to_string(),
673                    run_id: "r6".to_string(),
674                    status: "complete".to_string(),
675                    result: None,
676                },
677                "r6",
678            ),
679            (
680                ServerEvent::Tokens {
681                    agent_id: "a".to_string(),
682                    run_id: "r7".to_string(),
683                    prompt_tokens: 0,
684                    completion_tokens: 0,
685                    cached_tokens: 0,
686                    cache_write_tokens: 0,
687                },
688                "r7",
689            ),
690            (
691                ServerEvent::World {
692                    event: serde_json::json!({"event": "stage_transition", "run_id": "r8"}),
693                },
694                "r8",
695            ),
696            // A wrapped event with no run_id filters as the empty string
697            // rather than panicking (real world events always carry one).
698            (
699                ServerEvent::World {
700                    event: serde_json::Value::Null,
701                },
702                "",
703            ),
704        ];
705        for (ev, want) in cases {
706            assert_eq!(ev.run_id(), want);
707        }
708    }
709}