diff --git a/api/openapi.json b/api/openapi.json index 13ed549f..867ab315 100644 --- a/api/openapi.json +++ b/api/openapi.json @@ -10,7 +10,7 @@ "name": "MIT OR Apache-2.0", "identifier": "MIT OR Apache-2.0" }, - "version": "0.22.3" + "version": "0.23.0" }, "paths": { "/api/v1/clients": { @@ -2170,6 +2170,51 @@ } } }, + "/api/v1/plugins/logs": { + "post": { + "tags": [ + "plugins" + ], + "summary": "Ingest runner log lines", + "description": "The plugin/script runner ships its output here so the console's **Logs** page can show it.\n\nPlugins are not host child processes — the runner is a separate `bun` process that `import()`s\neach plugin in-process — so nothing a plugin logs passes through the host's own `tracing`, and\nbefore this endpoint the console's log page could not show a single plugin line. On Linux the\nfallback was `journalctl --user -u punktfunk-scripting`; on Windows the runner task writes no\nlog file at all, so a failing plugin was diagnosable only by stopping the scheduled task and\nre-running the runner by hand. Both are shell access on the host box, which is exactly what the\nconsole exists to avoid.\n\nLines land in the same ring as the host's own, sharing one `seq` cursor, targeted\n`plugin:` — so `GET /logs` needs no second cursor and the console needs no second poll.", + "operationId": "ingestPluginLogs", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PluginLogBatch" + } + } + }, + "required": true + }, + "responses": { + "204": { + "description": "Lines ingested" + }, + "400": { + "description": "Batch too large", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "401": { + "description": "Missing or invalid bearer token", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + } + } + } + }, "/api/v1/plugins/{id}": { "put": { "tags": [ @@ -6238,6 +6283,50 @@ "gamestream" ] }, + "PluginLogBatch": { + "type": "object", + "description": "A batch of runner log lines.", + "required": [ + "entries" + ], + "properties": { + "entries": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PluginLogLine" + } + } + } + }, + "PluginLogLine": { + "type": "object", + "description": "One log line produced by the runner or a plugin inside it (`POST /plugins/logs`).", + "required": [ + "ts_ms", + "level", + "source", + "msg" + ], + "properties": { + "level": { + "type": "string", + "description": "`ERROR` | `WARN` | `INFO` | `DEBUG` | `TRACE`. Anything else is coerced to `INFO`." + }, + "msg": { + "type": "string" + }, + "source": { + "type": "string", + "description": "Which unit emitted it — a plugin's `definePlugin` name, a package name, or `runner`.\nSurfaced in the console's target column as `plugin:`." + }, + "ts_ms": { + "type": "integer", + "format": "int64", + "description": "When the line was produced, unix milliseconds. Kept verbatim — see\n[`crate::log_capture::LogRing::push_remote`].", + "minimum": 0 + } + } + }, "PluginRegistration": { "type": "object", "description": "Register/renew body for `PUT /plugins/{id}`.", diff --git a/crates/punktfunk-host/src/log_capture.rs b/crates/punktfunk-host/src/log_capture.rs index f0e489f2..68edbf52 100644 --- a/crates/punktfunk-host/src/log_capture.rs +++ b/crates/punktfunk-host/src/log_capture.rs @@ -77,6 +77,32 @@ impl LogRing { .duration_since(UNIX_EPOCH) .map(|d| d.as_millis() as u64) .unwrap_or(0); + self.push_entry(level.to_string(), target.to_string(), msg, ts_ms); + } + + /// Ingest a line that was produced in **another process** — the plugin/script runner, via + /// `POST /plugins/logs` (see `mgmt::plugins::ingest_plugin_logs`). + /// + /// Plugins are not host child processes: the runner is a separate bun process that `import()`s + /// each plugin in-process, so a plugin's output never passes through this process's `tracing` + /// and [`RingLayer`] can't see it. Without this door the console's log page shows nothing about + /// the plugins at all, and on Windows nothing else does either — the runner task writes no log + /// file, so a failing plugin was diagnosable only by stopping the task and re-running it by + /// hand (field report 2026-08-03, the VirtualHere plugin). + /// + /// The caller's `ts_ms` is kept — the line was stamped when it happened, and re-stamping it on + /// arrival would collapse a whole batch onto the moment it was flushed. `seq` stays ours: it is + /// the cursor for a single ring with several producers, so only the ring can mint it. + pub fn push_remote(&self, level: &str, target: &str, msg: &str, ts_ms: u64) { + self.push_entry( + normalize_level(level).to_string(), + target.to_string(), + truncate_msg(msg.to_string()), + ts_ms, + ); + } + + fn push_entry(&self, level: String, target: String, msg: String, ts_ms: u64) { let mut inner = self.inner.lock().unwrap_or_else(|e| e.into_inner()); let seq = inner.next_seq; inner.next_seq += 1; @@ -86,8 +112,8 @@ impl LogRing { inner.entries.push_back(LogEntry { seq, ts_ms, - level: level.to_string(), - target: target.to_string(), + level, + target, msg, }); } @@ -125,6 +151,33 @@ pub fn ring() -> &'static LogRing { RING.get_or_init(LogRing::new) } +/// Coerce an externally-supplied level to the five the console's filter ranks. Anything else — +/// a plugin inventing `NOTICE`, a truncated line, empty — becomes `INFO` rather than being +/// rejected: an unfamiliar level is not a reason to drop the operator's diagnostics on the floor, +/// and an unranked string would sort as `0` in the console's `RANK` map and hide under every filter. +fn normalize_level(level: &str) -> &'static str { + match level.trim().to_ascii_uppercase().as_str() { + "ERROR" | "FATAL" | "SEVERE" => "ERROR", + "WARN" | "WARNING" => "WARN", + "DEBUG" => "DEBUG", + "TRACE" | "VERBOSE" => "TRACE", + _ => "INFO", + } +} + +/// Cap a message at [`MAX_MSG`], cutting on a char boundary and marking the elision. +fn truncate_msg(mut msg: String) -> String { + if msg.len() > MAX_MSG { + let mut end = MAX_MSG; + while !msg.is_char_boundary(end) { + end -= 1; + } + msg.truncate(end); + msg.push('…'); + } + msg +} + /// Targets whose DEBUG/TRACE output is steady-state chatter, not diagnostics — left in, they evict /// the entire ring tail: `mdns_sd` DEBUG-logs every multicast packet it can't parse (one chatty /// AirPlay/HomePod device on the LAN floods thousands of entries per hour), and `wasapi` DEBUG-logs @@ -223,15 +276,7 @@ impl FieldFmt { } else { self.msg.push_str(&self.fields); } - if self.msg.len() > MAX_MSG { - let mut end = MAX_MSG; - while !self.msg.is_char_boundary(end) { - end -= 1; - } - self.msg.truncate(end); - self.msg.push('…'); - } - self.msg + truncate_msg(self.msg) } } @@ -360,6 +405,45 @@ mod tests { assert!(page.entries.iter().any(|e| e.target == "mdns_sdx")); } + #[test] + fn remote_entries_keep_their_own_timestamp_and_share_the_cursor() { + let ring = LogRing::new(); + ring.push(&tracing::Level::INFO, "punktfunk_host", "local".into()); + ring.push_remote("WARN", "plugin:virtualhere", "remote", 1_700_000_000_123); + + let page = ring.since(0, 10); + assert_eq!(page.entries.len(), 2); + // One sequence across both producers — the console's cursor cannot see two rings. + assert_eq!(page.entries[0].seq, 1); + assert_eq!(page.entries[1].seq, 2); + let remote = &page.entries[1]; + assert_eq!(remote.level, "WARN"); + assert_eq!(remote.target, "plugin:virtualhere"); + assert_eq!(remote.msg, "remote"); + // Stamped when it happened, not when the batch arrived. + assert_eq!(remote.ts_ms, 1_700_000_000_123); + } + + #[test] + fn remote_levels_are_coerced_not_rejected() { + assert_eq!(normalize_level("error"), "ERROR"); + assert_eq!(normalize_level(" Warning "), "WARN"); + assert_eq!(normalize_level("TRACE"), "TRACE"); + // An unranked level would sort as 0 in the console's filter and hide under every setting. + assert_eq!(normalize_level("NOTICE"), "INFO"); + assert_eq!(normalize_level(""), "INFO"); + } + + #[test] + fn remote_messages_are_truncated_like_local_ones() { + let ring = LogRing::new(); + ring.push_remote("INFO", "plugin:x", &"ä".repeat(MAX_MSG), 1); + let page = ring.since(0, 10); + let msg = &page.entries[0].msg; + assert!(msg.ends_with('…')); + assert!(msg.len() <= MAX_MSG + '…'.len_utf8()); + } + #[test] fn message_truncation_keeps_char_boundary() { let f = FieldFmt { diff --git a/crates/punktfunk-host/src/mgmt.rs b/crates/punktfunk-host/src/mgmt.rs index 08cc6844..a1dbf8a3 100644 --- a/crates/punktfunk-host/src/mgmt.rs +++ b/crates/punktfunk-host/src/mgmt.rs @@ -253,6 +253,7 @@ fn api_router_parts() -> (Router>, utoipa::openapi::OpenApi) { .routes(routes!(plugins::list_plugins)) .routes(routes!(plugins::register_plugin, plugins::delete_plugin)) .routes(routes!(plugins::get_ui_credential)) + .routes(routes!(plugins::ingest_plugin_logs)) .routes(routes!(store::get_catalog)) .routes(routes!(store::refresh_catalog)) .routes(routes!(store::list_installed)) diff --git a/crates/punktfunk-host/src/mgmt/plugins.rs b/crates/punktfunk-host/src/mgmt/plugins.rs index 992e857e..c57ab88f 100644 --- a/crates/punktfunk-host/src/mgmt/plugins.rs +++ b/crates/punktfunk-host/src/mgmt/plugins.rs @@ -29,6 +29,12 @@ use std::time::{Duration, Instant}; /// this tolerates two missed ticks before a plugin drops out of the listing. const LEASE_TTL: Duration = Duration::from_secs(90); +/// Lines accepted per `POST /plugins/logs`. The runner batches on a short timer, so a batch this +/// size means a plugin is logging faster than the ring can usefully hold — the shipper drops its +/// own backlog (and says so in a line of its own) rather than letting one chatty plugin evict the +/// whole ring in a single request. +const MAX_LOG_BATCH: usize = 256; + // ---------------------------------------------------------------- wire shapes /// A plugin's UI surface as it registers it. Carries the secret — this shape is only ever a request @@ -60,6 +66,26 @@ pub(crate) struct PluginRegistration { pub ui: Option, } +/// One log line produced by the runner or a plugin inside it (`POST /plugins/logs`). +#[derive(Deserialize, ToSchema)] +pub(crate) struct PluginLogLine { + /// When the line was produced, unix milliseconds. Kept verbatim — see + /// [`crate::log_capture::LogRing::push_remote`]. + pub ts_ms: u64, + /// `ERROR` | `WARN` | `INFO` | `DEBUG` | `TRACE`. Anything else is coerced to `INFO`. + pub level: String, + /// Which unit emitted it — a plugin's `definePlugin` name, a package name, or `runner`. + /// Surfaced in the console's target column as `plugin:`. + pub source: String, + pub msg: String, +} + +/// A batch of runner log lines. +#[derive(Deserialize, ToSchema)] +pub(crate) struct PluginLogBatch { + pub entries: Vec, +} + /// The secret-free view of a plugin's UI surface — what [`list_plugins`] returns to the browser. #[derive(Serialize, ToSchema)] pub(crate) struct PluginUiPublic { @@ -264,6 +290,26 @@ fn sanitize(s: &str) -> String { .to_string() } +/// The console target for a runner-supplied line: `plugin:`. +/// +/// The source is NOT a [`valid_plugin_id`] — the runner names a unit by its `definePlugin` name +/// (`virtualhere`), its package name (`@punktfunk/plugin-virtualhere`), a bare script's file stem, +/// or `runner` for its own supervision lines, and all four are worth telling apart in the log. So +/// this sanitizes rather than validates: control characters go (a log target is rendered in a +/// terminal by `logs download` as readily as in the console), length is capped, and an empty source +/// becomes `runner` so a line is never attributed to nothing. +fn log_target(source: &str) -> String { + let mut s = sanitize(source); + if s.is_empty() { + s = "runner".into(); + } + // Cap on CHARS, not bytes — truncating a multi-byte name mid-sequence would panic. + if s.chars().count() > 64 { + s = s.chars().take(64).collect(); + } + format!("plugin:{s}") +} + /// Validate a registration body into the internal [`Valid`] form, or a human-readable reason. fn validate(reg: PluginRegistration) -> Result { let title = sanitize(®.title); @@ -367,6 +413,63 @@ pub(crate) async fn register_plugin( StatusCode::NO_CONTENT.into_response() } +/// Ingest runner log lines +/// +/// The plugin/script runner ships its output here so the console's **Logs** page can show it. +/// +/// Plugins are not host child processes — the runner is a separate `bun` process that `import()`s +/// each plugin in-process — so nothing a plugin logs passes through the host's own `tracing`, and +/// before this endpoint the console's log page could not show a single plugin line. On Linux the +/// fallback was `journalctl --user -u punktfunk-scripting`; on Windows the runner task writes no +/// log file at all, so a failing plugin was diagnosable only by stopping the scheduled task and +/// re-running the runner by hand. Both are shell access on the host box, which is exactly what the +/// console exists to avoid. +/// +/// Lines land in the same ring as the host's own, sharing one `seq` cursor, targeted +/// `plugin:` — so `GET /logs` needs no second cursor and the console needs no second poll. +#[utoipa::path( + post, + path = "/plugins/logs", + tag = "plugins", + operation_id = "ingestPluginLogs", + request_body = PluginLogBatch, + responses( + (status = NO_CONTENT, description = "Lines ingested"), + (status = BAD_REQUEST, description = "Batch too large", body = ApiError), + (status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError), + ) +)] +pub(crate) async fn ingest_plugin_logs(ApiJson(batch): ApiJson) -> Response { + if batch.entries.len() > MAX_LOG_BATCH { + return api_error( + StatusCode::BAD_REQUEST, + &format!("at most {MAX_LOG_BATCH} entries per batch"), + ); + } + for line in batch.entries { + crate::log_capture::ring().push_remote( + &line.level, + &log_target(&line.source), + &sanitize_msg(&line.msg), + line.ts_ms, + ); + } + StatusCode::NO_CONTENT.into_response() +} + +/// Strip control characters from an ingested message, keeping tabs. +/// +/// Same reasoning as [`sanitize`], one exception wider: a plugin's messages routinely carry a +/// stack trace or a vendor CLI's output, and an embedded newline would let one line forge several +/// in the downloaded log file. Tabs survive because they are load-bearing in that kind of output. +fn sanitize_msg(s: &str) -> String { + s.chars() + .map(|c| if c == '\t' || !c.is_control() { c } else { ' ' }) + .collect::() + .trim_end() + .to_string() +} + /// List registered plugins /// /// The live plugin directory (lease not expired), sorted by title. **Secret-free**: each entry diff --git a/crates/punktfunk-host/src/mgmt/tests.rs b/crates/punktfunk-host/src/mgmt/tests.rs index 256774fe..28c24881 100644 --- a/crates/punktfunk-host/src/mgmt/tests.rs +++ b/crates/punktfunk-host/src/mgmt/tests.rs @@ -620,6 +620,23 @@ async fn plugin_token_lane_is_scoped_and_loopback_only() { StatusCode::NO_CONTENT ); + // Log ingest. This is the ONLY token the scripting runner holds (on Windows its LocalService + // principal cannot even read the admin one), so if this lane ever stopped reaching this route + // the console's plugin logs would go quiet with nothing else failing — pin it here rather than + // rely on `plugin_may_access`'s denylist continuing to not match `/plugins/logs`. + let body = serde_json::json!({"entries": [{ + "ts_ms": 1_700_000_000_000u64, + "level": "INFO", + "source": "virtualhere", + "msg": "hello from the runner", + }]}); + let req = axum::http::Request::post("/api/v1/plugins/logs") + .header("content-type", "application/json") + .header("authorization", "Bearer plugin-secret") + .body(Body::from(body.to_string())) + .unwrap(); + assert_eq!(send(&app, req).await.0, StatusCode::NO_CONTENT); + // The carve-outs answer 403 (authenticated but not authorized), not 401. for (method, path) in [ (Method::GET, "/api/v1/hooks"), @@ -972,6 +989,59 @@ async fn plugin_registry_roundtrip() { assert_eq!(status, StatusCode::BAD_REQUEST); } +/// Runner log ingest: lines reach the same ring `GET /logs` serves, tagged so the console can tell +/// them from the host's own, and one chatty plugin can't evict the ring in a single request. +#[tokio::test] +async fn plugin_log_ingest_lands_in_the_ring() { + let app = test_app(test_state(), None); + let marker = "vh-ingest-marker-3f9a"; + + let (status, _) = send( + &app, + post_json( + "/api/v1/plugins/logs", + serde_json::json!({"entries": [ + {"ts_ms": 1_700_000_000_123u64, "level": "warn", "source": "virtualhere", "msg": marker}, + // No source: attributed to the runner rather than to nothing. + {"ts_ms": 1_700_000_000_124u64, "level": "NOTICE", "source": "", "msg": "orphan"}, + ]}), + ), + ) + .await; + assert_eq!(status, StatusCode::NO_CONTENT); + + let (status, body) = send(&app, get_req("/api/v1/logs?limit=1000")).await; + assert_eq!(status, StatusCode::OK); + let entries = body["entries"].as_array().unwrap(); + + let mine = entries + .iter() + .find(|e| e["msg"] == marker) + .expect("ingested line is served by GET /logs"); + // `plugin:` is what the console's Host/Plugins filter keys on. + assert_eq!(mine["target"], "plugin:virtualhere"); + // Lowercase in, canonical out — the console ranks these five and nothing else. + assert_eq!(mine["level"], "WARN"); + // Stamped when the line happened, not when the batch arrived. + assert_eq!(mine["ts_ms"], 1_700_000_000_123u64); + + let orphan = entries.iter().find(|e| e["msg"] == "orphan").unwrap(); + assert_eq!(orphan["target"], "plugin:runner"); + // An unranked level would sort as 0 in the console's filter and hide under every setting. + assert_eq!(orphan["level"], "INFO"); + + // An oversized batch is refused whole rather than half-ingested. + let big: Vec = (0..300) + .map(|i| serde_json::json!({"ts_ms": 1u64, "level": "INFO", "source": "x", "msg": format!("f{i}")})) + .collect(); + let (status, _) = send( + &app, + post_json("/api/v1/plugins/logs", serde_json::json!({"entries": big})), + ) + .await; + assert_eq!(status, StatusCode::BAD_REQUEST); +} + /// The OpenAPI document lists every route with a unique operationId (codegen relies /// on both), and the checked-in copy is current. #[test] diff --git a/docs-site/content/docs/plugins.mdx b/docs-site/content/docs/plugins.mdx index 567fcd99..9de18f07 100644 --- a/docs-site/content/docs/plugins.mdx +++ b/docs-site/content/docs/plugins.mdx @@ -295,8 +295,23 @@ installer's `PATH` change, or call the exe by full path. On Linux the host packa On SteamOS, re-run `scripts/steamdeck/install.sh` (or `scripts/steamdeck/update.sh`). On Windows, re-run the installer and keep the scripting component. -**The plugin doesn't show up in the console** — check the runner is actually running with -`punktfunk-host plugins status`, then look at its log: +**Where a plugin's log output goes** — the console's **Logs** page, under the **Plugins** filter. +The runner ships everything your plugins print to the host, so a plugin's own lines sit next to the +host's, on one timeline, with the same search and download. Each is tagged `plugin:` — the +plugin's own name for lines it logged itself, `plugin:runner` for the supervisor's (starting a +plugin, restarting a crashed one, refusing an unsafe file). + +An empty Plugins view almost always means the runner isn't running — it is a separate service, and +opt-in on Linux. Check with `punktfunk-host plugins status`. + + +Nothing is lost if the host is down: the runner keeps buffering and sends the backlog when the host +comes back. It says so in the log if the buffer overflowed, rather than presenting a gap as +continuity. + + +**Reading the runner's log directly** — rarely needed now, but it is the ground truth if the runner +can't reach the host at all: @@ -318,6 +333,22 @@ plugins (stop it with Ctrl+C): +**A plugin can't reach a service running on the same box (Linux)** — plugins that drive a local +daemon usually talk to it over a socket or FIFO in `/tmp`. The runner's unit shipped with +`PrivateTmp=yes` in earlier releases, which gave it a private `/tmp` and hid all of it: the plugin would +launch the vendor's binary happily and then time out reaching the daemon behind it, while the same +command worked perfectly in your own shell. If you are on an older host, or you have a drop-in that +reinstates it, put the real `/tmp` back: + +```sh +systemctl --user edit punktfunk-scripting +``` +```ini +[Service] +PrivateTmp=no +ReadWritePaths=/tmp +``` + ## Writing your own A plugin is a TypeScript module built on **`@punktfunk/plugin-kit`** (`definePluginKit`), supervised diff --git a/docs-site/content/docs/troubleshooting.md b/docs-site/content/docs/troubleshooting.md index aa16ecd4..18d03cdc 100644 --- a/docs-site/content/docs/troubleshooting.md +++ b/docs-site/content/docs/troubleshooting.md @@ -360,7 +360,10 @@ Read the host's log around the failed connect or capture. 1. Open the web console's **Logs** page. It always holds the host's recent output at *debug* detail, whatever the log level is set to — there's nothing to switch on and no restart needed. -2. Filter it down to the level or the text you're after. +2. Filter it down to the level or the text you're after. The **Host / Plugins** switch beside the + level buttons picks the producer: your [plugins](/docs/plugins) log to the same page, tagged + `plugin:`, so a misbehaving plugin is one click away rather than a separate hunt through + the journal. 3. Use **Download logs** to save exactly what you're filtering on as a timestamped `.log` file you can attach to a bug report. The button beside it hands the same text to your phone or tablet's share sheet, or copies it to the clipboard on a desktop. diff --git a/docs-site/content/docs/web-console.md b/docs-site/content/docs/web-console.md index cc3ba3a9..c52bd739 100644 --- a/docs-site/content/docs/web-console.md +++ b/docs-site/content/docs/web-console.md @@ -107,8 +107,9 @@ Nine destinations in the sidebar (a **More** tab on a phone holds the last five) title with its own art and launch command. See [Your game library](/docs/game-library). - **Performance** — arm a capture, run a session, stop it, and read the recording back as per-stage latency, throughput and health graphs. -- **Logs** — the host's recent log stream: follow it live, filter by level, search it, and download - or share it for a bug report. +- **Logs** — the host's recent log stream *and your plugins'*: follow it live, filter by level or + producer, search it, and download or share it for a bug report. Plugin lines are tagged + `plugin:` and the **Host / Plugins** switch isolates either side. - **Pairing** — arm a PIN, approve or deny devices waiting for approval, and unpair a device. A second PIN box for [Moonlight/GameStream](/docs/moonlight) clients appears only when this host runs the GameStream plane. diff --git a/scripts/punktfunk-scripting.service b/scripts/punktfunk-scripting.service index 58087f7c..c6bb2dfb 100644 --- a/scripts/punktfunk-scripting.service +++ b/scripts/punktfunk-scripting.service @@ -33,16 +33,24 @@ KillSignal=SIGTERM TimeoutStopSec=30 # Sandbox: free hardening for well-behaved plugins. The filesystem is read-only outside the home # directory (ReadWritePaths keeps plugin state, download dirs, and ~/.config/punktfunk writable); -# /tmp is private; no setuid re-escalation; sockets limited to what automation actually uses -# (loopback mgmt API, LAN/IPv6 webhooks, unix sockets). A plugin that must write OUTSIDE $HOME -# (e.g. a library on another mount) gets a drop-in: +# no setuid re-escalation; sockets limited to what automation actually uses (loopback mgmt API, +# LAN/IPv6 webhooks, unix sockets). A plugin that must write OUTSIDE $HOME (e.g. a library on +# another mount) gets a drop-in: # systemctl --user edit punktfunk-scripting → [Service]\nReadWritePaths=/mnt/games -# NOTE: the mount-namespace options (ProtectSystem/PrivateTmp) need unprivileged user namespaces -# for a *user* unit; on kernels/distros that restrict those, drop them via the same drop-in. +# NOTE: the mount-namespace options (ProtectSystem) need unprivileged user namespaces for a +# *user* unit; on kernels/distros that restrict those, drop them via the same drop-in. +# +# PrivateTmp is deliberately OFF (field report 2026-08-03, the VirtualHere plugin). A plugin's +# whole job is integrating with things already running on this box, and on Linux those talk over +# /tmp: VirtualHere's client IPC is the FIFO pair /tmp/vhclient + /tmp/vhclient_response, and X11 +# is /tmp/.X11-unix. A private /tmp namespace hides all of it — the plugin launches the vendor +# binary fine and then cannot reach the daemon behind it, which presents as an unexplained error +# that no amount of config fixes (the operator's own shell works, because that has the real /tmp). +# ReadWritePaths=/tmp puts the write bit back that ProtectSystem=strict takes away. NoNewPrivileges=yes -PrivateTmp=yes +PrivateTmp=no ProtectSystem=strict -ReadWritePaths=%h +ReadWritePaths=%h /tmp RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6 [Install] diff --git a/sdk/src/gen/punktfunk.ts b/sdk/src/gen/punktfunk.ts index 0b86e27e..f2f65bbd 100644 --- a/sdk/src/gen/punktfunk.ts +++ b/sdk/src/gen/punktfunk.ts @@ -21,6 +21,8 @@ export type ApiGpu = { readonly "id": string, readonly "name": string, readonly export const ApiGpu = Schema.Struct({ "id": Schema.String.annotate({ "description": "Stable identifier (`vendorid-deviceid-occurrence`, hex PCI ids) — pass to `setGpuPreference`.\nStable across reboots and driver updates, unlike an adapter index or LUID." }), "name": Schema.String.annotate({ "description": "Adapter/marketing name." }), "vendor": Schema.String.annotate({ "description": "`nvidia` | `amd` | `intel` | `other`." }), "vram_mb": Schema.Number.annotate({ "description": "Dedicated VRAM in MiB (0 where the platform doesn't expose it).", "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)) }).annotate({ "description": "One hardware GPU on the host (software/WARP adapters are never listed)." }) export type ApiMonitorInfo = { readonly "connector": string, readonly "description": string, readonly "enabled": boolean, readonly "managed": boolean, readonly "mode": string, readonly "primary": boolean, readonly "scale": number, readonly "selected": boolean, readonly "x": number, readonly "y": number } export const ApiMonitorInfo = Schema.Struct({ "connector": Schema.String.annotate({ "description": "Connector name (`DP-1`, `HDMI-A-2`) — the value `PUNKTFUNK_CAPTURE_MONITOR` takes." }), "description": Schema.String.annotate({ "description": "Human label for a picker (`make model`, else the connector)." }), "enabled": Schema.Boolean.annotate({ "description": "Driven right now. A disabled head is still listed, so it can be explained rather than missing." }), "managed": Schema.Boolean.annotate({ "description": "Best-effort: this is one of OUR virtual displays, not a real head (reliable on KWin only)." }), "mode": Schema.String.annotate({ "description": "`WIDTHxHEIGHT@HZ` of the current mode (size only when the refresh is unknown)." }), "primary": Schema.Boolean.annotate({ "description": "The compositor's primary/focused head." }), "scale": Schema.Number.annotate({ "description": "Logical scale factor.", "format": "double" }).check(Schema.isFinite()), "selected": Schema.Boolean.annotate({ "description": "True when `PUNKTFUNK_CAPTURE_MONITOR` currently names this monitor." }), "x": Schema.Number.annotate({ "description": "Desktop-space top-left — what makes a head identifiable when two share a size.", "format": "int32" }).check(Schema.isInt()), "y": Schema.Number.annotate({ "format": "int32" }).check(Schema.isInt()) }).annotate({ "description": "One physical monitor this host has, as the compositor reports it." }) +export type ApplyRequest = { readonly "force"?: boolean } +export const ApplyRequest = Schema.Struct({ "force": Schema.optionalKey(Schema.Boolean.annotate({ "description": "Proceed even while a streaming session is live (the stream will drop when the host\nrestarts — the console warns before sending this)." })) }) export type ApprovePending = { readonly "name"?: string | null } export const ApprovePending = Schema.Struct({ "name": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "Operator-chosen label for the device (defaults to the name it knocked with)." })) }).annotate({ "description": "Approve-pending-device request body. Send `{}` to keep the device's own name." }) export type ArmNativePairing = { readonly "fingerprint"?: string | null, readonly "ttl_secs"?: never } @@ -81,6 +83,8 @@ export type PendingDevice = { readonly "age_secs": number, readonly "fingerprint export const PendingDevice = Schema.Struct({ "age_secs": Schema.Number.annotate({ "description": "Seconds since the device last knocked.", "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "fingerprint": Schema.String.annotate({ "description": "Hex SHA-256 of the device's certificate — what approval pins." }), "id": Schema.Number.annotate({ "description": "Id to address approve/deny (per-process; entries expire after ~10 minutes).", "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "name": Schema.String.annotate({ "description": "Best-effort device label (the client's own name, else fingerprint-derived)." }) }).annotate({ "description": "An unpaired device that tried to connect while the host requires pairing — awaiting\n**delegated approval** (approve it here instead of fetching the host PIN out of band)." }) export type Plane = "native" | "gamestream" export const Plane = Schema.Literals(["native", "gamestream"]).annotate({ "description": "Which protocol plane an event originated from. Hooks and scripts filter on it — a hook\nthat fires for native clients but not Moonlight clients is a bug, not a v2 feature." }) +export type PluginLogLine = { readonly "level": string, readonly "msg": string, readonly "source": string, readonly "ts_ms": number } +export const PluginLogLine = Schema.Struct({ "level": Schema.String.annotate({ "description": "`ERROR` | `WARN` | `INFO` | `DEBUG` | `TRACE`. Anything else is coerced to `INFO`." }), "msg": Schema.String, "source": Schema.String.annotate({ "description": "Which unit emitted it — a plugin's `definePlugin` name, a package name, or `runner`.\nSurfaced in the console's target column as `plugin:`." }), "ts_ms": Schema.Number.annotate({ "description": "When the line was produced, unix milliseconds. Kept verbatim — see\n[`crate::log_capture::LogRing::push_remote`].", "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)) }).annotate({ "description": "One log line produced by the runner or a plugin inside it (`POST /plugins/logs`)." }) export type PluginRegistration = { readonly "title": string, readonly "ui"?: null | { readonly "icon"?: string | null, readonly "port": number, readonly "secret": string }, readonly "version"?: string | null } export const PluginRegistration = Schema.Struct({ "title": Schema.String.annotate({ "description": "Human-readable title for the console nav entry (1–64 chars; control chars stripped)." }), "ui": Schema.optionalKey(Schema.Union([Schema.Null, Schema.Struct({ "icon": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "Optional lucide icon name for the console nav entry (`^[a-z0-9-]{1,48}$`)." })), "port": Schema.Number.annotate({ "description": "The **loopback** port the plugin serves its UI on. The host and console only ever dial\n`127.0.0.1:`; a registration can never carry a hostname.", "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "secret": Schema.String.annotate({ "description": "Per-boot shared secret the console proxy must present (as `Authorization: Bearer`) on every\nrequest to the plugin's UI server. Rotated whenever the plugin restarts." }) }).annotate({ "description": "Present iff the plugin serves a UI surface. A registration with no `ui` is a liveness/phone-book\nentry only (e.g. a future runner-management listing) and grows no nav entry." })], { mode: "oneOf" })), "version": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "Optional plugin version, purely informational (≤32 chars)." })) }).annotate({ "description": "Register/renew body for `PUT /plugins/{id}`." }) export type PluginUiPublic = { readonly "icon"?: string | null, readonly "port": number } @@ -133,6 +137,8 @@ export type UiCredential = { readonly "port": number, readonly "secret": string export const UiCredential = Schema.Struct({ "port": Schema.Number.annotate({ "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "secret": Schema.String }).annotate({ "description": "`GET /plugins/{id}/ui-credential` — the console proxy's server-side lookup (bearer + loopback).\nThis is the only endpoint that returns a secret; the console BFF denylists it from the browser." }) export type UninstallRequest = { readonly "pkg": string } export const UninstallRequest = Schema.Struct({ "pkg": Schema.String }) +export type UpdateStatus = { readonly "apply": string, readonly "available": boolean, readonly "channel": string, readonly "channel_hint": string, readonly "check_disabled": boolean, readonly "current_version": string, readonly "install_kind": string, readonly "job"?: null | { readonly "received_bytes": number, readonly "stage": string, readonly "started_unix": number, readonly "target_version": string, readonly "total_bytes"?: never }, readonly "last_checked_unix"?: never, readonly "last_error"?: string | null, readonly "last_result"?: null | { readonly "error"?: string | null, readonly "finished_unix": number, readonly "from": string, readonly "log_path"?: string | null, readonly "ok": boolean, readonly "stage"?: string | null, readonly "staged"?: boolean, readonly "to": string }, readonly "manifest"?: null | { readonly "notes_url": string, readonly "published_at": string, readonly "serial": number, readonly "stale": boolean, readonly "version": string }, readonly "not_published": boolean, readonly "opt_in_hint"?: string | null } +export const UpdateStatus = Schema.Struct({ "apply": Schema.String.annotate({ "description": "What the console may offer for this install: `notify` (show the command) — later\nphases add `full` (one-click apply) and `staged` (apply + reboot to finish)." }), "available": Schema.Boolean.annotate({ "description": "A newer release than `current_version` exists for this channel (definitive\ncomparisons only — an unparseable version pair never flags)." }), "channel": Schema.String.annotate({ "description": "Release channel this install follows: `stable` | `canary`." }), "channel_hint": Schema.String.annotate({ "description": "The copy-pastable update command for this install kind." }), "check_disabled": Schema.Boolean.annotate({ "description": "Update checks are disabled on this host (`PUNKTFUNK_UPDATE_CHECK=0`)." }), "current_version": Schema.String.annotate({ "description": "The running host version." }), "install_kind": Schema.String.annotate({ "description": "How this host was installed: `windows-installer` | `sysext` | `rpm-ostree` | `apt` |\n`dnf` | `pacman` | `steamos-source` | `nix` | `source`." }), "job": Schema.optionalKey(Schema.Union([Schema.Null, Schema.Struct({ "received_bytes": Schema.Number.annotate({ "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "stage": Schema.String.annotate({ "description": "`downloading` | `verifying` | `applying` | `restarting`." }), "started_unix": Schema.Number.annotate({ "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "target_version": Schema.String.annotate({ "description": "The version being installed." }), "total_bytes": Schema.optionalKey(Schema.Never) }).annotate({ "description": "The apply in flight, if any." })], { mode: "oneOf" })), "last_checked_unix": Schema.optionalKey(Schema.Never), "last_error": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "Why the last check failed, verbatim, if it did." })), "last_result": Schema.optionalKey(Schema.Union([Schema.Null, Schema.Struct({ "error": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), "finished_unix": Schema.Number.annotate({ "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "from": Schema.String, "log_path": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "The installer's own log file on this host, for diagnosis." })), "ok": Schema.Boolean, "stage": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "The stage that failed; absent on success." })), "staged": Schema.optionalKey(Schema.Boolean.annotate({ "description": "Applied but activates on the next reboot (rpm-ostree)." })), "to": Schema.String }).annotate({ "description": "Outcome of the most recent apply attempt." })], { mode: "oneOf" })), "manifest": Schema.optionalKey(Schema.Union([Schema.Null, Schema.Struct({ "notes_url": Schema.String.annotate({ "description": "Release-notes link (pinned to our forge by the manifest validator)." }), "published_at": Schema.String.annotate({ "description": "RFC-3339 publish time (display only)." }), "serial": Schema.Number.annotate({ "description": "Publish serial (unix seconds) — monotonic per channel.", "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "stale": Schema.Boolean.annotate({ "description": "The last verified manifest is suspiciously old (>45 days) — the freeze/stale hint." }), "version": Schema.String.annotate({ "description": "The released version this manifest announces." }) }).annotate({ "description": "The last verified manifest, if any check has succeeded." })], { mode: "oneOf" })), "not_published": Schema.Boolean.annotate({ "description": "The check reached the feed and found this channel has **no release published yet** —\nan expected state (a channel nobody has announced to answers with a 404), not a\nfailure. Mutually exclusive with `last_error`, so a UI can say \"nothing published yet\"\ninstead of painting an empty feed as a broken host. Never set once a manifest has been\nseen for this channel: a feed that loses a document it used to serve stays an error." }), "opt_in_hint": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "This install could one-click apply, but the operator hasn't opted in yet — the\ncommand to run (Linux: join the `punktfunk-update` group)." })) }).annotate({ "description": "The full update-check state for this host." }) export type RuntimeStatus = { readonly "active_sessions": number, readonly "audio_streaming": boolean, readonly "games": ReadonlyArray, readonly "native_paired_clients": number, readonly "paired_clients": number, readonly "pin_pending": boolean, readonly "session"?: null | { readonly "fps": number, readonly "height": number, readonly "width": number }, readonly "stream"?: null | { readonly "bitrate_kbps": number, readonly "codec": ApiCodec, readonly "fps": number, readonly "height": number, readonly "last_resize_ms"?: never, readonly "min_fec": number, readonly "packet_size": number, readonly "time_to_first_frame_ms"?: never, readonly "width": number }, readonly "video_streaming": boolean } export const RuntimeStatus = Schema.Struct({ "active_sessions": Schema.Number.annotate({ "description": "Number of live streaming sessions across BOTH planes (GameStream + native punktfunk/1). The\nnative server admits concurrent sessions, so this can exceed 1; `session`/`stream` below\ndescribe a single representative session for the detail card.", "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "audio_streaming": Schema.Boolean.annotate({ "description": "True while the audio stream thread is running." }), "games": Schema.Array(ActiveGame).annotate({ "description": "Every launched game the host is tracking: one row per live session that launched a title, plus\nany game whose session has ended and which is waiting out its reconnect window before being\nended (`state: \"grace\"`). Empty when nothing was launched — a plain desktop stream has no game." }), "native_paired_clients": Schema.Number.annotate({ "description": "Number of paired native (punktfunk/1) devices — the default plane, so on a host that has\nnever been touched by Moonlight this is the only non-zero one of the pair.", "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "paired_clients": Schema.Number.annotate({ "description": "Number of pinned (paired) GameStream client certificates. Native (punktfunk/1) devices pair\nagainst a separate store and are counted in `native_paired_clients` — sum the two for\n\"how many clients are paired with this host\".", "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "pin_pending": Schema.Boolean.annotate({ "description": "True while a pairing handshake is parked waiting for the user's PIN\n(submit it via `POST /api/v1/pair/pin`)." }), "session": Schema.optionalKey(Schema.Union([Schema.Null, Schema.Struct({ "fps": Schema.Number.annotate({ "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "height": Schema.Number.annotate({ "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "width": Schema.Number.annotate({ "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)) }).annotate({ "description": "A representative active session. GameStream's launch (Moonlight `/launch`) when present, else\nthe first live native session. `null` when nothing is streaming." })], { mode: "oneOf" })), "stream": Schema.optionalKey(Schema.Union([Schema.Null, Schema.Struct({ "bitrate_kbps": Schema.Number.annotate({ "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "codec": ApiCodec, "fps": Schema.Number.annotate({ "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "height": Schema.Number.annotate({ "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "last_resize_ms": Schema.optionalKey(Schema.Never), "min_fec": Schema.Number.annotate({ "description": "Client's parity floor per FEC block (`minRequiredFecPackets`).", "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "packet_size": Schema.Number.annotate({ "description": "Video payload size per packet (bytes).", "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "time_to_first_frame_ms": Schema.optionalKey(Schema.Never), "width": Schema.Number.annotate({ "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)) }).annotate({ "description": "The active stream's parameters — RTSP-negotiated for GameStream, or the live native session's\nmode/codec/bitrate. `null` when nothing is streaming." })], { mode: "oneOf" })), "video_streaming": Schema.Boolean.annotate({ "description": "True while the video stream thread is running." }) }).annotate({ "description": "Live host status (changes as clients launch/end sessions)." }) export type DisplayStateResponse = { readonly "displays": ReadonlyArray } @@ -155,6 +161,8 @@ export type GameRefPayload = { readonly "app"?: string | null, readonly "client" export const GameRefPayload = Schema.Struct({ "app": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "Store-qualified library id (`steam:570`). Absent for an operator-typed GameStream\n`apps.json` command, which has no library entry behind it." })), "client": Schema.String.annotate({ "description": "Client-supplied device name of the session that launched it; may be empty." }), "plane": Plane, "store": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "Which store surfaced it (`steam`, `heroic`, `custom`, …), when known." })), "title": Schema.String.annotate({ "description": "Display title." }) }).annotate({ "description": "A launched game, as the `game.*` events see it." }) export type StreamRef = { readonly "app"?: string | null, readonly "client": string, readonly "hdr": boolean, readonly "mode": string, readonly "plane": Plane } export const StreamRef = Schema.Struct({ "app": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "The launched app/title for this stream, when one was requested (store-qualified id on\nthe native plane, app title on the GameStream plane)." })), "client": Schema.String.annotate({ "description": "Client-supplied device name; may be empty." }), "hdr": Schema.Boolean, "mode": Schema.String.annotate({ "description": "Negotiated mode, `WxH@Hz`." }), "plane": Plane }).annotate({ "description": "A live video stream (what the stream marker file reflects)." }) +export type PluginLogBatch = { readonly "entries": ReadonlyArray } +export const PluginLogBatch = Schema.Struct({ "entries": Schema.Array(PluginLogLine) }).annotate({ "description": "A batch of runner log lines." }) export type PluginSummary = { readonly "id": string, readonly "title": string, readonly "ui"?: null | PluginUiPublic, readonly "version"?: string | null } export const PluginSummary = Schema.Struct({ "id": Schema.String, "title": Schema.String, "ui": Schema.optionalKey(Schema.Union([Schema.Null, PluginUiPublic], { mode: "oneOf" })), "version": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])) }).annotate({ "description": "One entry in `GET /plugins`. **Never carries the secret** — the browser learns a plugin exists\nand has a UI, nothing that lets it reach the plugin directly (it goes through the console proxy)." }) export type HostInfo = { readonly "abi_version": number, readonly "app_version": string, readonly "codecs": ReadonlyArray, readonly "gamestream": boolean, readonly "gfe_version": string, readonly "hostname": string, readonly "local_ip": string, readonly "os": string, readonly "os_name": string, readonly "ports": PortMap, readonly "uniqueid": string, readonly "version": string } @@ -175,8 +183,8 @@ export type StatsSample = { readonly "bitrate_kbps": number, readonly "fec_recov export const StatsSample = Schema.Struct({ "bitrate_kbps": Schema.Number.annotate({ "description": "Configured target bitrate.", "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "fec_recovered": Schema.Number.annotate({ "description": "FEC shards recovered this window (delta).", "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "fps": Schema.Number.annotate({ "description": "Genuine NEW frames/s from the source.", "format": "float" }).check(Schema.isFinite()), "frames_dropped": Schema.Number.annotate({ "description": "Frames dropped this window (delta).", "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "mbps": Schema.Number.annotate({ "description": "Attempted sealed wire bytes/s (Mb/s): full UDP payloads at seal time — video AU bytes\nplus shard framing (header + AEAD) plus FEC parity, and for PyroWave's datagram-aligned\nmode the zero-padded window tails. NOT goodput, and NOT reduced by socket send drops.", "format": "float" }).check(Schema.isFinite()), "packets_dropped": Schema.Number.annotate({ "description": "Packets dropped this window (receiver-side / reassembler, where known).", "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "repeat_fps": Schema.Number.annotate({ "description": "Re-encoded holds/s (source-starvation indicator).", "format": "float" }).check(Schema.isFinite()), "send_dropped": Schema.Number.annotate({ "description": "Host send-buffer overflow / EAGAIN this window (delta).", "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "session_id": Schema.Number.annotate({ "description": "Disambiguates concurrent sessions (usually constant).", "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "stages": Schema.Array(StageTiming).annotate({ "description": "Ordered pipeline stages for this path." }), "t_ms": Schema.Number.annotate({ "description": "Milliseconds since capture start (monotonic; stamped by [`StatsRecorder::push_sample`]).", "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)) }).annotate({ "description": "One aggregated sample (~ every 2 s native, ~ every 1 s GameStream)." }) export type Job = { readonly "error"?: string | null, readonly "finished_at"?: never, readonly "id": string, readonly "kind": string, readonly "log": ReadonlyArray, readonly "phase": string, readonly "started_at": number, readonly "state": State, readonly "target": string } export const Job = Schema.Struct({ "error": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), "finished_at": Schema.optionalKey(Schema.Never), "id": Schema.String, "kind": Schema.String.annotate({ "description": "`install` or `uninstall`." }), "log": Schema.Array(Schema.String).annotate({ "description": "Tail of the runner's combined stdout/stderr." }), "phase": Schema.String.annotate({ "description": "Coarse step name, for a progress line the operator can read." }), "started_at": Schema.Number.annotate({ "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "state": State, "target": Schema.String.annotate({ "description": "What the operator asked for — a package name, or the raw spec they typed." }) }).annotate({ "description": "A job as the console sees it. Field names are snake_case like the rest of the management API\n(the *file* formats — index, sources, manifest — follow npm's camelCase instead)." }) -export type HostEvent = { readonly "client": ClientRef, readonly "kind": "client.connected", readonly "schema": number, readonly "seq": number, readonly "ts_ms": number } | { readonly "client": ClientRef, readonly "kind": "client.disconnected", readonly "reason": DisconnectReason, readonly "schema": number, readonly "seq": number, readonly "ts_ms": number } | { readonly "kind": "session.started", readonly "session": SessionRef, readonly "schema": number, readonly "seq": number, readonly "ts_ms": number } | { readonly "kind": "session.ended", readonly "session": SessionRef, readonly "schema": number, readonly "seq": number, readonly "ts_ms": number } | { readonly "kind": "stream.started", readonly "stream": StreamRef, readonly "schema": number, readonly "seq": number, readonly "ts_ms": number } | { readonly "kind": "stream.stopped", readonly "stream": StreamRef, readonly "schema": number, readonly "seq": number, readonly "ts_ms": number } | { readonly "game": GameRefPayload, readonly "kind": "game.running", readonly "schema": number, readonly "seq": number, readonly "ts_ms": number } | { readonly "game": GameRefPayload, readonly "kind": "game.exited", readonly "reason": GameEndReason, readonly "schema": number, readonly "seq": number, readonly "ts_ms": number } | { readonly "device": DeviceRef, readonly "kind": "pairing.pending", readonly "schema": number, readonly "seq": number, readonly "ts_ms": number } | { readonly "device": DeviceRef, readonly "kind": "pairing.completed", readonly "schema": number, readonly "seq": number, readonly "ts_ms": number } | { readonly "device": DeviceRef, readonly "kind": "pairing.denied", readonly "schema": number, readonly "seq": number, readonly "ts_ms": number } | { readonly "backend": string, readonly "kind": "display.created", readonly "mode": string, readonly "schema": number, readonly "seq": number, readonly "ts_ms": number } | { readonly "count": number, readonly "kind": "display.released", readonly "schema": number, readonly "seq": number, readonly "ts_ms": number } | { readonly "kind": "library.changed", readonly "source": string, readonly "schema": number, readonly "seq": number, readonly "ts_ms": number } | { readonly "id": string, readonly "kind": "plugins.changed", readonly "schema": number, readonly "seq": number, readonly "ts_ms": number } | { readonly "kind": "store.changed", readonly "schema": number, readonly "seq": number, readonly "ts_ms": number } | { readonly "gamestream": boolean, readonly "kind": "host.started", readonly "version": string, readonly "schema": number, readonly "seq": number, readonly "ts_ms": number } | { readonly "kind": "host.stopping", readonly "schema": number, readonly "seq": number, readonly "ts_ms": number } -export const HostEvent = Schema.Union([Schema.Struct({ "client": ClientRef, "kind": Schema.Literal("client.connected"), "schema": Schema.Number.annotate({ "description": "Wire-shape version ([`SCHEMA_VERSION`]).", "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "seq": Schema.Number.annotate({ "description": "Monotonic sequence number (1-based) — a consumer resumes with `since = last seen`.", "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "ts_ms": Schema.Number.annotate({ "description": "Unix timestamp in milliseconds (the [`crate::log_capture::LogEntry`] convention).", "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)) }), Schema.Struct({ "client": ClientRef, "kind": Schema.Literal("client.disconnected"), "reason": DisconnectReason, "schema": Schema.Number.annotate({ "description": "Wire-shape version ([`SCHEMA_VERSION`]).", "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "seq": Schema.Number.annotate({ "description": "Monotonic sequence number (1-based) — a consumer resumes with `since = last seen`.", "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "ts_ms": Schema.Number.annotate({ "description": "Unix timestamp in milliseconds (the [`crate::log_capture::LogEntry`] convention).", "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)) }), Schema.Struct({ "kind": Schema.Literal("session.started"), "session": SessionRef, "schema": Schema.Number.annotate({ "description": "Wire-shape version ([`SCHEMA_VERSION`]).", "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "seq": Schema.Number.annotate({ "description": "Monotonic sequence number (1-based) — a consumer resumes with `since = last seen`.", "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "ts_ms": Schema.Number.annotate({ "description": "Unix timestamp in milliseconds (the [`crate::log_capture::LogEntry`] convention).", "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)) }), Schema.Struct({ "kind": Schema.Literal("session.ended"), "session": SessionRef, "schema": Schema.Number.annotate({ "description": "Wire-shape version ([`SCHEMA_VERSION`]).", "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "seq": Schema.Number.annotate({ "description": "Monotonic sequence number (1-based) — a consumer resumes with `since = last seen`.", "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "ts_ms": Schema.Number.annotate({ "description": "Unix timestamp in milliseconds (the [`crate::log_capture::LogEntry`] convention).", "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)) }), Schema.Struct({ "kind": Schema.Literal("stream.started"), "stream": StreamRef, "schema": Schema.Number.annotate({ "description": "Wire-shape version ([`SCHEMA_VERSION`]).", "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "seq": Schema.Number.annotate({ "description": "Monotonic sequence number (1-based) — a consumer resumes with `since = last seen`.", "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "ts_ms": Schema.Number.annotate({ "description": "Unix timestamp in milliseconds (the [`crate::log_capture::LogEntry`] convention).", "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)) }), Schema.Struct({ "kind": Schema.Literal("stream.stopped"), "stream": StreamRef, "schema": Schema.Number.annotate({ "description": "Wire-shape version ([`SCHEMA_VERSION`]).", "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "seq": Schema.Number.annotate({ "description": "Monotonic sequence number (1-based) — a consumer resumes with `since = last seen`.", "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "ts_ms": Schema.Number.annotate({ "description": "Unix timestamp in milliseconds (the [`crate::log_capture::LogEntry`] convention).", "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)) }), Schema.Struct({ "game": GameRefPayload, "kind": Schema.Literal("game.running"), "schema": Schema.Number.annotate({ "description": "Wire-shape version ([`SCHEMA_VERSION`]).", "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "seq": Schema.Number.annotate({ "description": "Monotonic sequence number (1-based) — a consumer resumes with `since = last seen`.", "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "ts_ms": Schema.Number.annotate({ "description": "Unix timestamp in milliseconds (the [`crate::log_capture::LogEntry`] convention).", "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)) }).annotate({ "description": "A launched game was confirmed running — fires once per launch, after the host has actually\nseen the game's process (not merely spawned its launcher)." }), Schema.Struct({ "game": GameRefPayload, "kind": Schema.Literal("game.exited"), "reason": GameEndReason, "schema": Schema.Number.annotate({ "description": "Wire-shape version ([`SCHEMA_VERSION`]).", "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "seq": Schema.Number.annotate({ "description": "Monotonic sequence number (1-based) — a consumer resumes with `since = last seen`.", "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "ts_ms": Schema.Number.annotate({ "description": "Unix timestamp in milliseconds (the [`crate::log_capture::LogEntry`] convention).", "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)) }).annotate({ "description": "A launched game is gone. `reason` distinguishes the player quitting from the host ending it\nper the lifetime policy." }), Schema.Struct({ "device": DeviceRef, "kind": Schema.Literal("pairing.pending"), "schema": Schema.Number.annotate({ "description": "Wire-shape version ([`SCHEMA_VERSION`]).", "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "seq": Schema.Number.annotate({ "description": "Monotonic sequence number (1-based) — a consumer resumes with `since = last seen`.", "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "ts_ms": Schema.Number.annotate({ "description": "Unix timestamp in milliseconds (the [`crate::log_capture::LogEntry`] convention).", "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)) }), Schema.Struct({ "device": DeviceRef, "kind": Schema.Literal("pairing.completed"), "schema": Schema.Number.annotate({ "description": "Wire-shape version ([`SCHEMA_VERSION`]).", "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "seq": Schema.Number.annotate({ "description": "Monotonic sequence number (1-based) — a consumer resumes with `since = last seen`.", "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "ts_ms": Schema.Number.annotate({ "description": "Unix timestamp in milliseconds (the [`crate::log_capture::LogEntry`] convention).", "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)) }), Schema.Struct({ "device": DeviceRef, "kind": Schema.Literal("pairing.denied"), "schema": Schema.Number.annotate({ "description": "Wire-shape version ([`SCHEMA_VERSION`]).", "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "seq": Schema.Number.annotate({ "description": "Monotonic sequence number (1-based) — a consumer resumes with `since = last seen`.", "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "ts_ms": Schema.Number.annotate({ "description": "Unix timestamp in milliseconds (the [`crate::log_capture::LogEntry`] convention).", "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)) }), Schema.Struct({ "backend": Schema.String.annotate({ "description": "The virtual-display backend that minted it (`VirtualDisplay::name`)." }), "kind": Schema.Literal("display.created"), "mode": Schema.String.annotate({ "description": "`WxH@Hz`." }), "schema": Schema.Number.annotate({ "description": "Wire-shape version ([`SCHEMA_VERSION`]).", "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "seq": Schema.Number.annotate({ "description": "Monotonic sequence number (1-based) — a consumer resumes with `since = last seen`.", "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "ts_ms": Schema.Number.annotate({ "description": "Unix timestamp in milliseconds (the [`crate::log_capture::LogEntry`] convention).", "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)) }), Schema.Struct({ "count": Schema.Number.annotate({ "description": "How many kept displays this release retired.", "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "kind": Schema.Literal("display.released"), "schema": Schema.Number.annotate({ "description": "Wire-shape version ([`SCHEMA_VERSION`]).", "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "seq": Schema.Number.annotate({ "description": "Monotonic sequence number (1-based) — a consumer resumes with `since = last seen`.", "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "ts_ms": Schema.Number.annotate({ "description": "Unix timestamp in milliseconds (the [`crate::log_capture::LogEntry`] convention).", "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)) }), Schema.Struct({ "kind": Schema.Literal("library.changed"), "source": Schema.String.annotate({ "description": "What mutated the library: `\"manual\"` today; a provider id once the provider\nAPI (RFC §8) lands." }), "schema": Schema.Number.annotate({ "description": "Wire-shape version ([`SCHEMA_VERSION`]).", "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "seq": Schema.Number.annotate({ "description": "Monotonic sequence number (1-based) — a consumer resumes with `since = last seen`.", "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "ts_ms": Schema.Number.annotate({ "description": "Unix timestamp in milliseconds (the [`crate::log_capture::LogEntry`] convention).", "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)) }), Schema.Struct({ "id": Schema.String.annotate({ "description": "The plugin whose registration changed (registered, restarted, deregistered, or\nlease-expired). A consumer re-reads `GET /api/v1/plugins` for the new set." }), "kind": Schema.Literal("plugins.changed"), "schema": Schema.Number.annotate({ "description": "Wire-shape version ([`SCHEMA_VERSION`]).", "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "seq": Schema.Number.annotate({ "description": "Monotonic sequence number (1-based) — a consumer resumes with `since = last seen`.", "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "ts_ms": Schema.Number.annotate({ "description": "Unix timestamp in milliseconds (the [`crate::log_capture::LogEntry`] convention).", "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)) }), Schema.Struct({ "kind": Schema.Literal("store.changed"), "schema": Schema.Number.annotate({ "description": "Wire-shape version ([`SCHEMA_VERSION`]).", "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "seq": Schema.Number.annotate({ "description": "Monotonic sequence number (1-based) — a consumer resumes with `since = last seen`.", "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "ts_ms": Schema.Number.annotate({ "description": "Unix timestamp in milliseconds (the [`crate::log_capture::LogEntry`] convention).", "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)) }).annotate({ "description": "The set of installed plugins, or what the store knows about them, changed — an install or\nuninstall finished, or a catalog refresh brought in new rows. A consumer re-reads\n`GET /api/v1/store/catalog` / `…/installed`. Deliberately payload-free: the store's answer\nis a join over several sources of truth, so \"go look again\" is the only honest signal." }), Schema.Struct({ "gamestream": Schema.Boolean.annotate({ "description": "Whether the GameStream/Moonlight compat plane is enabled." }), "kind": Schema.Literal("host.started"), "version": Schema.String, "schema": Schema.Number.annotate({ "description": "Wire-shape version ([`SCHEMA_VERSION`]).", "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "seq": Schema.Number.annotate({ "description": "Monotonic sequence number (1-based) — a consumer resumes with `since = last seen`.", "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "ts_ms": Schema.Number.annotate({ "description": "Unix timestamp in milliseconds (the [`crate::log_capture::LogEntry`] convention).", "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)) }), Schema.Struct({ "kind": Schema.Literal("host.stopping"), "schema": Schema.Number.annotate({ "description": "Wire-shape version ([`SCHEMA_VERSION`]).", "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "seq": Schema.Number.annotate({ "description": "Monotonic sequence number (1-based) — a consumer resumes with `since = last seen`.", "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "ts_ms": Schema.Number.annotate({ "description": "Unix timestamp in milliseconds (the [`crate::log_capture::LogEntry`] convention).", "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)) })], { mode: "oneOf" }).annotate({ "description": "The event kind + payload, flattened: `\"kind\": \"stream.started\", …payload…`." }) +export type HostEvent = { readonly "client": ClientRef, readonly "kind": "client.connected", readonly "schema": number, readonly "seq": number, readonly "ts_ms": number } | { readonly "client": ClientRef, readonly "kind": "client.disconnected", readonly "reason": DisconnectReason, readonly "schema": number, readonly "seq": number, readonly "ts_ms": number } | { readonly "kind": "session.started", readonly "session": SessionRef, readonly "schema": number, readonly "seq": number, readonly "ts_ms": number } | { readonly "kind": "session.ended", readonly "session": SessionRef, readonly "schema": number, readonly "seq": number, readonly "ts_ms": number } | { readonly "kind": "stream.started", readonly "stream": StreamRef, readonly "schema": number, readonly "seq": number, readonly "ts_ms": number } | { readonly "kind": "stream.stopped", readonly "stream": StreamRef, readonly "schema": number, readonly "seq": number, readonly "ts_ms": number } | { readonly "game": GameRefPayload, readonly "kind": "game.running", readonly "schema": number, readonly "seq": number, readonly "ts_ms": number } | { readonly "game": GameRefPayload, readonly "kind": "game.exited", readonly "reason": GameEndReason, readonly "schema": number, readonly "seq": number, readonly "ts_ms": number } | { readonly "device": DeviceRef, readonly "kind": "pairing.pending", readonly "schema": number, readonly "seq": number, readonly "ts_ms": number } | { readonly "device": DeviceRef, readonly "kind": "pairing.completed", readonly "schema": number, readonly "seq": number, readonly "ts_ms": number } | { readonly "device": DeviceRef, readonly "kind": "pairing.denied", readonly "schema": number, readonly "seq": number, readonly "ts_ms": number } | { readonly "backend": string, readonly "kind": "display.created", readonly "mode": string, readonly "schema": number, readonly "seq": number, readonly "ts_ms": number } | { readonly "count": number, readonly "kind": "display.released", readonly "schema": number, readonly "seq": number, readonly "ts_ms": number } | { readonly "kind": "library.changed", readonly "source": string, readonly "schema": number, readonly "seq": number, readonly "ts_ms": number } | { readonly "channel": string, readonly "install_kind": string, readonly "kind": "update.available", readonly "version": string, readonly "schema": number, readonly "seq": number, readonly "ts_ms": number } | { readonly "from": string, readonly "kind": "update.applied", readonly "to": string, readonly "schema": number, readonly "seq": number, readonly "ts_ms": number } | { readonly "id": string, readonly "kind": "plugins.changed", readonly "schema": number, readonly "seq": number, readonly "ts_ms": number } | { readonly "kind": "store.changed", readonly "schema": number, readonly "seq": number, readonly "ts_ms": number } | { readonly "gamestream": boolean, readonly "kind": "host.started", readonly "version": string, readonly "schema": number, readonly "seq": number, readonly "ts_ms": number } | { readonly "kind": "host.stopping", readonly "schema": number, readonly "seq": number, readonly "ts_ms": number } +export const HostEvent = Schema.Union([Schema.Struct({ "client": ClientRef, "kind": Schema.Literal("client.connected"), "schema": Schema.Number.annotate({ "description": "Wire-shape version ([`SCHEMA_VERSION`]).", "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "seq": Schema.Number.annotate({ "description": "Monotonic sequence number (1-based) — a consumer resumes with `since = last seen`.", "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "ts_ms": Schema.Number.annotate({ "description": "Unix timestamp in milliseconds (the [`crate::log_capture::LogEntry`] convention).", "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)) }), Schema.Struct({ "client": ClientRef, "kind": Schema.Literal("client.disconnected"), "reason": DisconnectReason, "schema": Schema.Number.annotate({ "description": "Wire-shape version ([`SCHEMA_VERSION`]).", "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "seq": Schema.Number.annotate({ "description": "Monotonic sequence number (1-based) — a consumer resumes with `since = last seen`.", "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "ts_ms": Schema.Number.annotate({ "description": "Unix timestamp in milliseconds (the [`crate::log_capture::LogEntry`] convention).", "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)) }), Schema.Struct({ "kind": Schema.Literal("session.started"), "session": SessionRef, "schema": Schema.Number.annotate({ "description": "Wire-shape version ([`SCHEMA_VERSION`]).", "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "seq": Schema.Number.annotate({ "description": "Monotonic sequence number (1-based) — a consumer resumes with `since = last seen`.", "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "ts_ms": Schema.Number.annotate({ "description": "Unix timestamp in milliseconds (the [`crate::log_capture::LogEntry`] convention).", "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)) }), Schema.Struct({ "kind": Schema.Literal("session.ended"), "session": SessionRef, "schema": Schema.Number.annotate({ "description": "Wire-shape version ([`SCHEMA_VERSION`]).", "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "seq": Schema.Number.annotate({ "description": "Monotonic sequence number (1-based) — a consumer resumes with `since = last seen`.", "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "ts_ms": Schema.Number.annotate({ "description": "Unix timestamp in milliseconds (the [`crate::log_capture::LogEntry`] convention).", "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)) }), Schema.Struct({ "kind": Schema.Literal("stream.started"), "stream": StreamRef, "schema": Schema.Number.annotate({ "description": "Wire-shape version ([`SCHEMA_VERSION`]).", "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "seq": Schema.Number.annotate({ "description": "Monotonic sequence number (1-based) — a consumer resumes with `since = last seen`.", "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "ts_ms": Schema.Number.annotate({ "description": "Unix timestamp in milliseconds (the [`crate::log_capture::LogEntry`] convention).", "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)) }), Schema.Struct({ "kind": Schema.Literal("stream.stopped"), "stream": StreamRef, "schema": Schema.Number.annotate({ "description": "Wire-shape version ([`SCHEMA_VERSION`]).", "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "seq": Schema.Number.annotate({ "description": "Monotonic sequence number (1-based) — a consumer resumes with `since = last seen`.", "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "ts_ms": Schema.Number.annotate({ "description": "Unix timestamp in milliseconds (the [`crate::log_capture::LogEntry`] convention).", "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)) }), Schema.Struct({ "game": GameRefPayload, "kind": Schema.Literal("game.running"), "schema": Schema.Number.annotate({ "description": "Wire-shape version ([`SCHEMA_VERSION`]).", "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "seq": Schema.Number.annotate({ "description": "Monotonic sequence number (1-based) — a consumer resumes with `since = last seen`.", "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "ts_ms": Schema.Number.annotate({ "description": "Unix timestamp in milliseconds (the [`crate::log_capture::LogEntry`] convention).", "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)) }).annotate({ "description": "A launched game was confirmed running — fires once per launch, after the host has actually\nseen the game's process (not merely spawned its launcher)." }), Schema.Struct({ "game": GameRefPayload, "kind": Schema.Literal("game.exited"), "reason": GameEndReason, "schema": Schema.Number.annotate({ "description": "Wire-shape version ([`SCHEMA_VERSION`]).", "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "seq": Schema.Number.annotate({ "description": "Monotonic sequence number (1-based) — a consumer resumes with `since = last seen`.", "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "ts_ms": Schema.Number.annotate({ "description": "Unix timestamp in milliseconds (the [`crate::log_capture::LogEntry`] convention).", "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)) }).annotate({ "description": "A launched game is gone. `reason` distinguishes the player quitting from the host ending it\nper the lifetime policy." }), Schema.Struct({ "device": DeviceRef, "kind": Schema.Literal("pairing.pending"), "schema": Schema.Number.annotate({ "description": "Wire-shape version ([`SCHEMA_VERSION`]).", "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "seq": Schema.Number.annotate({ "description": "Monotonic sequence number (1-based) — a consumer resumes with `since = last seen`.", "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "ts_ms": Schema.Number.annotate({ "description": "Unix timestamp in milliseconds (the [`crate::log_capture::LogEntry`] convention).", "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)) }), Schema.Struct({ "device": DeviceRef, "kind": Schema.Literal("pairing.completed"), "schema": Schema.Number.annotate({ "description": "Wire-shape version ([`SCHEMA_VERSION`]).", "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "seq": Schema.Number.annotate({ "description": "Monotonic sequence number (1-based) — a consumer resumes with `since = last seen`.", "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "ts_ms": Schema.Number.annotate({ "description": "Unix timestamp in milliseconds (the [`crate::log_capture::LogEntry`] convention).", "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)) }), Schema.Struct({ "device": DeviceRef, "kind": Schema.Literal("pairing.denied"), "schema": Schema.Number.annotate({ "description": "Wire-shape version ([`SCHEMA_VERSION`]).", "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "seq": Schema.Number.annotate({ "description": "Monotonic sequence number (1-based) — a consumer resumes with `since = last seen`.", "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "ts_ms": Schema.Number.annotate({ "description": "Unix timestamp in milliseconds (the [`crate::log_capture::LogEntry`] convention).", "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)) }), Schema.Struct({ "backend": Schema.String.annotate({ "description": "The virtual-display backend that minted it (`VirtualDisplay::name`)." }), "kind": Schema.Literal("display.created"), "mode": Schema.String.annotate({ "description": "`WxH@Hz`." }), "schema": Schema.Number.annotate({ "description": "Wire-shape version ([`SCHEMA_VERSION`]).", "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "seq": Schema.Number.annotate({ "description": "Monotonic sequence number (1-based) — a consumer resumes with `since = last seen`.", "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "ts_ms": Schema.Number.annotate({ "description": "Unix timestamp in milliseconds (the [`crate::log_capture::LogEntry`] convention).", "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)) }), Schema.Struct({ "count": Schema.Number.annotate({ "description": "How many kept displays this release retired.", "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "kind": Schema.Literal("display.released"), "schema": Schema.Number.annotate({ "description": "Wire-shape version ([`SCHEMA_VERSION`]).", "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "seq": Schema.Number.annotate({ "description": "Monotonic sequence number (1-based) — a consumer resumes with `since = last seen`.", "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "ts_ms": Schema.Number.annotate({ "description": "Unix timestamp in milliseconds (the [`crate::log_capture::LogEntry`] convention).", "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)) }), Schema.Struct({ "kind": Schema.Literal("library.changed"), "source": Schema.String.annotate({ "description": "What mutated the library: `\"manual\"` today; a provider id once the provider\nAPI (RFC §8) lands." }), "schema": Schema.Number.annotate({ "description": "Wire-shape version ([`SCHEMA_VERSION`]).", "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "seq": Schema.Number.annotate({ "description": "Monotonic sequence number (1-based) — a consumer resumes with `since = last seen`.", "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "ts_ms": Schema.Number.annotate({ "description": "Unix timestamp in milliseconds (the [`crate::log_capture::LogEntry`] convention).", "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)) }), Schema.Struct({ "channel": Schema.String.annotate({ "description": "The channel it was announced on (`stable` | `canary`)." }), "install_kind": Schema.String.annotate({ "description": "This host's install kind (`apt`, `windows-installer`, …) — lets a hook or the\ntray render the right \"how to update\" hint without a second call." }), "kind": Schema.Literal("update.available"), "version": Schema.String.annotate({ "description": "The newer release's version string." }), "schema": Schema.Number.annotate({ "description": "Wire-shape version ([`SCHEMA_VERSION`]).", "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "seq": Schema.Number.annotate({ "description": "Monotonic sequence number (1-based) — a consumer resumes with `since = last seen`.", "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "ts_ms": Schema.Number.annotate({ "description": "Unix timestamp in milliseconds (the [`crate::log_capture::LogEntry`] convention).", "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)) }).annotate({ "description": "A verified update manifest announced a release newer than the running host. Emitted\nonce per discovered version (a steady-state \"newer exists\" doesn't re-fire on every\nrefresh)." }), Schema.Struct({ "from": Schema.String, "kind": Schema.Literal("update.applied"), "to": Schema.String, "schema": Schema.Number.annotate({ "description": "Wire-shape version ([`SCHEMA_VERSION`]).", "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "seq": Schema.Number.annotate({ "description": "Monotonic sequence number (1-based) — a consumer resumes with `since = last seen`.", "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "ts_ms": Schema.Number.annotate({ "description": "Unix timestamp in milliseconds (the [`crate::log_capture::LogEntry`] convention).", "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)) }).annotate({ "description": "A host update completed: emitted by boot-time reconciliation, i.e. by the NEW binary's\nfirst start after a successful apply." }), Schema.Struct({ "id": Schema.String.annotate({ "description": "The plugin whose registration changed (registered, restarted, deregistered, or\nlease-expired). A consumer re-reads `GET /api/v1/plugins` for the new set." }), "kind": Schema.Literal("plugins.changed"), "schema": Schema.Number.annotate({ "description": "Wire-shape version ([`SCHEMA_VERSION`]).", "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "seq": Schema.Number.annotate({ "description": "Monotonic sequence number (1-based) — a consumer resumes with `since = last seen`.", "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "ts_ms": Schema.Number.annotate({ "description": "Unix timestamp in milliseconds (the [`crate::log_capture::LogEntry`] convention).", "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)) }), Schema.Struct({ "kind": Schema.Literal("store.changed"), "schema": Schema.Number.annotate({ "description": "Wire-shape version ([`SCHEMA_VERSION`]).", "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "seq": Schema.Number.annotate({ "description": "Monotonic sequence number (1-based) — a consumer resumes with `since = last seen`.", "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "ts_ms": Schema.Number.annotate({ "description": "Unix timestamp in milliseconds (the [`crate::log_capture::LogEntry`] convention).", "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)) }).annotate({ "description": "The set of installed plugins, or what the store knows about them, changed — an install or\nuninstall finished, or a catalog refresh brought in new rows. A consumer re-reads\n`GET /api/v1/store/catalog` / `…/installed`. Deliberately payload-free: the store's answer\nis a join over several sources of truth, so \"go look again\" is the only honest signal." }), Schema.Struct({ "gamestream": Schema.Boolean.annotate({ "description": "Whether the GameStream/Moonlight compat plane is enabled." }), "kind": Schema.Literal("host.started"), "version": Schema.String, "schema": Schema.Number.annotate({ "description": "Wire-shape version ([`SCHEMA_VERSION`]).", "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "seq": Schema.Number.annotate({ "description": "Monotonic sequence number (1-based) — a consumer resumes with `since = last seen`.", "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "ts_ms": Schema.Number.annotate({ "description": "Unix timestamp in milliseconds (the [`crate::log_capture::LogEntry`] convention).", "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)) }), Schema.Struct({ "kind": Schema.Literal("host.stopping"), "schema": Schema.Number.annotate({ "description": "Wire-shape version ([`SCHEMA_VERSION`]).", "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "seq": Schema.Number.annotate({ "description": "Monotonic sequence number (1-based) — a consumer resumes with `since = last seen`.", "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "ts_ms": Schema.Number.annotate({ "description": "Unix timestamp in milliseconds (the [`crate::log_capture::LogEntry`] convention).", "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)) })], { mode: "oneOf" }).annotate({ "description": "The event kind + payload, flattened: `\"kind\": \"stream.started\", …payload…`." }) export type CustomPreset = { readonly "fields": { readonly "identity": Identity, readonly "keep_alive": KeepAlive, readonly "layout": Layout, readonly "max_displays": number, readonly "mode_conflict": ModeConflict, readonly "topology": Topology }, readonly "game_session"?: "auto" | "dedicated", readonly "id": string, readonly "name": string } export const CustomPreset = Schema.Struct({ "fields": Schema.Struct({ "identity": Identity, "keep_alive": KeepAlive, "layout": Layout, "max_displays": Schema.Number.annotate({ "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "mode_conflict": ModeConflict, "topology": Topology }).annotate({ "description": "The six display-behavior axes this preset applies (the same shape a built-in preset expands to)." }), "game_session": Schema.optionalKey(Schema.Literals(["auto", "dedicated"]).annotate({ "description": "The game-session routing this preset applies (orthogonal to the six axes; see [`GameSession`]).\nA custom preset captures the operator's *full* setup, so — unlike a built-in preset — applying\none does set this axis." })), "id": Schema.String.annotate({ "description": "Host-assigned, stable for the life of the entry (the `{id}` in the CRUD path)." }), "name": Schema.String.annotate({ "description": "User-facing name shown on the preset card; editable." }) }).annotate({ "description": "A user-defined named preset: a saved bundle of the six display-behavior axes (exactly what a\nbuilt-in [`Preset`] expands to) plus the orthogonal game-session axis, that the operator names\nand applies from the console.\n\nUnlike the built-in [`Preset`]s (a closed enum), custom presets are **data** — a catalog stored in\n`/display-presets.json`. Applying one writes a `Custom` [`DisplayPolicy`] carrying these\nfields (the console reuses `PUT /display/settings`), so [`DisplayPolicy::effective`] stays pure and\nthe built-in set is never touched. The catalog is decoupled from the active `display-settings.json`:\nediting or deleting a preset never mutates the running policy (re-apply to adopt a change)." }) export type DisplayPolicy = { readonly "capture_monitor"?: string | null, readonly "ddc_power_off"?: boolean, readonly "game_session"?: "auto" | "dedicated", readonly "identity"?: Identity, readonly "keep_alive"?: KeepAlive, readonly "layout"?: Layout, readonly "max_displays"?: number, readonly "mode_conflict"?: ModeConflict, readonly "pnp_disable_monitors"?: boolean, readonly "preset"?: Preset, readonly "topology"?: Topology, readonly "version"?: number } @@ -472,6 +480,12 @@ export type ListPlugins200 = ReadonlyArray export const ListPlugins200 = Schema.Array(PluginSummary) export type ListPlugins401 = ApiError export const ListPlugins401 = ApiError +export type IngestPluginLogsRequestJson = PluginLogBatch +export const IngestPluginLogsRequestJson = PluginLogBatch +export type IngestPluginLogs400 = ApiError +export const IngestPluginLogs400 = ApiError +export type IngestPluginLogs401 = ApiError +export const IngestPluginLogs401 = ApiError export type RegisterPluginRequestJson = PluginRegistration export const RegisterPluginRequestJson = PluginRegistration export type RegisterPlugin400 = ApiError @@ -638,6 +652,26 @@ export type UninstallPlugin403 = ApiError export const UninstallPlugin403 = ApiError export type UninstallPlugin409 = ApiError export const UninstallPlugin409 = ApiError +export type ApplyUpdateRequestJson = ApplyRequest +export const ApplyUpdateRequestJson = ApplyRequest +export type ApplyUpdate202 = UpdateStatus +export const ApplyUpdate202 = UpdateStatus +export type ApplyUpdate401 = ApiError +export const ApplyUpdate401 = ApiError +export type ApplyUpdate409 = ApiError +export const ApplyUpdate409 = ApiError +export type ForceUpdateCheck200 = UpdateStatus +export const ForceUpdateCheck200 = UpdateStatus +export type ForceUpdateCheck401 = ApiError +export const ForceUpdateCheck401 = ApiError +export type ForceUpdateCheck409 = ApiError +export const ForceUpdateCheck409 = ApiError +export type ForceUpdateCheck429 = ApiError +export const ForceUpdateCheck429 = ApiError +export type GetUpdateStatus200 = UpdateStatus +export const GetUpdateStatus200 = UpdateStatus +export type GetUpdateStatus401 = ApiError +export const GetUpdateStatus401 = ApiError export interface OperationConfig { /** @@ -1095,6 +1129,15 @@ export const make = ( "401": decodeError("ListPlugins401", ListPlugins401), orElse: unexpectedStatus })) + ), + "ingestPluginLogs": (options) => HttpClientRequest.post(`/api/v1/plugins/logs`).pipe( + HttpClientRequest.bodyJsonUnsafe(options.payload), + withResponse(options.config)(HttpClientResponse.matchStatus({ + "400": decodeError("IngestPluginLogs400", IngestPluginLogs400), + "401": decodeError("IngestPluginLogs401", IngestPluginLogs401), + "204": () => Effect.void, + orElse: unexpectedStatus + })) ), "registerPlugin": (id, options) => HttpClientRequest.put(`/api/v1/plugins/${id}`).pipe( HttpClientRequest.bodyJsonUnsafe(options.payload), @@ -1321,6 +1364,31 @@ export const make = ( "409": decodeError("UninstallPlugin409", UninstallPlugin409), orElse: unexpectedStatus })) + ), + "applyUpdate": (options) => HttpClientRequest.post(`/api/v1/update/apply`).pipe( + HttpClientRequest.bodyJsonUnsafe(options.payload), + withResponse(options.config)(HttpClientResponse.matchStatus({ + "2xx": decodeSuccess(ApplyUpdate202), + "401": decodeError("ApplyUpdate401", ApplyUpdate401), + "409": decodeError("ApplyUpdate409", ApplyUpdate409), + orElse: unexpectedStatus + })) + ), + "forceUpdateCheck": (options) => HttpClientRequest.post(`/api/v1/update/check`).pipe( + withResponse(options?.config)(HttpClientResponse.matchStatus({ + "2xx": decodeSuccess(ForceUpdateCheck200), + "401": decodeError("ForceUpdateCheck401", ForceUpdateCheck401), + "409": decodeError("ForceUpdateCheck409", ForceUpdateCheck409), + "429": decodeError("ForceUpdateCheck429", ForceUpdateCheck429), + orElse: unexpectedStatus + })) + ), + "getUpdateStatus": (options) => HttpClientRequest.get(`/api/v1/update/status`).pipe( + withResponse(options?.config)(HttpClientResponse.matchStatus({ + "2xx": decodeSuccess(GetUpdateStatus200), + "401": decodeError("GetUpdateStatus401", GetUpdateStatus401), + orElse: unexpectedStatus + })) ) } } @@ -1589,6 +1657,21 @@ readonly "submitPairingPin": (options: { readonl */ readonly "listPlugins": (options: { readonly config?: Config | undefined } | undefined) => Effect.Effect, HttpClientError.HttpClientError | SchemaError | PunktfunkError<"ListPlugins401", typeof ListPlugins401.Type>> /** +* The plugin/script runner ships its output here so the console's **Logs** page can show it. +* +* Plugins are not host child processes — the runner is a separate `bun` process that `import()`s +* each plugin in-process — so nothing a plugin logs passes through the host's own `tracing`, and +* before this endpoint the console's log page could not show a single plugin line. On Linux the +* fallback was `journalctl --user -u punktfunk-scripting`; on Windows the runner task writes no +* log file at all, so a failing plugin was diagnosable only by stopping the scheduled task and +* re-running the runner by hand. Both are shell access on the host box, which is exactly what the +* console exists to avoid. +* +* Lines land in the same ring as the host's own, sharing one `seq` cursor, targeted +* `plugin:` — so `GET /logs` needs no second cursor and the console needs no second poll. +*/ +readonly "ingestPluginLogs": (options: { readonly payload: typeof IngestPluginLogsRequestJson.Encoded; readonly config?: Config | undefined }) => Effect.Effect, HttpClientError.HttpClientError | SchemaError | PunktfunkError<"IngestPluginLogs400", typeof IngestPluginLogs400.Type> | PunktfunkError<"IngestPluginLogs401", typeof IngestPluginLogs401.Type>> + /** * Upserts the plugin's directory entry and renews its lease (TTL 90 s). Idempotent: a plugin PUTs * this every ~30 s while it runs. The optional `ui` block declares a loopback UI surface the console * will proxy and add to its nav. Emits `plugins.changed` when an operator-visible field changed @@ -1733,6 +1816,24 @@ readonly "deletePluginSource": (name: string, op * the tree. */ readonly "uninstallPlugin": (options: { readonly payload: typeof UninstallPluginRequestJson.Encoded; readonly config?: Config | undefined }) => Effect.Effect, HttpClientError.HttpClientError | SchemaError | PunktfunkError<"UninstallPlugin400", typeof UninstallPlugin400.Type> | PunktfunkError<"UninstallPlugin401", typeof UninstallPlugin401.Type> | PunktfunkError<"UninstallPlugin403", typeof UninstallPlugin403.Type> | PunktfunkError<"UninstallPlugin409", typeof UninstallPlugin409.Type>> + /** +* Starts the one-click apply for install kinds that support it (Windows installer). The +* request carries no version or URL — the host installs exactly what its verified manifest +* announced. Progress is polled via `GET /update/status` (`job`); the host restarts as part +* of the apply, and the outcome lands in `last_result` after it comes back. +*/ +readonly "applyUpdate": (options: { readonly payload: typeof ApplyUpdateRequestJson.Encoded; readonly config?: Config | undefined }) => Effect.Effect, HttpClientError.HttpClientError | SchemaError | PunktfunkError<"ApplyUpdate401", typeof ApplyUpdate401.Type> | PunktfunkError<"ApplyUpdate409", typeof ApplyUpdate409.Type>> + /** +* Forces a manifest fetch + verification and returns the refreshed state. Rate-limited to +* one forced check per 30 s. +*/ +readonly "forceUpdateCheck": (options: { readonly config?: Config | undefined } | undefined) => Effect.Effect, HttpClientError.HttpClientError | SchemaError | PunktfunkError<"ForceUpdateCheck401", typeof ForceUpdateCheck401.Type> | PunktfunkError<"ForceUpdateCheck409", typeof ForceUpdateCheck409.Type> | PunktfunkError<"ForceUpdateCheck429", typeof ForceUpdateCheck429.Type>> + /** +* How this host was installed, which channel it follows, whether a newer release is known, +* and how to update. Reading this may kick a background refresh when the cached check is +* older than 6 h; the response never blocks on the network. +*/ +readonly "getUpdateStatus": (options: { readonly config?: Config | undefined } | undefined) => Effect.Effect, HttpClientError.HttpClientError | SchemaError | PunktfunkError<"GetUpdateStatus401", typeof GetUpdateStatus401.Type>> } export interface PunktfunkError { diff --git a/sdk/src/log-ship.ts b/sdk/src/log-ship.ts new file mode 100644 index 00000000..f7003fca --- /dev/null +++ b/sdk/src/log-ship.ts @@ -0,0 +1,287 @@ +// The runner's log door into the web console (field report 2026-08-03, the VirtualHere plugin). +// +// WHY THIS EXISTS: plugins are not host child processes. The runner is a separate bun process that +// `import()`s each plugin in-process, so a plugin's output is THIS process's stdout and the host's +// `tracing` ring — the thing `GET /api/v1/logs` and the console's Logs page serve — never sees a +// byte of it. On Linux the fallback was `journalctl --user -u punktfunk-scripting`; on Windows the +// runner scheduled task writes no log file AT ALL, so a failing plugin could only be diagnosed by +// stopping the task and re-running the runner by hand. Both need shell access on the host box, +// which is the exact thing the console exists to avoid. A user hitting a plugin misconfiguration +// therefore had no way to see the error explaining it. +// +// So: tee every console line to `POST /api/v1/plugins/logs`, which lands it in the host's ring +// alongside the host's own lines under the target `plugin:`. +// +// Design rules this file will not break: +// - **stdout stays authoritative.** The original console method is called FIRST and always, so +// journald/foreground output is unchanged whatever the host is doing. Shipping is additive. +// - **Never recurse.** Nothing on the shipping path may log through the patched console; a failed +// POST that logged its own failure would enqueue that line, fail again, and spin. +// - **Never throw into a caller.** `console.log` is not allowed to fail because the host is down. +// - **Bounded.** The queue has a hard cap and drops its OLDEST lines, then says how many — an +// unreachable host must not turn the runner into a memory leak. +import { format } from "node:util"; +import { type ConnectOptions, resolveConfig } from "./config.js"; + +/** The level a console method implies when a line carries no level of its own. */ +const LEVEL_BY_METHOD = { + log: "INFO", + info: "INFO", + debug: "DEBUG", + warn: "WARN", + error: "ERROR", +} as const; +type Method = keyof typeof LEVEL_BY_METHOD; +const METHODS = Object.keys(LEVEL_BY_METHOD) as Method[]; + +/** + * ` [] [LEVEL:] ` — the line format `plugin-kit`'s `loggingLayer` and this + * package's `runner.ts` both emit. Parsing it back recovers the plugin name and level that the + * formatting flattened, so the console can show `plugin:virtualhere` / `WARN` instead of one + * undifferentiated `runner` stream. + * + * A line that does NOT match is still shipped — attributed to `runner` at the console method's own + * level. A plugin calling bare `console.error("boom")` is precisely the case this must not lose. + */ +const STAMPED = + /^(\d{4}-\d{2}-\d{2}T[\d:.]+Z) \[([^\]\n]{1,64})\](?:[ \t]+([A-Z]{3,9}):)?[ \t]?([\s\S]*)$/; + +/** Lines per POST. Must stay ≤ the host's `MAX_LOG_BATCH` or a batch is rejected wholesale. */ +const BATCH = 256; +/** Queued lines before the oldest start dropping. ~2 MB worst case at the host's 2 KB cap. */ +const QUEUE_LIMIT = 1000; +/** A multi-line message (a stack trace) ships as one entry per line, capped here. */ +const MAX_LINES_PER_MESSAGE = 40; + +export interface LogShipperOptions { + /** Connection overrides. Defaults to the same zero-config resolution `connect()` uses. */ + connect?: ConnectOptions; + /** Flush cadence in ms (default 2000). */ + intervalMs?: number; +} + +export interface LogShipper { + /** Send whatever is queued right now. Never rejects. */ + flush: () => Promise; + /** Restore the original console methods and stop the timer. */ + stop: () => void; +} + +interface Line { + ts_ms: number; + level: string; + source: string; + msg: string; +} + +/** + * Split a rendered console call into shippable lines. + * + * Newlines become separate entries rather than one blob: the log viewer is line-oriented, and the + * payload that matters most here — an Effect `Cause.pretty` stack from a plugin that failed to + * start — is unreadable folded onto a single row. The cap keeps one pathological dump from + * evicting the ring on its own. + */ +const toLines = (method: Method, args: unknown[]): Line[] => { + const text = format(...args); + const m = STAMPED.exec(text); + const source = m?.[2] ?? "runner"; + const level = m?.[3] ?? LEVEL_BY_METHOD[method]; + const parsedTs = m?.[1] !== undefined ? Date.parse(m[1]) : Number.NaN; + const ts_ms = Number.isNaN(parsedTs) ? Date.now() : parsedTs; + const body = m?.[4] ?? text; + + const parts = body.split(/\r?\n/); + const kept = parts.slice(0, MAX_LINES_PER_MESSAGE); + if (parts.length > kept.length) { + kept.push(`… ${parts.length - kept.length} more line(s) suppressed`); + } + // A trailing newline yields one empty part; an all-empty message still ships one entry so the + // console never silently swallows a call. + const meaningful = kept.filter((l) => l.trim() !== ""); + return (meaningful.length > 0 ? meaningful : [""]).map((msg) => ({ + ts_ms, + level, + source, + msg, + })); +}; + +/** + * Patch the console to tee into the host's log ring, and start the flush timer. + * + * Install this ONLY in the managed runner (`runner-cli.ts`). A plugin's own CLI (`punktfunk-plugin-x + * doctor`) must keep its output local — an operator running a diagnostic in their terminal is not + * asking to write to the host's log. + */ +export const installLogShipper = ( + options: LogShipperOptions = {}, +): LogShipper => { + const queue: Line[] = []; + let droppedByOverflow = 0; + // True from the moment a flush claims a batch until its POST settles. Guards flush RE-ENTRY + // only — the interval can fire while a slow POST is still open, and two concurrent flushes + // would splice disjoint batches out of one queue and deliver them out of order. + // + // It deliberately does NOT gate `enqueue`. It used to, as a recursion guard, and that silently + // dropped every line logged during the few ms of a POST — which under load is a great many of + // them, and exactly the lines a busy plugin is producing. The recursion it was guarding is + // handled by the rule at the top of this file instead: nothing on the shipping path logs. + let shipping = false; + let stopped = false; + // Set once the host's URL/token/CA resolve. Before that (the host writes `plugin-token` as it + // boots, and the runner may well start first) lines keep queueing — those earliest lines are + // exactly the ones that explain a plugin failing to load. + let resolved: Awaited> | undefined; + let resolving = false; + // Consecutive failures, for backoff. The host being down is normal (restart, update) and must + // not mean a POST attempt every 2 s forever. + let failures = 0; + let skipTicks = 0; + + // The ORIGINAL function objects, unbound. `stop()` must put back exactly what it found: binding + // here and restoring the bound copy would leave a different function in place each cycle, so + // an install/stop/install sequence accumulates a wrapper per round. Calls go through `.call` + // below to keep `this` right without touching identity. + const original = Object.fromEntries(METHODS.map((m) => [m, console[m]])) as Record< + Method, + (...args: unknown[]) => void + >; + + const enqueue = (method: Method, args: unknown[]) => { + if (stopped) return; + try { + for (const line of toLines(method, args)) { + if (queue.length >= QUEUE_LIMIT) { + queue.shift(); + droppedByOverflow += 1; + } + queue.push(line); + } + } catch { + // Formatting a hostile object must never break the caller's console call. + } + }; + + for (const method of METHODS) { + console[method] = ((...args: unknown[]) => { + original[method].call(console, ...args); + enqueue(method, args); + }) as typeof console.log; + } + + const ensureResolved = async (): Promise => { + if (resolved) return true; + if (resolving) return false; + resolving = true; + try { + resolved = await resolveConfig(options.connect); + return true; + } catch { + // No token yet (or no host at all). Keep buffering and try again next tick. + return false; + } finally { + resolving = false; + } + }; + + /** Put a failed batch back at the FRONT, still honoring the cap (oldest lose). */ + const requeue = (batch: Line[]) => { + queue.unshift(...batch); + if (queue.length > QUEUE_LIMIT) { + droppedByOverflow += queue.length - QUEUE_LIMIT; + queue.splice(0, queue.length - QUEUE_LIMIT); + } + }; + + const flush = async (): Promise => { + if (stopped || shipping || queue.length === 0) return; + if (!(await ensureResolved()) || !resolved) return; + // Re-check after the await: `ensureResolved` yields, so another flush may have claimed the + // queue in the meantime. + if (stopped || shipping || queue.length === 0) return; + + shipping = true; + const batch = queue.splice(0, BATCH); + if (droppedByOverflow > 0) { + // Tell the operator the tail is incomplete rather than presenting a gap as continuity — + // the same contract the host's ring keeps with its `dropped` flag. + batch.unshift({ + ts_ms: Date.now(), + level: "WARN", + source: "runner", + msg: `log shipper dropped ${droppedByOverflow} line(s): the queue filled while the host was unreachable`, + }); + droppedByOverflow = 0; + } + + try { + const res = await resolved.fetch(`${resolved.url}/api/v1/plugins/logs`, { + method: "POST", + headers: { + "content-type": "application/json", + authorization: `Bearer ${resolved.token}`, + }, + body: JSON.stringify({ entries: batch }), + }); + if (!res.ok) { + // 4xx is our bug (a shape the host rejects) and retrying cannot fix it — drop the + // batch. 5xx/transport is the host's problem and worth keeping. + if (res.status >= 500) requeue(batch); + // A token that went stale (host re-keyed) resolves again from disk on the next tick. + if (res.status === 401) resolved = undefined; + failures += 1; + } else { + failures = 0; + } + } catch { + requeue(batch); + failures += 1; + } finally { + shipping = false; + // 2 s, 4 s, 8 s … capped at ~30 s while the host stays away. + skipTicks = failures === 0 ? 0 : Math.min(2 ** (failures - 1), 15); + } + }; + + // The most recent flush, so an explicit `flush()` can WAIT for a periodic one rather than hit + // the re-entry guard and return having sent nothing. That matters on the shutdown path: the + // runner flushes once more after its units' finalizers have run, and those last lines are the + // ones that say whether the shutdown was clean. The window is widest exactly when the host is + // slow — which is when the logs are worth most. + let inFlight: Promise = Promise.resolve(); + const runFlush = (): Promise => { + inFlight = flush(); + return inFlight; + }; + + const timer = setInterval(() => { + if (skipTicks > 0) { + skipTicks -= 1; + return; + } + void runFlush(); + }, options.intervalMs ?? 2_000); + // The runner parks on its own keep-alive handle; this timer must not be what holds the process + // open, or a runner with nothing to run would never exit. + timer.unref?.(); + + return { + flush: async () => { + skipTicks = 0; + // `flush` never rejects (every path is caught); `.catch` only keeps that a guarantee. + await inFlight.catch(() => {}); + await runFlush(); + }, + stop: () => { + stopped = true; + clearInterval(timer); + for (const method of METHODS) { + console[method] = original[method] as typeof console.log; + } + }, + }; +}; + +/** Exported for tests. */ +export const __test = { toLines, STAMPED }; diff --git a/sdk/src/runner-cli.ts b/sdk/src/runner-cli.ts index b021db85..b9ff67fb 100644 --- a/sdk/src/runner-cli.ts +++ b/sdk/src/runner-cli.ts @@ -22,6 +22,7 @@ // plugin store (crates/punktfunk-host/src/store), which installs one reviewed version of a // package that may live on somebody else's registry — but they are ordinary CLI flags too. import { Effect, Fiber } from "effect"; +import { installLogShipper } from "./log-ship.js"; import { addPlugins, listInstalled, removePlugins } from "./plugins.js"; import { discoverUnits, runner } from "./runner.js"; @@ -157,16 +158,30 @@ if (process.argv.includes("--list")) { // nothing at all: field report 2026-07-25 had it pinning a full core indefinitely, `strace` // showing a bare `clock_gettime` loop and nothing else. One idle handle is the whole fix. const keepAlive = setInterval(() => {}, 2 ** 31 - 1); + +// Tee this process's output to the host so the console's Logs page can show it. Installed HERE and +// not in `runner.ts`, so it covers the supervised run only: a plugin's own CLI builds the same +// layer graph, and an operator running `punktfunk-plugin-x doctor` in their terminal is not asking +// to write into the host's log. Must be installed before the runner starts — the lines that explain +// a plugin failing to load are the first ones out. +const shipper = installLogShipper(); + const fiber = Effect.runFork(runner(options)); let stopping = false; const shutdown = (signal: string) => { if (stopping) return process.exit(1); // second signal = get out now stopping = true; console.log(`${new Date().toISOString()} [runner] ${signal} — interrupting units…`); - void Effect.runPromise(Fiber.interrupt(fiber)).finally(() => process.exit(0)); + void Effect.runPromise(Fiber.interrupt(fiber)) + // Ship what the finalizers just said before the process goes away — a clean shutdown's + // last lines are the ones that tell you whether it WAS clean. + .finally(() => shipper.flush()) + .finally(() => process.exit(0)); }; process.on("SIGINT", () => shutdown("SIGINT")); process.on("SIGTERM", () => shutdown("SIGTERM")); await Effect.runPromise(Fiber.await(fiber)); -clearInterval(keepAlive); // every unit ended on its own — let the process exit +await shipper.flush(); // every unit ended on its own — don't leave their last lines unsent +shipper.stop(); +clearInterval(keepAlive); // …then let the process exit diff --git a/sdk/src/runner.ts b/sdk/src/runner.ts index 9ccad6e6..9a1732b3 100644 --- a/sdk/src/runner.ts +++ b/sdk/src/runner.ts @@ -41,10 +41,24 @@ export interface RunnerOptions { connect?: ConnectOptions; /** Restart backoff base (test seam). Default 1 s, capped at 60 s, jittered. */ restartBase?: Duration.Input; - /** Line sink. Default: stamped stdout. */ - log?: (line: string) => void; + /** + * Line sink. Default: stamped stdout, with `warn`/`error` going to the matching console method + * (hence stderr, and hence the right level in the console's log page — see `log-ship.ts`). + * + * `level` is optional so an existing `(line: string) => void` sink stays assignable. + */ + log?: (line: string, level?: RunnerLogLevel) => void; } +/** + * Severity of a runner line. Only three, because that is all the runner distinguishes: it is + * reporting on units, not producing application logs. + */ +export type RunnerLogLevel = "info" | "warn" | "error"; + +/** The sink shape used internally, with the level always supplied by the caller's default. */ +type LogSink = (line: string, level?: RunnerLogLevel) => void; + export interface Unit { /** Display name: the file stem, or the plugin package name. */ name: string; @@ -52,8 +66,12 @@ export interface Unit { file: string; } -const defaultLog = (line: string) => - console.log(`${new Date().toISOString()} ${line}`); +const defaultLog: LogSink = (line, level = "info") => { + const stamped = `${new Date().toISOString()} ${line}`; + if (level === "error") console.error(stamped); + else if (level === "warn") console.warn(stamped); + else console.log(stamped); +}; // ---- unit-file trust (the sshd rule, both halves) --------------------------------------------- @@ -225,7 +243,7 @@ const windowsPowershellEnv = (): Record => { }; /** Read a file's SDDL and apply [`windowsSddlUnsafeReason`]. Unreadable ACL ⇒ refuse. */ -const windowsFileIsSafe = (file: string, log: (l: string) => void): boolean => { +const windowsFileIsSafe = (file: string, log: LogSink): boolean => { const escaped = file.replace(/'/g, "''"); const res = spawnSync( windowsPowershell(), @@ -244,7 +262,7 @@ const windowsFileIsSafe = (file: string, log: (l: string) => void): boolean => { ); const sddl = res.status === 0 ? (res.stdout ?? "").trim() : ""; if (!sddl) { - log(`[runner] REFUSING ${file} — could not read its ACL`); + log(`[runner] REFUSING ${file} — could not read its ACL`, "error"); return false; } const reason = windowsSddlUnsafeReason(sddl, processSid()); @@ -253,6 +271,7 @@ const windowsFileIsSafe = (file: string, log: (l: string) => void): boolean => { `[runner] REFUSING ${file} — ${reason}. Reinstall the plugin with ` + `\`punktfunk-host plugins add\`, or re-own the file to Administrators and strip ` + `non-admin write ACEs (icacls).`, + "error", ); return false; } @@ -264,13 +283,14 @@ const windowsFileIsSafe = (file: string, log: (l: string) => void): boolean => { * could have written — group/world-writable mode on Unix; on Windows, an owner outside * SYSTEM/Administrators/TrustedInstaller or a write-capable ACE for a non-admin principal. */ -const fileIsSafe = (file: string, log: (l: string) => void): boolean => { +const fileIsSafe = (file: string, log: LogSink): boolean => { if (process.platform === "win32") return windowsFileIsSafe(file, log); try { const mode = fs.statSync(file).mode & 0o022; if (mode !== 0) { log( `[runner] REFUSING ${file} — group/world-writable (chmod go-w it first)`, + "error", ); return false; } @@ -285,7 +305,7 @@ const SCRIPT_EXTENSIONS = new Set([".ts", ".js", ".mjs", ".mts", ".cjs"]); /** Enumerate the operator's units: loose scripts plus installed plugin packages. */ export const discoverUnits = ( options: RunnerOptions = {}, - log: (l: string) => void = options.log ?? defaultLog, + log: LogSink = options.log ?? defaultLog, ): Unit[] => { const units: Unit[] = []; const scriptsDir = options.scriptsDir ?? path.join(configDir(), "scripts"); @@ -332,7 +352,7 @@ export const discoverUnits = ( if (!fileIsSafe(file, log)) return; units.push({ name, file }); } catch (e) { - log(`[runner] skipping ${name}: unreadable package.json (${e})`); + log(`[runner] skipping ${name}: unreadable package.json (${e})`, "warn"); } }; try { @@ -379,7 +399,7 @@ const attemptUnit = ( unit: Unit, attempt: number, options: RunnerOptions, - log: (l: string) => void, + log: LogSink, ): Effect.Effect<"plugin" | "script", unknown> => Effect.gen(function* () { const mod = (yield* Effect.tryPromise( @@ -431,7 +451,8 @@ export const superviseUnit = ( let attempt = 0; const once = Effect.suspend(() => { attempt += 1; - if (attempt > 1) log(`[${unit.name}] restarting (attempt ${attempt})`); + if (attempt > 1) + log(`[${unit.name}] restarting (attempt ${attempt})`, "warn"); return attemptUnit(unit, attempt, options, log); }); return once.pipe( @@ -446,13 +467,18 @@ export const superviseUnit = ( ), Effect.tapCause((cause) => Effect.sync(() => - log(`[${unit.name}] failed: ${Cause.pretty(cause).split("\n")[0]}`), + log( + `[${unit.name}] failed: ${Cause.pretty(cause).split("\n")[0]}`, + "error", + ), ), ), Effect.retry(restart), Effect.catchCause((cause) => // A retry schedule that gives up (it doesn't, but stay total) — log and end. - Effect.sync(() => log(`[${unit.name}] gave up: ${Cause.pretty(cause)}`)), + Effect.sync(() => + log(`[${unit.name}] gave up: ${Cause.pretty(cause)}`, "error"), + ), ), Effect.asVoid, ); diff --git a/sdk/test/log-ship.test.ts b/sdk/test/log-ship.test.ts new file mode 100644 index 00000000..a088db48 --- /dev/null +++ b/sdk/test/log-ship.test.ts @@ -0,0 +1,329 @@ +// The log shipper's contract: what it recovers from a formatted line, that it tees rather than +// swallows, that a POST failure neither loses lines nor spins, and that it stays bounded. +import { afterEach, describe, expect, test } from "bun:test"; +import { __test, installLogShipper } from "../src/log-ship.js"; + +const { toLines } = __test; + +const TOKEN = "ship-token"; + +interface Captured { + entries: { ts_ms: number; level: string; source: string; msg: string }[]; +} + +/** A host that records every batch, answering with whatever `status()` says. */ +const mockHost = (status: () => number = () => 204) => { + const batches: Captured[] = []; + const auth: (string | null)[] = []; + const server = Bun.serve({ + port: 0, + fetch: async (req) => { + const url = new URL(req.url); + if (url.pathname !== "/api/v1/plugins/logs") { + return new Response("not found", { status: 404 }); + } + auth.push(req.headers.get("authorization")); + batches.push((await req.json()) as Captured); + const s = status(); + return new Response(s === 204 ? null : "nope", { status: s }); + }, + }); + return { + batches, + auth, + url: `http://127.0.0.1:${server.port}`, + stop: () => server.stop(true), + }; +}; + +/** + * Run `body` with a shipper installed, always restoring the console. + * + * The console is swapped for a recorder BEFORE the shipper installs, so `seen` is what the shipper + * teed through to "stdout" — and the suite stays readable, since a test that logs 2000 lines would + * otherwise print all 2000. + */ +const withShipper = async ( + url: string, + body: ( + s: ReturnType, + seen: string[], + ) => Promise, +): Promise => { + const seen: string[] = []; + const real = { log: console.log, warn: console.warn, error: console.error }; + const record = (...a: unknown[]) => { + seen.push(String(a[0])); + }; + console.log = record; + console.warn = record; + console.error = record; + const shipper = installLogShipper({ + connect: { url, token: TOKEN }, + // Long enough that only explicit flushes fire — the tests drive the timing. + intervalMs: 60_000, + }); + try { + return await body(shipper, seen); + } finally { + shipper.stop(); + console.log = real.log; + console.warn = real.warn; + console.error = real.error; + } +}; + +describe("parsing a formatted line", () => { + test("recovers the plugin name and timestamp plugin-kit's format flattened", () => { + const [line] = toLines( + "log", + ["2026-08-03T10:11:12.345Z [virtualhere] holding nothing"], + ); + expect(line?.source).toBe("virtualhere"); + expect(line?.level).toBe("INFO"); + expect(line?.msg).toBe("holding nothing"); + expect(line?.ts_ms).toBe(Date.parse("2026-08-03T10:11:12.345Z")); + }); + + test("an explicit level in the line beats the console method", () => { + // plugin-kit renders Effect's level verbatim — "WARNING", not "WARN". The host coerces it. + const [line] = toLines("log", [ + "2026-08-03T10:11:12.345Z [virtualhere] WARNING: vhclient failed (ETIMEDOUT)", + ]); + expect(line?.level).toBe("WARNING"); + expect(line?.msg).toBe("vhclient failed (ETIMEDOUT)"); + }); + + test("the runner's own error lines keep their severity", () => { + const [line] = toLines("error", [ + "2026-08-03T10:11:12.345Z [virtualhere] failed: VhIpcError: no such binary", + ]); + expect(line?.source).toBe("virtualhere"); + expect(line?.level).toBe("ERROR"); + }); + + test("an unstamped call is kept, not dropped", () => { + // A plugin reaching for bare console.error is exactly the case that must not be lost. + const [line] = toLines("error", ["boom", { code: 7 }]); + expect(line?.source).toBe("runner"); + expect(line?.level).toBe("ERROR"); + expect(line?.msg).toContain("boom"); + expect(line?.msg).toContain("7"); + }); + + test("a multi-line message becomes one entry per line", () => { + const lines = toLines("log", [ + "2026-08-03T10:11:12.345Z [x] failed\n at foo\n at bar", + ]); + expect(lines.map((l) => l.msg)).toEqual(["failed", " at foo", " at bar"]); + // Every fragment keeps the original stamp, so the trace cannot interleave with other units. + expect(new Set(lines.map((l) => l.ts_ms)).size).toBe(1); + }); + + test("a pathological dump is capped and says so", () => { + const lines = toLines("log", [ + `2026-08-03T10:11:12.345Z [x] ${"line\n".repeat(200)}`, + ]); + expect(lines.length).toBeLessThanOrEqual(41); + expect(lines.at(-1)?.msg).toContain("more line(s) suppressed"); + }); +}); + +describe("shipping", () => { + let stopHost: (() => void) | undefined; + afterEach(() => { + stopHost?.(); + stopHost = undefined; + }); + + test("tees: stdout still gets the line, and the host gets it too", async () => { + const host = mockHost(); + stopHost = host.stop; + const seen = await withShipper(host.url, async (shipper, seen) => { + console.log("2026-08-03T10:11:12.345Z [virtualhere] hello"); + await shipper.flush(); + return seen; + }); + + // The original console still ran — journald/foreground output is never traded away. + expect(seen).toEqual(["2026-08-03T10:11:12.345Z [virtualhere] hello"]); + expect(host.batches).toHaveLength(1); + expect(host.batches[0]?.entries[0]).toMatchObject({ + source: "virtualhere", + level: "INFO", + msg: "hello", + }); + expect(host.auth[0]).toBe(`Bearer ${TOKEN}`); + }); + + test("a 5xx keeps the lines for the next flush", async () => { + let status = 500; + const host = mockHost(() => status); + stopHost = host.stop; + await withShipper(host.url, async (shipper) => { + console.log("2026-08-03T10:11:12.345Z [x] keep me"); + await shipper.flush(); + expect(host.batches).toHaveLength(1); + status = 204; + await shipper.flush(); + }); + // Re-sent rather than dropped: the host being down is not the line's fault. + expect(host.batches).toHaveLength(2); + expect(host.batches[1]?.entries[0]?.msg).toBe("keep me"); + }); + + test("a 4xx drops the batch instead of retrying forever", async () => { + const host = mockHost(() => 400); + stopHost = host.stop; + await withShipper(host.url, async (shipper) => { + console.log("2026-08-03T10:11:12.345Z [x] malformed"); + await shipper.flush(); + await shipper.flush(); + }); + // A shape the host rejects cannot be fixed by sending it again. + expect(host.batches).toHaveLength(1); + }); + + test("a line logged WHILE a POST is in flight is not lost", async () => { + // A plugin logging during the few ms of a POST is the normal case under load, not an edge + // one. An earlier version held a `shipping` flag across the whole `await fetch` and dropped + // everything enqueued in that window — silently, which is the worst way to lose a log line. + let release: (() => void) | undefined; + const held = new Promise((r) => { + release = r; + }); + let arrived: (() => void) | undefined; + // Resolves once the server actually has the request — i.e. the shipper is genuinely mid-POST. + // Logging merely "after calling flush()" proves nothing: flush yields at its own awaits long + // before the fetch starts, so the line would land in the pre-send queue and the test would + // pass against the broken code. + const received = new Promise((r) => { + arrived = r; + }); + const batches: Captured[] = []; + const server = Bun.serve({ + port: 0, + fetch: async (req) => { + batches.push((await req.json()) as Captured); + arrived?.(); + await held; // hold this POST open + return new Response(null, { status: 204 }); + }, + }); + stopHost = () => server.stop(true); + + await withShipper(`http://127.0.0.1:${server.port}`, async (shipper) => { + console.log("2026-08-03T10:11:12.345Z [x] before"); + const inFlight = shipper.flush(); + await received; + // The POST is open right now; this is the line that used to vanish. + console.log("2026-08-03T10:11:12.345Z [x] during"); + release?.(); + await inFlight; + await shipper.flush(); + }); + + const all = batches.flatMap((b) => b.entries.map((e) => e.msg)); + expect(all).toContain("before"); + expect(all).toContain("during"); + }); + + test("an explicit flush waits for an in-flight one instead of no-opping", async () => { + // This is the shutdown path. The runner flushes once more after its units' finalizers have + // run, and those last lines are the ones that say whether the shutdown WAS clean. If a + // periodic flush happened to be mid-POST, an explicit flush that simply returned would + // leave them unsent — and the window is widest exactly when the host is slow, which is when + // the logs matter most. + let release: (() => void) | undefined; + const held = new Promise((r) => { + release = r; + }); + let arrived: (() => void) | undefined; + const received = new Promise((r) => { + arrived = r; + }); + let first = true; + const batches: Captured[] = []; + const server = Bun.serve({ + port: 0, + fetch: async (req) => { + batches.push((await req.json()) as Captured); + if (first) { + first = false; + arrived?.(); + await held; + } + return new Response(null, { status: 204 }); + }, + }); + stopHost = () => server.stop(true); + + await withShipper(`http://127.0.0.1:${server.port}`, async (shipper) => { + console.log("2026-08-03T10:11:12.345Z [x] first"); + const slow = shipper.flush(); + await received; + console.log("2026-08-03T10:11:12.345Z [x] shutdown line"); + release?.(); + // The shutdown flush: must not return until the tail is actually sent. + await shipper.flush(); + await slow; + }); + + const all = batches.flatMap((b) => b.entries.map((e) => e.msg)); + expect(all).toContain("shutdown line"); + }); + + test("overlapping flushes do not double-send", async () => { + // The timer can fire while a slow POST is still open. Two concurrent flushes would splice + // disjoint batches out of one queue and deliver them out of order. + const host = mockHost(); + stopHost = host.stop; + await withShipper(host.url, async (shipper) => { + console.log("2026-08-03T10:11:12.345Z [x] one"); + await Promise.all([shipper.flush(), shipper.flush()]); + }); + expect(host.batches).toHaveLength(1); + expect(host.batches[0]?.entries).toHaveLength(1); + }); + + test("an unreachable host neither throws into the caller nor grows without bound", async () => { + // Nothing listening on this port. + await withShipper("http://127.0.0.1:1", async (shipper) => { + for (let i = 0; i < 2_000; i++) { + console.log(`2026-08-03T10:11:12.345Z [x] line ${i}`); + } + // The whole point: console.log above must not have thrown, and flush must not reject. + await shipper.flush(); + }); + }); + + test("overflow is announced, not silently swallowed", async () => { + const host = mockHost(); + stopHost = host.stop; + await withShipper(host.url, async (shipper) => { + // Overrun the 1000-line cap while the shipper has had no chance to drain. + for (let i = 0; i < 1_200; i++) { + console.log(`2026-08-03T10:11:12.345Z [x] line ${i}`); + } + await shipper.flush(); + }); + const first = host.batches[0]?.entries[0]; + expect(first?.level).toBe("WARN"); + expect(first?.msg).toContain("dropped"); + // The tail is what survived — the oldest lines are the ones that went. + expect(host.batches[0]?.entries[1]?.msg).toBe("line 200"); + }); + + test("stop() puts the real console back", async () => { + const host = mockHost(); + stopHost = host.stop; + const before = console.log; + const shipper = installLogShipper({ + connect: { url: host.url, token: TOKEN }, + intervalMs: 60_000, + }); + expect(console.log).not.toBe(before); + shipper.stop(); + expect(console.log).toBe(before); + }); +}); diff --git a/web/messages/de.json b/web/messages/de.json index 0f78fabe..9112fab4 100644 --- a/web/messages/de.json +++ b/web/messages/de.json @@ -319,7 +319,11 @@ "nav_stats": "Leistung", "nav_logs": "Logs", "logs_title": "Logs", - "logs_subtitle": "Der aktuelle Log-Stream des Hosts — live verfolgen, nach Level filtern, durchsuchen.", + "logs_subtitle": "Der aktuelle Log-Stream des Hosts und deiner Plugins — live verfolgen, nach Level filtern, durchsuchen.", + "logs_source_all": "Alle", + "logs_source_host": "Host", + "logs_source_plugins": "Plugins", + "logs_empty_plugins": "Noch keine Plugin-Ausgabe. Plugins loggen hier, sobald der Plugin-Runner läuft — prüfe `punktfunk-host plugins status`.", "logs_follow": "Folgen", "logs_pause": "Pause", "logs_clear": "Leeren", diff --git a/web/messages/en.json b/web/messages/en.json index 7104f83a..224dae17 100644 --- a/web/messages/en.json +++ b/web/messages/en.json @@ -319,7 +319,11 @@ "nav_stats": "Performance", "nav_logs": "Logs", "logs_title": "Logs", - "logs_subtitle": "The host's recent log stream — follow live, filter by level, search.", + "logs_subtitle": "The host's recent log stream, and your plugins' — follow live, filter by level, search.", + "logs_source_all": "All", + "logs_source_host": "Host", + "logs_source_plugins": "Plugins", + "logs_empty_plugins": "No plugin output yet. Plugins log here once the plugin runner is running — check `punktfunk-host plugins status`.", "logs_follow": "Follow", "logs_pause": "Pause", "logs_clear": "Clear", diff --git a/web/src/sections/Logs/LogsCard.tsx b/web/src/sections/Logs/LogsCard.tsx index bce3f6c0..c0dc62b1 100644 --- a/web/src/sections/Logs/LogsCard.tsx +++ b/web/src/sections/Logs/LogsCard.tsx @@ -38,6 +38,29 @@ const LEVEL_CLASS: Record = { const KEEP = 5_000; // accumulated entries (client memory bound) const SHOW = 1_000; // rendered rows (DOM bound) +/** + * Producer filter. The ring carries the host's own `tracing` events AND whatever the plugin runner + * ships up (`POST /api/v1/plugins/logs`), the latter targeted `plugin:`. Without this the two + * are interleaved with nothing but the target column to tell them apart, and "show me what my + * plugin said" — the question that sends people to `journalctl` — means knowing to type `plugin:` + * into the search box. + */ +const SOURCES = ["all", "host", "plugins"] as const; +type Source = (typeof SOURCES)[number]; + +/** The target prefix the host stamps on every runner-shipped line. */ +const PLUGIN_TARGET_PREFIX = "plugin:"; + +const matchesSource = (target: string, source: Source): boolean => + source === "all" || + (source === "plugins") === target.startsWith(PLUGIN_TARGET_PREFIX); + +const SOURCE_LABEL: Record string> = { + all: () => m.logs_source_all(), + host: () => m.logs_source_host(), + plugins: () => m.logs_source_plugins(), +}; + /** * Container: cursor-paged log polling. A non-empty page advances the cursor — a new query key, * so the next page fetches immediately and a backlog drains fast; an empty page leaves the key @@ -177,6 +200,7 @@ export const LogsCard: FC<{ onRetry, }) => { const [minLevel, setMinLevel] = useState("DEBUG"); + const [source, setSource] = useState("all"); const [search, setSearch] = useState(""); const listRef = useRef(null); @@ -186,11 +210,12 @@ export const LogsCard: FC<{ return entries.filter( (e) => (RANK[e.level] ?? 0) >= min && + matchesSource(e.target, source) && (q === "" || e.msg.toLowerCase().includes(q) || e.target.toLowerCase().includes(q)), ); - }, [entries, minLevel, search]); + }, [entries, minLevel, source, search]); const visible = useMemo(() => matched.slice(-SHOW), [matched]); const shareLabel = shareMode === "share" ? m.logs_share() : m.logs_copy(); @@ -233,6 +258,18 @@ export const LogsCard: FC<{ ))} +
+ {SOURCES.map((s) => ( + + ))} +
setSearch(e.target.value)} @@ -317,7 +354,15 @@ export const LogsCard: FC<{ ) : (

- {isLoading ? m.common_loading() : m.logs_empty()} + {isLoading + ? m.common_loading() + : // "No plugin output" has a specific, actionable cause that the generic + // "adjust the filter" line actively misdirects from: the runner is a + // separate service and is opt-in on Linux, so the usual reason for an + // empty Plugins view is that it simply isn't running. + source === "plugins" + ? m.logs_empty_plugins() + : m.logs_empty()}

)} diff --git a/web/src/stories/Logs.stories.tsx b/web/src/stories/Logs.stories.tsx index 85a7c4be..0112469b 100644 --- a/web/src/stories/Logs.stories.tsx +++ b/web/src/stories/Logs.stories.tsx @@ -64,6 +64,22 @@ const fixtureEntries: LogEntry[] = [ "punktfunk_host::encode", "NVENC opened 1920x1080 nv12 gop=inf rfi=on", ), + // Lines the plugin runner shipped up (`POST /api/v1/plugins/logs`), targeted `plugin:`. + // They share the ring and the cursor with the host's own, which is what the Host/Plugins + // filter exists to separate — so the fixture has to carry both to be worth screenshotting. + entry(9, "INFO", "plugin:runner", "starting virtualhere"), + entry( + 10, + "INFO", + "plugin:virtualhere", + "bound couch-deck.11 (Thrustmaster T300RS) for stream", + ), + entry( + 11, + "ERROR", + "plugin:virtualhere", + "vhclientx86_64 failed (ETIMEDOUT) — the VirtualHere client is not answering on /tmp/vhclient", + ), ]; const meta = {