From 8680a404dc794a6dfa90404818d2a1c21b41d56d Mon Sep 17 00:00:00 2001 From: Vinnie Falco Date: Fri, 30 Oct 2026 17:25:51 -0700 Subject: [PATCH 1/4] Give Workshop its own copies of the shared test fixtures `crates/workshop/server/tests/fixtures/realtime` now holds the five Realtime protocol examples, and the Workshop relay and UI tests read them from there instead of `crates/gateway/stt/`. `crates/workshop/ui/test/fixtures/replay` holds the 11 snapshot streams plus `ui-baseline.json` and `ui-metrics.json`, which `stt-replay.mjs` writes through `writeJson`. The Gateway's `baseline.json` and `metrics.json` lose their `ui` section, and `replay.rs` drops `ui` from `FOREIGN_SECTIONS`. - The drift check sits in `crates/build-xtask/tests/realtime_wire_conformance.rs`, because a Workshop crate may not depend on a Gateway crate. It compares the two Realtime fixture directories byte for byte and names the file and the first difference. - `docs-claims.mjs` drops the exemption for `crates/gateway/stt/`, so the `engine` and `harness` scan now applies one rule to every file. - The snapshot streams and the `ui` baselines are a frozen copy with no drift check. A Gateway edit to a replay fixture does not fail the Workshop suite. --- .../tests/realtime_wire_conformance.rs | 134 +++++++++++ .../api/tests/fixtures/replay/baseline.json | 90 ------- .../api/tests/fixtures/replay/metrics.json | 90 ------- crates/gateway/stt/api/tests/it/replay.rs | 5 +- .../fixtures/realtime/client-events.json | 37 +++ .../fixtures/realtime/effective-sessions.json | 44 ++++ .../fixtures/realtime/invalid-sequences.json | 224 +++++++++++++++++ .../fixtures/realtime/server-events.json | 153 ++++++++++++ .../fixtures/realtime/valid-sequences.json | 189 +++++++++++++++ .../server/tests/it/realtime_relay.rs | 10 +- crates/workshop/ui/test/agent-stt.mjs | 5 +- crates/workshop/ui/test/docs-claims.mjs | 8 +- .../ui/test/fixtures/audio/pcm16le-24khz.json | 8 + .../jfk-native-recaptured.snapshots.json | 225 ++++++++++++++++++ .../fixtures/replay/jfk-native.snapshots.json | 225 ++++++++++++++++++ .../scripted-agreed-shrink.snapshots.json | 65 +++++ .../replay/scripted-final-word.snapshots.json | 95 ++++++++ .../scripted-punctuation-case.snapshots.json | 75 ++++++ .../scripted-short-word-final.snapshots.json | 135 +++++++++++ ...scripted-silence-short-word.snapshots.json | 115 +++++++++ ...ed-skipped-word-then-speech.snapshots.json | 135 +++++++++++ .../scripted-sliding-window.snapshots.json | 95 ++++++++ ...ted-trailing-echo-same-pass.snapshots.json | 105 ++++++++ .../scripted-trailing-echo.snapshots.json | 105 ++++++++ .../ui/test/fixtures/replay/ui-baseline.json | 90 +++++++ .../ui/test/fixtures/replay/ui-metrics.json | 90 +++++++ crates/workshop/ui/test/pcm-worklet.mjs | 13 +- .../ui/test/realtime-wire-fixtures.mjs | 5 +- crates/workshop/ui/test/stt-replay.mjs | 48 ++-- crates/workshop/ui/test/stt-stream.mjs | 4 +- 30 files changed, 2397 insertions(+), 225 deletions(-) create mode 100644 crates/build-xtask/tests/realtime_wire_conformance.rs create mode 100644 crates/workshop/server/tests/fixtures/realtime/client-events.json create mode 100644 crates/workshop/server/tests/fixtures/realtime/effective-sessions.json create mode 100644 crates/workshop/server/tests/fixtures/realtime/invalid-sequences.json create mode 100644 crates/workshop/server/tests/fixtures/realtime/server-events.json create mode 100644 crates/workshop/server/tests/fixtures/realtime/valid-sequences.json create mode 100644 crates/workshop/ui/test/fixtures/audio/pcm16le-24khz.json create mode 100644 crates/workshop/ui/test/fixtures/replay/jfk-native-recaptured.snapshots.json create mode 100644 crates/workshop/ui/test/fixtures/replay/jfk-native.snapshots.json create mode 100644 crates/workshop/ui/test/fixtures/replay/scripted-agreed-shrink.snapshots.json create mode 100644 crates/workshop/ui/test/fixtures/replay/scripted-final-word.snapshots.json create mode 100644 crates/workshop/ui/test/fixtures/replay/scripted-punctuation-case.snapshots.json create mode 100644 crates/workshop/ui/test/fixtures/replay/scripted-short-word-final.snapshots.json create mode 100644 crates/workshop/ui/test/fixtures/replay/scripted-silence-short-word.snapshots.json create mode 100644 crates/workshop/ui/test/fixtures/replay/scripted-skipped-word-then-speech.snapshots.json create mode 100644 crates/workshop/ui/test/fixtures/replay/scripted-sliding-window.snapshots.json create mode 100644 crates/workshop/ui/test/fixtures/replay/scripted-trailing-echo-same-pass.snapshots.json create mode 100644 crates/workshop/ui/test/fixtures/replay/scripted-trailing-echo.snapshots.json create mode 100644 crates/workshop/ui/test/fixtures/replay/ui-baseline.json create mode 100644 crates/workshop/ui/test/fixtures/replay/ui-metrics.json diff --git a/crates/build-xtask/tests/realtime_wire_conformance.rs b/crates/build-xtask/tests/realtime_wire_conformance.rs new file mode 100644 index 000000000..5388fb7ad --- /dev/null +++ b/crates/build-xtask/tests/realtime_wire_conformance.rs @@ -0,0 +1,134 @@ +//! Conformance between the Gateway's Realtime wire fixtures and the copies +//! Workshop owns. +//! +//! Workshop reads the canonical Realtime examples from its own tree, because +//! a test input it does not own stops the Workshop UI suite and the +//! `workshop-server` relay test from running once the Gateway moves. A copy +//! drifts, so this test compares the copies byte for byte and names the file +//! and the first difference whenever they disagree. +//! +//! The check lives here because `build-xtask` is the one crate the product +//! rules let read both families; the workshop crates may not depend on a +//! gateway crate. Like `discovery_conformance.rs`, it is expected to be +//! deleted when the Gateway moves to its own repository: at that point the +//! Gateway copy stops existing in this tree, and the Workshop copy becomes +//! the only copy to keep. + +#![expect( + clippy::expect_used, + reason = "test helpers panic on setup failure, which is the desired behavior" +)] + +use std::path::{Path, PathBuf}; + +/// The Gateway's copies of the Realtime fixtures, relative to the repository +/// root. +const GATEWAY_FIXTURES: &str = "crates/gateway/stt/api/tests/fixtures/realtime"; + +/// Workshop's copies, relative to the repository root. The SPA suite and the +/// `workshop-server` relay test read these same files. +const WORKSHOP_FIXTURES: &str = "crates/workshop/server/tests/fixtures/realtime"; + +/// The fixture files, in the order the Workshop suites use them. A file the +/// Gateway adds later is reported by `the_gateway_and_workshop_fixture_sets_agree`. +const FIXTURES: [&str; 5] = [ + "client-events.json", + "effective-sessions.json", + "invalid-sequences.json", + "server-events.json", + "valid-sequences.json", +]; + +fn repository_root() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")) + .ancestors() + .nth(2) + .expect("build-xtask lives at /crates/build-xtask") + .to_path_buf() +} + +fn json_files(dir: &Path) -> Vec { + let entries = + std::fs::read_dir(dir).unwrap_or_else(|error| panic!("{} reads: {error}", dir.display())); + let mut names = entries + .map(|entry| { + entry + .expect("a fixture directory entry reads") + .file_name() + .to_str() + .expect("a fixture file name is UTF-8") + .to_owned() + }) + .filter(|name| { + Path::new(name) + .extension() + .is_some_and(|extension| extension.eq_ignore_ascii_case("json")) + }) + .collect::>(); + names.sort(); + names +} + +/// The byte offset of the first difference, or `None` when the two are equal. +/// +/// The length check comes last but cannot be skipped: `zip` stops at the +/// shorter input, so a copy that only gained or lost trailing bytes would +/// otherwise compare equal. +fn first_difference(left: &[u8], right: &[u8]) -> Option { + if let Some(offset) = left.iter().zip(right).position(|(a, b)| a != b) { + return Some(offset); + } + (left.len() != right.len()).then_some(left.len().min(right.len())) +} + +#[test] +fn every_workshop_copy_matches_the_gateway_original() { + let root = repository_root(); + let gateway_dir = root.join(GATEWAY_FIXTURES); + let workshop_dir = root.join(WORKSHOP_FIXTURES); + let mut drifts = Vec::new(); + + for name in FIXTURES { + let gateway = std::fs::read(gateway_dir.join(name)) + .unwrap_or_else(|error| panic!("{GATEWAY_FIXTURES}/{name} reads: {error}")); + let workshop = std::fs::read(workshop_dir.join(name)) + .unwrap_or_else(|error| panic!("{WORKSHOP_FIXTURES}/{name} reads: {error}")); + match first_difference(&gateway, &workshop) { + None => {} + Some(offset) => drifts.push(format!( + "{name} differs at byte {offset}: the Gateway has {} bytes, Workshop has {}. \ + Copy the Gateway file over the Workshop one, or change both together.", + gateway.len(), + workshop.len() + )), + } + } + + assert!( + drifts.is_empty(), + "the Workshop copies of the Realtime fixtures drifted from the Gateway's originals:\n{}", + drifts.join("\n") + ); +} + +#[test] +fn the_gateway_and_workshop_fixture_sets_agree() { + let root = repository_root(); + assert_eq!( + json_files(&root.join(GATEWAY_FIXTURES)), + json_files(&root.join(WORKSHOP_FIXTURES)), + "the two fixture directories hold different files; a fixture the Gateway adds or \ + removes needs the same change in the Workshop copy" + ); +} + +#[test] +fn a_trailing_byte_counts_as_a_difference() { + // `zip` stops at the shorter input, so this is the case a plain zipped + // comparison misses. A copy that only gains a trailing newline is drift. + assert_eq!(first_difference(b"abc", b"abc"), None); + assert_eq!(first_difference(b"abc", b"abd"), Some(2)); + assert_eq!(first_difference(b"abc", b"abc "), Some(3)); + assert_eq!(first_difference(b"abc ", b"abc"), Some(3)); + assert_eq!(first_difference(b"", b"x"), Some(0)); +} diff --git a/crates/gateway/stt/api/tests/fixtures/replay/baseline.json b/crates/gateway/stt/api/tests/fixtures/replay/baseline.json index a988dae65..4fa603b50 100644 --- a/crates/gateway/stt/api/tests/fixtures/replay/baseline.json +++ b/crates/gateway/stt/api/tests/fixtures/replay/baseline.json @@ -170,95 +170,5 @@ "partial_latency_ms": 321.3333333333332, "upsr": 0.1, "upwr": 0.3333333333333333 - }, - "ui": { - "jfk-native": { - "changed_pairs": 13, - "changed_words": 43, - "completed_words": 22, - "pairs": 21, - "upsr": 0.6190476190476191, - "upwr": 1.9545454545454546 - }, - "jfk-native-recaptured": { - "changed_pairs": 3, - "changed_words": 35, - "completed_words": 22, - "pairs": 22, - "upsr": 0.13636363636363635, - "upwr": 1.5909090909090908 - }, - "scripted-agreed-shrink": { - "changed_pairs": 3, - "changed_words": 4, - "completed_words": 5, - "pairs": 6, - "upsr": 0.5, - "upwr": 0.8 - }, - "scripted-final-word": { - "changed_pairs": 1, - "changed_words": 6, - "completed_words": 12, - "pairs": 11, - "upsr": 0.09090909090909091, - "upwr": 0.5 - }, - "scripted-punctuation-case": { - "changed_pairs": 6, - "changed_words": 18, - "completed_words": 9, - "pairs": 7, - "upsr": 0.8571428571428571, - "upwr": 2 - }, - "scripted-short-word-final": { - "changed_pairs": 3, - "changed_words": 8, - "completed_words": 10, - "pairs": 15, - "upsr": 0.2, - "upwr": 0.8 - }, - "scripted-silence-short-word": { - "changed_pairs": 1, - "changed_words": 2, - "completed_words": 10, - "pairs": 9, - "upsr": 0.1111111111111111, - "upwr": 0.2 - }, - "scripted-skipped-word-then-speech": { - "changed_pairs": 1, - "changed_words": 2, - "completed_words": 9, - "pairs": 12, - "upsr": 0.08333333333333333, - "upwr": 0.2222222222222222 - }, - "scripted-sliding-window": { - "changed_pairs": 3, - "changed_words": 20, - "completed_words": 15, - "pairs": 9, - "upsr": 0.3333333333333333, - "upwr": 1.3333333333333333 - }, - "scripted-trailing-echo": { - "changed_pairs": 1, - "changed_words": 4, - "completed_words": 15, - "pairs": 10, - "upsr": 0.1, - "upwr": 0.26666666666666666 - }, - "scripted-trailing-echo-same-pass": { - "changed_pairs": 1, - "changed_words": 4, - "completed_words": 15, - "pairs": 10, - "upsr": 0.1, - "upwr": 0.26666666666666666 - } } } diff --git a/crates/gateway/stt/api/tests/fixtures/replay/metrics.json b/crates/gateway/stt/api/tests/fixtures/replay/metrics.json index 45b6505c2..4afe80a01 100644 --- a/crates/gateway/stt/api/tests/fixtures/replay/metrics.json +++ b/crates/gateway/stt/api/tests/fixtures/replay/metrics.json @@ -170,95 +170,5 @@ "partial_latency_ms": 321.3333333333332, "upsr": 0.0, "upwr": 0.0 - }, - "ui": { - "jfk-native": { - "changed_pairs": 4, - "changed_words": 29, - "completed_words": 22, - "pairs": 22, - "upsr": 0.18181818181818182, - "upwr": 1.3181818181818181 - }, - "jfk-native-recaptured": { - "changed_pairs": 3, - "changed_words": 35, - "completed_words": 22, - "pairs": 22, - "upsr": 0.13636363636363635, - "upwr": 1.5909090909090908 - }, - "scripted-agreed-shrink": { - "changed_pairs": 1, - "changed_words": 2, - "completed_words": 5, - "pairs": 6, - "upsr": 0.16666666666666666, - "upwr": 0.4 - }, - "scripted-final-word": { - "changed_pairs": 1, - "changed_words": 5, - "completed_words": 12, - "pairs": 9, - "upsr": 0.1111111111111111, - "upwr": 0.4166666666666667 - }, - "scripted-punctuation-case": { - "changed_pairs": 1, - "changed_words": 1, - "completed_words": 9, - "pairs": 7, - "upsr": 0.14285714285714285, - "upwr": 0.1111111111111111 - }, - "scripted-short-word-final": { - "changed_pairs": 2, - "changed_words": 7, - "completed_words": 10, - "pairs": 13, - "upsr": 0.15384615384615385, - "upwr": 0.7 - }, - "scripted-silence-short-word": { - "changed_pairs": 1, - "changed_words": 2, - "completed_words": 10, - "pairs": 11, - "upsr": 0.09090909090909091, - "upwr": 0.2 - }, - "scripted-skipped-word-then-speech": { - "changed_pairs": 1, - "changed_words": 2, - "completed_words": 10, - "pairs": 13, - "upsr": 0.07692307692307693, - "upwr": 0.2 - }, - "scripted-sliding-window": { - "changed_pairs": 1, - "changed_words": 13, - "completed_words": 15, - "pairs": 9, - "upsr": 0.1111111111111111, - "upwr": 0.8666666666666667 - }, - "scripted-trailing-echo": { - "changed_pairs": 0, - "changed_words": 0, - "completed_words": 15, - "pairs": 10, - "upsr": 0, - "upwr": 0 - }, - "scripted-trailing-echo-same-pass": { - "changed_pairs": 0, - "changed_words": 0, - "completed_words": 15, - "pairs": 10, - "upsr": 0, - "upwr": 0 - } } } diff --git a/crates/gateway/stt/api/tests/it/replay.rs b/crates/gateway/stt/api/tests/it/replay.rs index 5bc20f362..d97c0f96a 100644 --- a/crates/gateway/stt/api/tests/it/replay.rs +++ b/crates/gateway/stt/api/tests/it/replay.rs @@ -31,9 +31,8 @@ const UPDATE_VARIABLE: &str = "RUNVIL_REPLAY_UPDATE"; const SCRIPTED_PREFIX: &str = "scripted-"; const NATIVE_FIXTURE: &str = "jfk-native-recaptured"; /// Sections no replay produces, kept as written: the native interim decode -/// timing and the Workshop take reducer's rendered-text metrics, which -/// `crates/workshop/ui/test/stt-replay.mjs` owns. -const FOREIGN_SECTIONS: [&str; 2] = ["native", "ui"]; +/// timing that the ignored native capture tests record. +const FOREIGN_SECTIONS: [&str; 1] = ["native"]; const SUMMARY_FILES: [&str; 2] = ["baseline", "metrics"]; fn fixture_dir() -> PathBuf { diff --git a/crates/workshop/server/tests/fixtures/realtime/client-events.json b/crates/workshop/server/tests/fixtures/realtime/client-events.json new file mode 100644 index 000000000..934df03c4 --- /dev/null +++ b/crates/workshop/server/tests/fixtures/realtime/client-events.json @@ -0,0 +1,37 @@ +{ + "input_audio_buffer_append": { + "type": "input_audio_buffer.append", + "audio": "AAABAP//", + "event_id": "client_append_1" + }, + "input_audio_buffer_clear": { + "type": "input_audio_buffer.clear" + }, + "input_audio_buffer_commit": { + "type": "input_audio_buffer.commit" + }, + "session_update": { + "type": "session.update", + "session": { + "type": "transcription", + "audio": { + "input": { + "format": { + "type": "audio/pcm", + "rate": 24000 + }, + "noise_reduction": null, + "transcription": { + "model": "realtime-transcribe", + "prompt": "meeting notes" + }, + "turn_detection": null + } + }, + "include": [ + "item.input_audio_transcription.hypothesis" + ] + }, + "event_id": "client_update_1" + } +} diff --git a/crates/workshop/server/tests/fixtures/realtime/effective-sessions.json b/crates/workshop/server/tests/fixtures/realtime/effective-sessions.json new file mode 100644 index 000000000..eabdbd567 --- /dev/null +++ b/crates/workshop/server/tests/fixtures/realtime/effective-sessions.json @@ -0,0 +1,44 @@ +{ + "default": { + "id": "sess_canonical", + "object": "realtime.transcription_session", + "type": "transcription", + "audio": { + "input": { + "format": { + "type": "audio/pcm", + "rate": 24000 + }, + "noise_reduction": null, + "transcription": { + "model": "realtime-transcribe", + "prompt": "" + }, + "turn_detection": null + } + }, + "include": [] + }, + "updated": { + "id": "sess_canonical", + "object": "realtime.transcription_session", + "type": "transcription", + "audio": { + "input": { + "format": { + "type": "audio/pcm", + "rate": 24000 + }, + "noise_reduction": null, + "transcription": { + "model": "realtime-transcribe", + "prompt": "meeting notes" + }, + "turn_detection": null + } + }, + "include": [ + "item.input_audio_transcription.hypothesis" + ] + } +} diff --git a/crates/workshop/server/tests/fixtures/realtime/invalid-sequences.json b/crates/workshop/server/tests/fixtures/realtime/invalid-sequences.json new file mode 100644 index 000000000..2d3c16a3d --- /dev/null +++ b/crates/workshop/server/tests/fixtures/realtime/invalid-sequences.json @@ -0,0 +1,224 @@ +{ + "append_after_precommit_failure": { + "input": { "message": { "type": "input_audio_buffer.append", "audio": "AA==", "event_id": "client_append_after_failure" }, "setup": { "pending_precommit_failure": true } }, + "expected_error": { "event_id": "evt_append_after_failure", "type": "error", "error": { "type": "invalid_request_error", "code": "precommit_transcription_failed", "message": "Further appends are rejected after accurate precommit failure", "param": "audio", "event_id": "client_append_after_failure" } }, + "effective_session_after": "default", + "keeps_connection_usable": true + }, + "append_invalid_base64": { + "input": { "message": { "type": "input_audio_buffer.append", "audio": "***", "event_id": "client_invalid_base64" } }, + "expected_error": { "event_id": "evt_invalid_base64", "type": "error", "error": { "type": "invalid_request_error", "code": "invalid_base64_audio", "message": "Audio must be valid Base64", "param": "audio", "event_id": "client_invalid_base64" } }, + "effective_session_after": "default", + "keeps_connection_usable": true + }, + "append_limit_exceeded": { + "input": { "message": { "type": "input_audio_buffer.append", "audio": "AA==", "event_id": "client_append_limit" }, "materialize": { "decoded_audio_bytes": 15728641 } }, + "expected_error": { "event_id": "evt_append_limit", "type": "error", "error": { "type": "invalid_request_error", "code": "audio_append_too_large", "message": "Decoded audio exceeds the 15 MiB append limit", "param": "audio", "event_id": "client_append_limit" } }, + "effective_session_after": "default", + "keeps_connection_usable": true + }, + "append_unknown_field": { + "input": { "message": { "type": "input_audio_buffer.append", "audio": "AA==", "extra": true, "event_id": "client_append_unknown" } }, + "expected_error": { "event_id": "evt_append_unknown", "type": "error", "error": { "type": "invalid_request_error", "code": "unknown_field", "message": "Unknown field extra", "param": "extra", "event_id": "client_append_unknown" } }, + "effective_session_after": "default", + "keeps_connection_usable": true + }, + "clear_unknown_field": { + "input": { "message": { "type": "input_audio_buffer.clear", "extra": true, "event_id": "client_clear_unknown" } }, + "expected_error": { "event_id": "evt_clear_unknown", "type": "error", "error": { "type": "invalid_request_error", "code": "unknown_field", "message": "Unknown field extra", "param": "extra", "event_id": "client_clear_unknown" } }, + "effective_session_after": "default", + "keeps_connection_usable": true + }, + "commit_short_audio": { + "input": { "message": { "type": "input_audio_buffer.commit", "event_id": "client_short_commit" }, "setup": { "buffered_audio_ms": 99 } }, + "expected_error": { "event_id": "evt_short_commit", "type": "error", "error": { "type": "invalid_request_error", "code": "audio_too_short", "message": "A commit requires at least 100 ms of audio", "param": "audio", "event_id": "client_short_commit" } }, + "effective_session_after": "default", + "keeps_connection_usable": true + }, + "commit_unknown_field": { + "input": { "message": { "type": "input_audio_buffer.commit", "extra": true, "event_id": "client_commit_unknown" } }, + "expected_error": { "event_id": "evt_commit_unknown", "type": "error", "error": { "type": "invalid_request_error", "code": "unknown_field", "message": "Unknown field extra", "param": "extra", "event_id": "client_commit_unknown" } }, + "effective_session_after": "default", + "keeps_connection_usable": true + }, + "dangling_pcm_byte_on_commit": { + "input": { "message": { "type": "input_audio_buffer.commit", "event_id": "client_odd_pcm" }, "setup": { "pending_pcm_bytes": [1] } }, + "expected_error": { "event_id": "evt_odd_pcm", "type": "error", "error": { "type": "invalid_request_error", "code": "invalid_pcm_audio", "message": "PCM16 audio ends with an incomplete sample", "param": "audio", "event_id": "client_odd_pcm" } }, + "effective_session_after": "default", + "keeps_connection_usable": true + }, + "excessive_queue_lag": { + "input": { "message": { "type": "input_audio_buffer.append", "audio": "AAA=", "event_id": "client_queue_lag" }, "setup": { "audio_lag_ms": 2001 } }, + "expected_error": { "event_id": "evt_queue_lag", "type": "error", "error": { "type": "overload_error", "code": "audio_queue_lag", "message": "Audio queue lag exceeds two seconds", "param": "audio", "event_id": "client_queue_lag" } }, + "effective_session_after": "default", + "keeps_connection_usable": true + }, + "invalid_client_event_id": { + "input": { "message": { "type": "input_audio_buffer.clear", "event_id": 7 } }, + "expected_error": { "event_id": "evt_invalid_client_id", "type": "error", "error": { "type": "invalid_request_error", "code": "invalid_event_id", "message": "event_id must be a string", "param": "event_id", "event_id": null } }, + "effective_session_after": "default", + "keeps_connection_usable": true + }, + "invalid_include_type": { + "input": { "message": { "type": "session.update", "session": { "type": "transcription", "include": "item.input_audio_transcription.hypothesis" }, "event_id": "client_include_type" } }, + "expected_error": { "event_id": "evt_include_type", "type": "error", "error": { "type": "invalid_request_error", "code": "invalid_include", "message": "session.include must be an array", "param": "session.include", "event_id": "client_include_type" } }, + "effective_session_after": "default", + "keeps_connection_usable": true + }, + "invalid_prompt_type": { + "input": { "message": { "type": "session.update", "session": { "type": "transcription", "audio": { "input": { "transcription": { "prompt": 7 } } } }, "event_id": "client_prompt_type" } }, + "expected_error": { "event_id": "evt_prompt_type", "type": "error", "error": { "type": "invalid_request_error", "code": "invalid_prompt", "message": "Transcription prompt must be a string", "param": "session.audio.input.transcription.prompt", "event_id": "client_prompt_type" } }, + "effective_session_after": "default", + "keeps_connection_usable": true + }, + "malformed_json": { + "input": { "wire_text": "{\"type\":\"input_audio_buffer.clear\"" }, + "expected_error": { "event_id": "evt_malformed_json", "type": "error", "error": { "type": "invalid_request_error", "code": "invalid_json", "message": "The client event is not valid JSON" } }, + "effective_session_after": "default", + "keeps_connection_usable": true + }, + "maximum_committed_items": { + "input": { "message": { "type": "input_audio_buffer.commit", "event_id": "client_item_limit" }, "setup": { "committed_items_finalizing": 4, "buffered_audio_ms": 100 } }, + "expected_error": { "event_id": "evt_item_limit", "type": "error", "error": { "type": "overload_error", "code": "too_many_committed_items", "message": "At most four committed items may finalize concurrently", "param": null, "event_id": "client_item_limit" } }, + "effective_session_after": "default", + "keeps_connection_usable": true + }, + "maximum_unfinalized_audio": { + "input": { "message": { "type": "input_audio_buffer.append", "audio": "AAA=", "event_id": "client_audio_limit" }, "setup": { "unfinalized_audio_ms": 30000 } }, + "expected_error": { "event_id": "evt_audio_limit", "type": "error", "error": { "type": "overload_error", "code": "too_much_unfinalized_audio", "message": "Unfinalized audio exceeds 30 seconds: 30000 ms retained, 1 ms requested", "param": "audio", "event_id": "client_audio_limit" } }, + "effective_session_after": "default", + "keeps_connection_usable": true + }, + "missing_append_audio": { + "input": { "message": { "type": "input_audio_buffer.append", "event_id": "client_missing_audio" } }, + "expected_error": { "event_id": "evt_missing_audio", "type": "error", "error": { "type": "invalid_request_error", "code": "missing_required_field", "message": "Missing required field audio", "param": "audio", "event_id": "client_missing_audio" } }, + "effective_session_after": "default", + "keeps_connection_usable": true + }, + "missing_client_event_type": { + "input": { "message": { "event_id": "client_missing_type" } }, + "expected_error": { "event_id": "evt_missing_type", "type": "error", "error": { "type": "invalid_request_error", "code": "missing_required_field", "message": "Missing required field type", "param": "type", "event_id": "client_missing_type" } }, + "effective_session_after": "default", + "keeps_connection_usable": true + }, + "missing_session": { + "input": { "message": { "type": "session.update", "event_id": "client_missing_session" } }, + "expected_error": { "event_id": "evt_missing_session", "type": "error", "error": { "type": "invalid_request_error", "code": "missing_required_field", "message": "Missing required field session", "param": "session", "event_id": "client_missing_session" } }, + "effective_session_after": "default", + "keeps_connection_usable": true + }, + "missing_session_type": { + "input": { "message": { "type": "session.update", "session": {}, "event_id": "client_missing_session_type" } }, + "expected_error": { "event_id": "evt_missing_session_type", "type": "error", "error": { "type": "invalid_request_error", "code": "missing_required_field", "message": "Missing required field session.type", "param": "session.type", "event_id": "client_missing_session_type" } }, + "effective_session_after": "default", + "keeps_connection_usable": true + }, + "non_null_noise_reduction": { + "input": { "message": { "type": "session.update", "session": { "type": "transcription", "audio": { "input": { "noise_reduction": { "type": "near_field" } } } }, "event_id": "client_noise_reduction" } }, + "expected_error": { "event_id": "evt_noise_reduction", "type": "error", "error": { "type": "invalid_request_error", "code": "unsupported_noise_reduction", "message": "Only null noise reduction is supported", "param": "session.audio.input.noise_reduction", "event_id": "client_noise_reduction" } }, + "effective_session_after": "default", + "keeps_connection_usable": true + }, + "non_null_turn_detection": { + "input": { "message": { "type": "session.update", "session": { "type": "transcription", "audio": { "input": { "turn_detection": { "type": "server_vad" } } } }, "event_id": "client_turn_detection" } }, + "expected_error": { "event_id": "evt_turn_detection", "type": "error", "error": { "type": "invalid_request_error", "code": "unsupported_turn_detection", "message": "Only null turn detection is supported", "param": "session.audio.input.turn_detection", "event_id": "client_turn_detection" } }, + "effective_session_after": "default", + "keeps_connection_usable": true + }, + "repeated_include": { + "input": { "message": { "type": "session.update", "session": { "type": "transcription", "include": ["item.input_audio_transcription.hypothesis", "item.input_audio_transcription.hypothesis"] }, "event_id": "client_repeated_include" } }, + "expected_error": { "event_id": "evt_repeated_include", "type": "error", "error": { "type": "invalid_request_error", "code": "unsupported_include", "message": "session.include repeats an include value", "param": "session.include", "event_id": "client_repeated_include" } }, + "effective_session_after": "default", + "keeps_connection_usable": true + }, + "session_audio_unknown_field": { + "input": { "message": { "type": "session.update", "session": { "type": "transcription", "audio": { "input": {}, "output": {} } }, "event_id": "client_audio_unknown" } }, + "expected_error": { "event_id": "evt_audio_unknown", "type": "error", "error": { "type": "invalid_request_error", "code": "unknown_field", "message": "Unknown field session.audio.output", "param": "session.audio.output", "event_id": "client_audio_unknown" } }, + "effective_session_after": "default", + "keeps_connection_usable": true + }, + "session_input_unknown_field": { + "input": { "message": { "type": "session.update", "session": { "type": "transcription", "audio": { "input": { "extra": true } } }, "event_id": "client_input_unknown" } }, + "expected_error": { "event_id": "evt_input_unknown", "type": "error", "error": { "type": "invalid_request_error", "code": "unknown_field", "message": "Unknown field session.audio.input.extra", "param": "session.audio.input.extra", "event_id": "client_input_unknown" } }, + "effective_session_after": "default", + "keeps_connection_usable": true + }, + "session_transcription_unknown_field": { + "input": { "message": { "type": "session.update", "session": { "type": "transcription", "audio": { "input": { "transcription": { "extra": true } } } }, "event_id": "client_transcription_unknown" } }, + "expected_error": { "event_id": "evt_transcription_unknown", "type": "error", "error": { "type": "invalid_request_error", "code": "unknown_field", "message": "Unknown field session.audio.input.transcription.extra", "param": "session.audio.input.transcription.extra", "event_id": "client_transcription_unknown" } }, + "effective_session_after": "default", + "keeps_connection_usable": true + }, + "session_unknown_field": { + "input": { "message": { "type": "session.update", "session": { "type": "transcription", "extra": true }, "event_id": "client_session_unknown" } }, + "expected_error": { "event_id": "evt_session_unknown", "type": "error", "error": { "type": "invalid_request_error", "code": "unknown_field", "message": "Unknown field session.extra", "param": "session.extra", "event_id": "client_session_unknown" } }, + "effective_session_after": "default", + "keeps_connection_usable": true + }, + "session_update_unknown_field": { + "input": { "message": { "type": "session.update", "session": { "type": "transcription" }, "extra": true, "event_id": "client_update_unknown" } }, + "expected_error": { "event_id": "evt_update_unknown", "type": "error", "error": { "type": "invalid_request_error", "code": "unknown_field", "message": "Unknown field extra", "param": "extra", "event_id": "client_update_unknown" } }, + "effective_session_after": "default", + "keeps_connection_usable": true + }, + "unknown_event_type": { + "input": { "message": { "type": "response.create", "event_id": "client_unknown_type" } }, + "expected_error": { "event_id": "evt_unknown_type", "type": "error", "error": { "type": "invalid_request_error", "code": "unsupported_event_type", "message": "Unsupported client event type response.create", "param": "type", "event_id": "client_unknown_type" } }, + "effective_session_after": "default", + "keeps_connection_usable": true + }, + "unknown_include": { + "input": { "message": { "type": "session.update", "session": { "type": "transcription", "include": ["item.input_audio_transcription.logprobs"] }, "event_id": "client_unknown_include" } }, + "expected_error": { "event_id": "evt_unknown_include", "type": "error", "error": { "type": "invalid_request_error", "code": "unsupported_include", "message": "Unsupported include value item.input_audio_transcription.logprobs", "param": "session.include", "event_id": "client_unknown_include" } }, + "effective_session_after": "default", + "keeps_connection_usable": true + }, + "unsupported_delay": { + "input": { "message": { "type": "session.update", "session": { "type": "transcription", "audio": { "input": { "transcription": { "delay_ms": 500 } } } }, "event_id": "client_delay" } }, + "expected_error": { "event_id": "evt_delay", "type": "error", "error": { "type": "invalid_request_error", "code": "unsupported_delay", "message": "Transcription delay is not supported", "param": "session.audio.input.transcription.delay_ms", "event_id": "client_delay" } }, + "effective_session_after": "default", + "keeps_connection_usable": true + }, + "unsupported_format_rate": { + "input": { "message": { "type": "session.update", "session": { "type": "transcription", "audio": { "input": { "format": { "type": "audio/pcm", "rate": 16000 } } } }, "event_id": "client_format_rate" } }, + "expected_error": { "event_id": "evt_format_rate", "type": "error", "error": { "type": "invalid_request_error", "code": "unsupported_audio_format", "message": "Only 24 kHz PCM audio is supported", "param": "session.audio.input.format.rate", "event_id": "client_format_rate" } }, + "effective_session_after": "default", + "keeps_connection_usable": true + }, + "unsupported_format_type": { + "input": { "message": { "type": "session.update", "session": { "type": "transcription", "audio": { "input": { "format": { "type": "audio/wav", "rate": 24000 } } } }, "event_id": "client_format_type" } }, + "expected_error": { "event_id": "evt_format_type", "type": "error", "error": { "type": "invalid_request_error", "code": "unsupported_audio_format", "message": "Only audio/pcm is supported", "param": "session.audio.input.format.type", "event_id": "client_format_type" } }, + "effective_session_after": "default", + "keeps_connection_usable": true + }, + "unsupported_keywords": { + "input": { "message": { "type": "session.update", "session": { "type": "transcription", "audio": { "input": { "transcription": { "keywords": ["Runvil"] } } } }, "event_id": "client_keywords" } }, + "expected_error": { "event_id": "evt_keywords", "type": "error", "error": { "type": "invalid_request_error", "code": "unsupported_keywords", "message": "Transcription keywords are not supported", "param": "session.audio.input.transcription.keywords", "event_id": "client_keywords" } }, + "effective_session_after": "default", + "keeps_connection_usable": true + }, + "unsupported_language": { + "input": { "message": { "type": "session.update", "session": { "type": "transcription", "audio": { "input": { "transcription": { "language": "en" } } } }, "event_id": "client_language" } }, + "expected_error": { "event_id": "evt_language", "type": "error", "error": { "type": "invalid_request_error", "code": "unsupported_language", "message": "A transcription language is not supported", "param": "session.audio.input.transcription.language", "event_id": "client_language" } }, + "effective_session_after": "default", + "keeps_connection_usable": true + }, + "unsupported_logprobs": { + "input": { "message": { "type": "session.update", "session": { "type": "transcription", "audio": { "input": { "transcription": { "logprobs": true } } } }, "event_id": "client_logprobs" } }, + "expected_error": { "event_id": "evt_logprobs", "type": "error", "error": { "type": "invalid_request_error", "code": "unsupported_logprobs", "message": "Transcription logprobs are not supported", "param": "session.audio.input.transcription.logprobs", "event_id": "client_logprobs" } }, + "effective_session_after": "default", + "keeps_connection_usable": true + }, + "unsupported_model": { + "input": { "message": { "type": "session.update", "session": { "type": "transcription", "audio": { "input": { "transcription": { "model": "whisper-1" } } } }, "event_id": "client_model" } }, + "expected_error": { "event_id": "evt_model", "type": "error", "error": { "type": "invalid_request_error", "code": "unsupported_model", "message": "Only realtime-transcribe is supported", "param": "session.audio.input.transcription.model", "event_id": "client_model" } }, + "effective_session_after": "default", + "keeps_connection_usable": true + }, + "wrong_session_type": { + "input": { "message": { "type": "session.update", "session": { "type": "realtime" }, "event_id": "client_session_type" } }, + "expected_error": { "event_id": "evt_session_type", "type": "error", "error": { "type": "invalid_request_error", "code": "unsupported_session_type", "message": "Only transcription sessions are supported", "param": "session.type", "event_id": "client_session_type" } }, + "effective_session_after": "default", + "keeps_connection_usable": true + } +} diff --git a/crates/workshop/server/tests/fixtures/realtime/server-events.json b/crates/workshop/server/tests/fixtures/realtime/server-events.json new file mode 100644 index 000000000..d3eddd325 --- /dev/null +++ b/crates/workshop/server/tests/fixtures/realtime/server-events.json @@ -0,0 +1,153 @@ +{ + "conversation_item_created": { + "event_id": "evt_item_created", + "type": "conversation.item.created", + "previous_item_id": null, + "item": { + "id": "item_alpha", + "type": "message", + "status": "completed", + "role": "user", + "content": [ + { + "type": "input_audio", + "transcript": null + } + ] + } + }, + "error_correlated": { + "event_id": "evt_error_correlated", + "type": "error", + "error": { + "type": "invalid_request_error", + "code": "unsupported_model", + "message": "Only realtime-transcribe is supported", + "param": "session.audio.input.transcription.model", + "event_id": "client_bad_update" + } + }, + "error_minimal": { + "event_id": "evt_error_minimal", + "type": "error", + "error": { + "type": "server_error", + "code": "internal_error", + "message": "Transcription failed" + } + }, + "error_uncorrelated": { + "event_id": "evt_error_uncorrelated", + "type": "error", + "error": { + "type": "server_error", + "code": "engine_replaced", + "message": "The speech engine was replaced", + "param": null, + "event_id": null + } + }, + "input_audio_buffer_cleared": { + "event_id": "evt_buffer_cleared", + "type": "input_audio_buffer.cleared" + }, + "input_audio_buffer_committed": { + "event_id": "evt_buffer_committed", + "type": "input_audio_buffer.committed", + "item_id": "item_alpha", + "previous_item_id": null + }, + "session_created": { + "event_id": "evt_session_created", + "type": "session.created", + "session": { + "id": "sess_canonical", + "object": "realtime.transcription_session", + "type": "transcription", + "audio": { + "input": { + "format": { + "type": "audio/pcm", + "rate": 24000 + }, + "noise_reduction": null, + "transcription": { + "model": "realtime-transcribe", + "prompt": "" + }, + "turn_detection": null + } + }, + "include": [] + } + }, + "session_updated": { + "event_id": "evt_session_updated", + "type": "session.updated", + "session": { + "id": "sess_canonical", + "object": "realtime.transcription_session", + "type": "transcription", + "audio": { + "input": { + "format": { + "type": "audio/pcm", + "rate": 24000 + }, + "noise_reduction": null, + "transcription": { + "model": "realtime-transcribe", + "prompt": "meeting notes" + }, + "turn_detection": null + } + }, + "include": [ + "item.input_audio_transcription.hypothesis" + ] + } + }, + "transcription_completed": { + "event_id": "evt_transcription_completed", + "type": "conversation.item.input_audio_transcription.completed", + "item_id": "item_alpha", + "content_index": 0, + "transcript": "Hello, world", + "usage": { + "type": "duration", + "seconds": 1.25 + } + }, + "transcription_delta": { + "event_id": "evt_transcription_delta", + "type": "conversation.item.input_audio_transcription.delta", + "item_id": "item_alpha", + "content_index": 0, + "delta": "Hello" + }, + "transcription_failed": { + "event_id": "evt_transcription_failed", + "type": "conversation.item.input_audio_transcription.failed", + "item_id": "item_beta", + "content_index": 0, + "error": { + "type": "server_error", + "code": "transcription_failed", + "message": "Authoritative transcription failed", + "param": "audio" + } + }, + "transcription_hypothesis": { + "event_id": "evt_transcription_hypothesis", + "type": "conversation.item.input_audio_transcription.hypothesis", + "item_id": "item_alpha", + "content_index": 0, + "revision": 3, + "transcript": "Hello, world", + "finalized": "Hello", + "agreed": ", wor", + "tentative": "ld", + "audio_start_ms": 0, + "audio_end_ms": 1250 + } +} diff --git a/crates/workshop/server/tests/fixtures/realtime/valid-sequences.json b/crates/workshop/server/tests/fixtures/realtime/valid-sequences.json new file mode 100644 index 000000000..44228ba3c --- /dev/null +++ b/crates/workshop/server/tests/fixtures/realtime/valid-sequences.json @@ -0,0 +1,189 @@ +{ + "clear_retires_only_uncommitted_input": { + "events": [ + { "direction": "client", "message": { "type": "input_audio_buffer.append", "audio": "AAABAP//", "event_id": "client_clear_append" } }, + { "direction": "client", "message": { "type": "input_audio_buffer.clear", "event_id": "client_clear" } }, + { "direction": "server", "message": { "event_id": "evt_clear", "type": "input_audio_buffer.cleared" } } + ], + "invariants": [ + "clear cancels and retires only uncommitted work", + "clear resets partial PCM and resampler state", + "clear leaves committed items untouched" + ] + }, + "configuration_snapshot_isolation": { + "events": [ + { "direction": "server", "message": { "event_id": "evt_snapshot_created", "type": "session.created", "session": { "id": "sess_snapshot", "object": "realtime.transcription_session", "type": "transcription", "audio": { "input": { "format": { "type": "audio/pcm", "rate": 24000 }, "noise_reduction": null, "transcription": { "model": "realtime-transcribe", "prompt": "" }, "turn_detection": null } }, "include": [] } } }, + { "direction": "client", "message": { "event_id": "client_prompt_first", "type": "session.update", "session": { "type": "transcription", "audio": { "input": { "transcription": { "model": "realtime-transcribe", "prompt": "first prompt" } } } } } }, + { "direction": "server", "message": { "event_id": "evt_prompt_first", "type": "session.updated", "session": { "id": "sess_snapshot", "object": "realtime.transcription_session", "type": "transcription", "audio": { "input": { "format": { "type": "audio/pcm", "rate": 24000 }, "noise_reduction": null, "transcription": { "model": "realtime-transcribe", "prompt": "first prompt" }, "turn_detection": null } }, "include": [] } } }, + { "direction": "client", "message": { "type": "input_audio_buffer.append", "audio": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" } }, + { "direction": "client", "message": { "event_id": "client_prompt_second", "type": "session.update", "session": { "type": "transcription", "audio": { "input": { "transcription": { "model": "realtime-transcribe", "prompt": "second prompt" } } } } } }, + { "direction": "server", "message": { "event_id": "evt_prompt_second", "type": "session.updated", "session": { "id": "sess_snapshot", "object": "realtime.transcription_session", "type": "transcription", "audio": { "input": { "format": { "type": "audio/pcm", "rate": 24000 }, "noise_reduction": null, "transcription": { "model": "realtime-transcribe", "prompt": "second prompt" }, "turn_detection": null } }, "include": [] } } }, + { "direction": "client", "message": { "type": "input_audio_buffer.commit" } }, + { "direction": "server", "message": { "event_id": "evt_snapshot_commit", "type": "input_audio_buffer.committed", "item_id": "item_snapshot", "previous_item_id": null } }, + { "direction": "server", "message": { "event_id": "evt_snapshot_item", "type": "conversation.item.created", "previous_item_id": null, "item": { "id": "item_snapshot", "type": "message", "status": "completed", "role": "user", "content": [{ "type": "input_audio", "transcript": null }] } } }, + { "direction": "server", "message": { "event_id": "evt_snapshot_done", "type": "conversation.item.input_audio_transcription.completed", "item_id": "item_snapshot", "content_index": 0, "transcript": "first prompt remained attached", "usage": { "type": "duration", "seconds": 0.25 } } } + ], + "invariants": [ + "the first append snapshots format model prompt and include", + "the later update affects only the next input buffer", + "the update response contains the complete effective session" + ] + }, + "durable_lineage": { + "events": [ + { "direction": "client", "message": { "type": "input_audio_buffer.append", "audio": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" } }, + { "direction": "client", "message": { "type": "input_audio_buffer.commit" } }, + { "direction": "server", "message": { "event_id": "evt_lineage_commit_a", "type": "input_audio_buffer.committed", "item_id": "item_lineage_a", "previous_item_id": null } }, + { "direction": "server", "message": { "event_id": "evt_lineage_item_a", "type": "conversation.item.created", "previous_item_id": null, "item": { "id": "item_lineage_a", "type": "message", "status": "completed", "role": "user", "content": [{ "type": "input_audio", "transcript": null }] } } }, + { "direction": "server", "message": { "event_id": "evt_lineage_done_a", "type": "conversation.item.input_audio_transcription.completed", "item_id": "item_lineage_a", "content_index": 0, "transcript": "first", "usage": { "type": "duration", "seconds": 0.2 } } }, + { "direction": "client", "message": { "type": "input_audio_buffer.append", "audio": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" } }, + { "direction": "client", "message": { "type": "input_audio_buffer.commit" } }, + { "direction": "server", "message": { "event_id": "evt_lineage_commit_b", "type": "input_audio_buffer.committed", "item_id": "item_lineage_b", "previous_item_id": "item_lineage_a" } }, + { "direction": "server", "message": { "event_id": "evt_lineage_item_b", "type": "conversation.item.created", "previous_item_id": "item_lineage_a", "item": { "id": "item_lineage_b", "type": "message", "status": "completed", "role": "user", "content": [{ "type": "input_audio", "transcript": null }] } } } + ], + "invariants": [ + "durable commit order survives removal of completed items", + "previous_item_id is determined only by commit order" + ] + }, + "first_event_readiness": { + "events": [ + { "direction": "server", "message": { "event_id": "evt_ready", "type": "session.created", "session": { "id": "sess_ready", "object": "realtime.transcription_session", "type": "transcription", "audio": { "input": { "format": { "type": "audio/pcm", "rate": 24000 }, "noise_reduction": null, "transcription": { "model": "realtime-transcribe", "prompt": "" }, "turn_detection": null } }, "include": [] } } } + ], + "invariants": [ + "session.created is the first server event", + "the connection starts ready with the advertised defaults" + ] + }, + "hypothesis_negotiation": { + "events": [ + { "direction": "client", "message": { "event_id": "client_hypothesis", "type": "session.update", "session": { "type": "transcription", "include": ["item.input_audio_transcription.hypothesis"] } } }, + { "direction": "server", "message": { "event_id": "evt_hypothesis_updated", "type": "session.updated", "session": { "id": "sess_hypothesis", "object": "realtime.transcription_session", "type": "transcription", "audio": { "input": { "format": { "type": "audio/pcm", "rate": 24000 }, "noise_reduction": null, "transcription": { "model": "realtime-transcribe", "prompt": "" }, "turn_detection": null } }, "include": ["item.input_audio_transcription.hypothesis"] } } }, + { "direction": "client", "message": { "type": "input_audio_buffer.append", "audio": "AAABAP//" } }, + { "direction": "server", "message": { "event_id": "evt_hypothesis", "type": "conversation.item.input_audio_transcription.hypothesis", "item_id": "item_hypothesis", "content_index": 0, "revision": 1, "transcript": "Hello", "finalized": "Hel", "agreed": "l", "tentative": "o", "audio_start_ms": 0, "audio_end_ms": 250 } }, + { "direction": "server", "message": { "event_id": "evt_hypothesis_2", "type": "conversation.item.input_audio_transcription.hypothesis", "item_id": "item_hypothesis", "content_index": 0, "revision": 2, "transcript": "Hello!", "finalized": "Hello", "agreed": "", "tentative": "!", "audio_start_ms": 0, "audio_end_ms": 300 } }, + { "direction": "server", "message": { "event_id": "evt_hypothesis_done", "type": "conversation.item.input_audio_transcription.completed", "item_id": "item_hypothesis", "content_index": 0, "transcript": "Hello", "usage": { "type": "duration", "seconds": 0.25 } } } + ], + "invariants": [ + "hypotheses are emitted only after exact include negotiation", + "hypothesis revisions increase monotonically", + "completion is authoritative" + ] + }, + "producer_hypothesis_ownership": { + "events": [ + { "direction": "client", "message": { "event_id": "client_producer_hypothesis", "type": "session.update", "session": { "type": "transcription", "include": ["item.input_audio_transcription.hypothesis"] } } }, + { "direction": "server", "message": { "event_id": "evt_producer_hypothesis_3", "type": "conversation.item.input_audio_transcription.hypothesis", "item_id": "item_producer_hypothesis", "content_index": 0, "revision": 3, "transcript": "ask not your country new tail first", "finalized": "ask not your country", "agreed": "", "tentative": " new tail first", "audio_start_ms": 0, "audio_end_ms": 8100 } }, + { "direction": "server", "message": { "event_id": "evt_producer_hypothesis_4", "type": "conversation.item.input_audio_transcription.hypothesis", "item_id": "item_producer_hypothesis", "content_index": 0, "revision": 4, "transcript": "ask not your country new tail second", "finalized": "ask not your country", "agreed": " new tail", "tentative": " second", "audio_start_ms": 0, "audio_end_ms": 8200 } } + ], + "invariants": [ + "producer snapshots partition finalized agreed and tentative text without overlap", + "each field owns its exact leading boundary whitespace", + "the visible transcript is the fields concatenated exactly once" + ] + }, + "immediate_commit_and_provisional_promotion": { + "events": [ + { "direction": "client", "message": { "type": "input_audio_buffer.append", "audio": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" } }, + { "direction": "client", "message": { "event_id": "client_commit", "type": "input_audio_buffer.commit" } }, + { "direction": "server", "message": { "event_id": "evt_commit", "type": "input_audio_buffer.committed", "item_id": "item_provisional", "previous_item_id": null } }, + { "direction": "server", "message": { "event_id": "evt_item", "type": "conversation.item.created", "previous_item_id": null, "item": { "id": "item_provisional", "type": "message", "status": "completed", "role": "user", "content": [{ "type": "input_audio", "transcript": null }] } } } + ], + "invariants": [ + "commit acknowledgment and item creation are immediate", + "the provisional item ID is promoted unchanged", + "server IDs are not derived from the client event ID" + ] + }, + "optional_client_ids_and_error_correlation": { + "events": [ + { "direction": "client", "message": { "type": "input_audio_buffer.append", "audio": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", "event_id": "client_optional" } }, + { "direction": "client", "message": { "type": "input_audio_buffer.commit" } }, + { "direction": "client", "message": { "event_id": "client_bad_update", "type": "session.update", "session": { "type": "transcription", "audio": { "input": { "transcription": { "model": "not-supported" } } } } } }, + { "direction": "server", "message": { "event_id": "evt_bad_update", "type": "error", "error": { "type": "invalid_request_error", "code": "unsupported_model", "message": "Only realtime-transcribe is supported", "param": "session.audio.input.transcription.model", "event_id": "client_bad_update" } } } + ], + "invariants": [ + "client event IDs are optional opaque strings", + "a client event ID is echoed only in its correlated error", + "server event IDs are independently generated" + ] + }, + "overlapping_items_reverse_completion": { + "events": [ + { "direction": "client", "message": { "type": "input_audio_buffer.append", "audio": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" } }, + { "direction": "client", "message": { "type": "input_audio_buffer.commit" } }, + { "direction": "server", "message": { "event_id": "evt_overlap_commit_a", "type": "input_audio_buffer.committed", "item_id": "item_overlap_a", "previous_item_id": null } }, + { "direction": "server", "message": { "event_id": "evt_overlap_item_a", "type": "conversation.item.created", "previous_item_id": null, "item": { "id": "item_overlap_a", "type": "message", "status": "completed", "role": "user", "content": [{ "type": "input_audio", "transcript": null }] } } }, + { "direction": "client", "message": { "type": "input_audio_buffer.append", "audio": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" } }, + { "direction": "client", "message": { "type": "input_audio_buffer.commit" } }, + { "direction": "server", "message": { "event_id": "evt_overlap_commit_b", "type": "input_audio_buffer.committed", "item_id": "item_overlap_b", "previous_item_id": "item_overlap_a" } }, + { "direction": "server", "message": { "event_id": "evt_overlap_item_b", "type": "conversation.item.created", "previous_item_id": "item_overlap_a", "item": { "id": "item_overlap_b", "type": "message", "status": "completed", "role": "user", "content": [{ "type": "input_audio", "transcript": null }] } } }, + { "direction": "server", "message": { "event_id": "evt_overlap_done_b", "type": "conversation.item.input_audio_transcription.completed", "item_id": "item_overlap_b", "content_index": 0, "transcript": "second", "usage": { "type": "duration", "seconds": 0.2 } } }, + { "direction": "server", "message": { "event_id": "evt_overlap_done_a", "type": "conversation.item.input_audio_transcription.completed", "item_id": "item_overlap_a", "content_index": 0, "transcript": "first", "usage": { "type": "duration", "seconds": 0.2 } } } + ], + "invariants": [ + "committed items finalize independently", + "completion order may differ from durable commit order", + "each item has one terminal outcome" + ] + }, + "pending_precommit_failure_clear": { + "events": [ + { "direction": "client", "message": { "type": "input_audio_buffer.append", "audio": "AAABAP//" } }, + { "direction": "client", "message": { "type": "input_audio_buffer.clear" } }, + { "direction": "server", "message": { "event_id": "evt_pending_clear", "type": "input_audio_buffer.cleared" } } + ], + "invariants": [ + "clear discards a pending precommit failure", + "clear does not invent an item or emit an item failure" + ] + }, + "pending_precommit_failure_commit": { + "events": [ + { "direction": "client", "message": { "type": "input_audio_buffer.append", "audio": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" } }, + { "direction": "client", "message": { "type": "input_audio_buffer.commit" } }, + { "direction": "server", "message": { "event_id": "evt_pending_commit", "type": "input_audio_buffer.committed", "item_id": "item_pending", "previous_item_id": null } }, + { "direction": "server", "message": { "event_id": "evt_pending_item", "type": "conversation.item.created", "previous_item_id": null, "item": { "id": "item_pending", "type": "message", "status": "completed", "role": "user", "content": [{ "type": "input_audio", "transcript": null }] } } }, + { "direction": "server", "message": { "event_id": "evt_pending_failed", "type": "conversation.item.input_audio_transcription.failed", "item_id": "item_pending", "content_index": 0, "error": { "type": "server_error", "code": "precommit_transcription_failed", "message": "Accurate precommit transcription failed", "param": null } } } + ], + "invariants": [ + "commit establishes the item before reporting the pending failure", + "the item receives exactly one terminal failure" + ] + }, + "saturated_commit_retry": { + "events": [ + { "direction": "client", "message": { "type": "input_audio_buffer.append", "audio": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" } }, + { "direction": "client", "message": { "event_id": "client_commit_saturated", "type": "input_audio_buffer.commit" } }, + { "direction": "server", "message": { "event_id": "evt_commit_saturated", "type": "error", "error": { "type": "overload_error", "code": "too_many_committed_items", "message": "At most four committed items may finalize concurrently", "param": null, "event_id": "client_commit_saturated" } } }, + { "direction": "server", "message": { "event_id": "evt_capacity_released", "type": "conversation.item.input_audio_transcription.completed", "item_id": "item_existing", "content_index": 0, "transcript": "released", "usage": { "type": "duration", "seconds": 0.2 } } }, + { "direction": "client", "message": { "event_id": "client_commit_retry", "type": "input_audio_buffer.commit" } }, + { "direction": "server", "message": { "event_id": "evt_commit_retry", "type": "input_audio_buffer.committed", "item_id": "item_retry", "previous_item_id": "item_existing" } }, + { "direction": "server", "message": { "event_id": "evt_item_retry", "type": "conversation.item.created", "previous_item_id": "item_existing", "item": { "id": "item_retry", "type": "message", "status": "completed", "role": "user", "content": [{ "type": "input_audio", "transcript": null }] } } } + ], + "invariants": [ + "commit reserves every bounded resource before detaching input", + "a saturated commit leaves the same provisional input retryable", + "retry promotes the original provisional item ID" + ] + }, + "standard_live_deltas_before_commit": { + "events": [ + { "direction": "client", "message": { "type": "input_audio_buffer.append", "audio": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" } }, + { "direction": "server", "message": { "event_id": "evt_delta_live", "type": "conversation.item.input_audio_transcription.delta", "item_id": "item_delta", "content_index": 0, "delta": "Hello" } }, + { "direction": "client", "message": { "type": "input_audio_buffer.commit" } }, + { "direction": "server", "message": { "event_id": "evt_delta_commit", "type": "input_audio_buffer.committed", "item_id": "item_delta", "previous_item_id": null } }, + { "direction": "server", "message": { "event_id": "evt_delta_item", "type": "conversation.item.created", "previous_item_id": null, "item": { "id": "item_delta", "type": "message", "status": "completed", "role": "user", "content": [{ "type": "input_audio", "transcript": null }] } } }, + { "direction": "server", "message": { "event_id": "evt_delta_rest", "type": "conversation.item.input_audio_transcription.delta", "item_id": "item_delta", "content_index": 0, "delta": " big world" } }, + { "direction": "server", "message": { "event_id": "evt_delta_done", "type": "conversation.item.input_audio_transcription.completed", "item_id": "item_delta", "content_index": 0, "transcript": "Hello big world", "usage": { "type": "duration", "seconds": 0.2 } } } + ], + "invariants": [ + "standard deltas arrive during the take, before commit", + "deltas only append to text already sent and hold back the last two agreed words until commit", + "commit sends the held-back rest once, after conversation item creation", + "accepted deltas are not internally dropped while the peer remains writable", + "completion remains authoritative" + ] + } +} diff --git a/crates/workshop/server/tests/it/realtime_relay.rs b/crates/workshop/server/tests/it/realtime_relay.rs index d2f84f29d..98f901251 100644 --- a/crates/workshop/server/tests/it/realtime_relay.rs +++ b/crates/workshop/server/tests/it/realtime_relay.rs @@ -49,10 +49,12 @@ struct FixtureUpstream { } fn canonical_server_frames() -> Vec { - let fixtures: serde_json::Value = serde_json::from_slice(include_bytes!( - "../../../../gateway/stt/api/tests/fixtures/realtime/valid-sequences.json" - )) - .expect("canonical Realtime sequences parse"); + // Workshop's own copy of the canonical sequences, under this crate's + // test fixtures. The Gateway keeps its own copy; `build-xtask`'s + // `realtime_wire_conformance` test reports drift between the two. + let fixtures: serde_json::Value = + serde_json::from_slice(include_bytes!("../fixtures/realtime/valid-sequences.json")) + .expect("canonical Realtime sequences parse"); [ "first_event_readiness", "hypothesis_negotiation", diff --git a/crates/workshop/ui/test/agent-stt.mjs b/crates/workshop/ui/test/agent-stt.mjs index 564fc9c31..c36332805 100644 --- a/crates/workshop/ui/test/agent-stt.mjs +++ b/crates/workshop/ui/test/agent-stt.mjs @@ -21,12 +21,13 @@ import { JSDOM } from "jsdom"; import { assertNoLeaks } from "./helpers/leak-check.mjs"; const testDir = path.dirname(fileURLToPath(import.meta.url)); +// The canonical Realtime fixtures Workshop owns, under the server's test +// fixtures. The Gateway keeps its own copies. const fixtureDir = path.join( testDir, "..", "..", - "..", - "gateway", "stt", "api", + "server", "tests", "fixtures", "realtime", diff --git a/crates/workshop/ui/test/docs-claims.mjs b/crates/workshop/ui/test/docs-claims.mjs index af6f2a81c..b6ad9e301 100644 --- a/crates/workshop/ui/test/docs-claims.mjs +++ b/crates/workshop/ui/test/docs-claims.mjs @@ -102,7 +102,13 @@ test("rulebooks use Engine, Harness, Host, and Plugin as the root AGENTS.md defi if (/\bhost(s|ed|ing)?\b|\bhost-/.test(text.replace(HOST_ALLOWED, " "))) { offenders.push(`${where} (lowercase "host": required "Host" for the application, another word for any other sense)`); } - const terms = file.startsWith("crates/gateway/stt/") ? /(? - typeof parsed === "number" ? JSON.rawJSON(context.source) : parsed, - ); - const next = sorted({ ...sections, [UI_SECTION]: value }); - writeFileSync(target, `${JSON.stringify(next, null, 2)}\n`); +function writeJson(file, value) { + writeFileSync(path.join(replayDir, file), `${JSON.stringify(value, null, 2)}\n`); } function fixtures() { @@ -63,7 +67,7 @@ function ratio(numerator, denominator) { return denominator === 0 ? 0 : numerator / denominator; } -/** Counts the editor text sequence the way the gateway replay counts transcripts. */ +/** Counts the editor text sequence the way the Gateway replay counts transcripts. */ function renderedMetrics(rendered) { const counts = rendered .slice(1) @@ -81,12 +85,12 @@ function renderedMetrics(rendered) { }; } -/** The gateway replay's UPWR rule, compared without division. */ +/** The Gateway replay's UPWR rule, compared without division. */ function thresholdViolations(current, baseline) { return Object.entries(current).flatMap(([name, metrics]) => { const base = baseline[name]; if (base === undefined) { - return [`${name} has no ui baseline section`]; + return [`${name} has no recorded baseline`]; } return metrics.changed_words * base.completed_words > base.changed_words * metrics.completed_words @@ -198,29 +202,29 @@ test("every replay fixture renders through the take reducer within its ui baseli } if (update) { - rewriteSection("metrics.json", current); + writeJson(METRICS_FILE, current); } else { assert.deepEqual( - readJson("metrics.json")[UI_SECTION], + readJson(METRICS_FILE), current, - `metrics.json ui section drifted; run with ${UPDATE_VARIABLE}=1 to regenerate it`, + `${METRICS_FILE} drifted; run with ${UPDATE_VARIABLE}=1 to regenerate it`, ); } - const recorded = readJson("baseline.json")[UI_SECTION] ?? {}; + const recorded = readJson(BASELINE_FILE); const unrecorded = Object.keys(current).filter((name) => !(name in recorded)); const baseline = { ...recorded }; if (update && unrecorded.length > 0) { for (const name of unrecorded) { baseline[name] = current[name]; } - rewriteSection("baseline.json", sorted(baseline)); + writeJson(BASELINE_FILE, sorted(baseline)); } const violations = thresholdViolations(current, baseline); assert.deepEqual(violations, [], `rendered-text thresholds are broken:\n${violations.join("\n")}`); assert.ok( aggregateUpwrBelowBaseline(current, baseline), - "aggregate rendered UPWR across fixtures is below the ui baseline aggregate", + "aggregate rendered UPWR across fixtures is below the recorded baseline aggregate", ); }); diff --git a/crates/workshop/ui/test/stt-stream.mjs b/crates/workshop/ui/test/stt-stream.mjs index 8fba4d2c2..70d760309 100644 --- a/crates/workshop/ui/test/stt-stream.mjs +++ b/crates/workshop/ui/test/stt-stream.mjs @@ -10,7 +10,9 @@ import { JSDOM } from "jsdom"; import { assertNoLeaks } from "./helpers/leak-check.mjs"; const uiDir = path.dirname(fileURLToPath(import.meta.url)); -const fixtures = path.join(uiDir, "..", "..", "..", "gateway", "stt", "api", "tests", "fixtures", "realtime"); +// Workshop's own copy of the canonical wire fixtures, beside the Rust test +// that reads the same files, the way `agent-frames.json` is shared. +const fixtures = path.join(uiDir, "..", "..", "server", "tests", "fixtures", "realtime"); const bundle = await esbuild.build({ stdin: { contents: ` From de0be65428368de18bc727e21d0b2a9de072389c Mon Sep 17 00:00:00 2001 From: Vinnie Falco Date: Fri, 30 Oct 2026 18:20:04 -0700 Subject: [PATCH 2/4] Stage the Gateway sidecar from a pinned release Workshop obtains its Gateway sidecar from a pinned release or a stub, and builds the Gateway from source only when nothing else supplies one. `gateway-pin/pin.json` records the release, the checksum and size of each asset, and the `min_gateway` floor. `tools/fetch-gateway-sidecar.mjs` downloads, verifies, and stages one target, `--stub` writes a compile-only file, and `cargo workshop` accepts `--gateway-binary` and `RUNVIL_GATEWAY_SIDECAR`. - A sidecar staged by `tools/fetch-gateway-sidecar.mjs` leaves a `.pinned` marker beside it, and `crates/workshop/desktop/build.rs` then leaves the file alone instead of refreshing it from `target//runvil-gateway`. - A placeholder pin yields an empty `RUNVIL_MIN_GATEWAY`, which `VersionCheck::compare` reads as `NoMinimumRecorded`. `announce` logs that case at `tracing::info!` and pushes no status line, so an unfilled pin turns the version check off with no visible sign. - `gateway-pin/pin.json` and both committed spec copies still hold `PLACEHOLDER`, so `nightly.yml` and `release-workshop.yml` fail at the fetch until someone runs `node tools/bump-gateway.mjs ` against a published Gateway release. --- .github/workflows/ci.yml | 18 +- .github/workflows/nightly.yml | 34 +- .github/workflows/release-workshop.yml | 41 +- .../workflows/workshop-installer-smoke.yml | 15 +- crates/build-workshop/src/main.rs | 71 +++- crates/build-workshop/src/main/pipeline.rs | 207 ++++++++-- crates/build-workshop/src/main/runner.rs | 3 +- .../build-workshop/src/main/tests-failures.rs | 10 + .../src/main/tests-gateway-source.rs | 356 ++++++++++++++++++ crates/build-workshop/src/main/tests.rs | 147 +++++--- crates/workshop/desktop/build.rs | 98 +++-- crates/workshop/gateway/Cargo.toml | 1 + crates/workshop/gateway/build.rs | 36 +- crates/workshop/gateway/src/lib.rs | 4 + crates/workshop/gateway/src/version-tests.rs | 165 ++++++++ crates/workshop/gateway/src/version.rs | 207 ++++++++++ crates/workshop/server/src/app/compose.rs | 7 + gateway-pin/README.md | 46 +++ gateway-pin/asyncapi.yaml | 8 + gateway-pin/openapi.yaml | 8 + gateway-pin/pin.json | 45 +++ tools/bump-gateway.mjs | 342 +++++++++++++++++ tools/bump-gateway.test.mjs | 239 ++++++++++++ tools/fetch-gateway-sidecar.mjs | 313 +++++++++++++++ tools/fetch-gateway-sidecar.test.mjs | 355 +++++++++++++++++ tools/gateway-archive.mjs | 131 +++++++ tools/gateway-archive.test.mjs | 120 ++++++ tools/gateway-pin.mjs | 208 ++++++++++ tools/gateway-pin.test.mjs | 169 +++++++++ tools/stage-gateway-sidecar.mjs | 77 +++- tools/stage-gateway-sidecar.test.mjs | 84 +++++ 31 files changed, 3381 insertions(+), 184 deletions(-) create mode 100644 crates/build-workshop/src/main/tests-gateway-source.rs create mode 100644 crates/workshop/gateway/src/version-tests.rs create mode 100644 crates/workshop/gateway/src/version.rs create mode 100644 gateway-pin/README.md create mode 100644 gateway-pin/asyncapi.yaml create mode 100644 gateway-pin/openapi.yaml create mode 100644 gateway-pin/pin.json create mode 100644 tools/bump-gateway.mjs create mode 100644 tools/bump-gateway.test.mjs create mode 100644 tools/fetch-gateway-sidecar.mjs create mode 100644 tools/fetch-gateway-sidecar.test.mjs create mode 100644 tools/gateway-archive.mjs create mode 100644 tools/gateway-archive.test.mjs create mode 100644 tools/gateway-pin.mjs create mode 100644 tools/gateway-pin.test.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9bce4ea9f..594c344f5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -149,11 +149,8 @@ jobs: npm ci --prefix crates/workshop npm ci --prefix crates/gateway/config-ui/ui - - name: Build featureless Gateway - run: cargo build --locked -p gateway --no-default-features - - - name: Stage Gateway sidecar - run: node tools/stage-gateway-sidecar.mjs stage --target x86_64-pc-windows-msvc --source target/debug/runvil-gateway.exe + - name: Stage a stub Gateway sidecar + run: node tools/fetch-gateway-sidecar.mjs --stub --target x86_64-pc-windows-msvc # workshop-server-api is the desktop app's re-export view of workshop-server, # so it runs in this partition with the crates it fronts and stays @@ -228,11 +225,8 @@ jobs: npm ci --prefix crates/workshop npm ci --prefix crates/gateway/config-ui/ui - - name: Build featureless Gateway - run: cargo build --locked -p gateway --no-default-features - - - name: Stage Gateway sidecar - run: node tools/stage-gateway-sidecar.mjs stage --target x86_64-unknown-linux-gnu --source target/debug/runvil-gateway + - name: Stage a stub Gateway sidecar + run: node tools/fetch-gateway-sidecar.mjs --stub --target x86_64-unknown-linux-gnu - name: Build (workshop, Linux) run: cargo build --locked -p workshop @@ -317,7 +311,9 @@ jobs: # crypto backend, aws-lc-rs. This fails when `ring` re-enters its # normal-edge closure (through a reqwest bump or a Tauri plugin default) # and when `cargo tree` itself cannot run; only a successful, ring-free - # result passes. + # result passes. This is a Gateway-side check: it reads the Gateway + # crate's dependency closure, which moves with the Gateway, so it stays + # here until that check moves too. - name: One rustls crypto backend in the gateway shell: bash run: | diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index f20d45df1..1521f6a74 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -164,10 +164,6 @@ jobs: working-directory: crates/workshop run: npm ci - - name: Install config UI dependencies - working-directory: crates/gateway/config-ui/ui - run: npm ci - - name: Install Tauri system packages if: startsWith(matrix.name, 'linux') run: | @@ -179,30 +175,26 @@ jobs: # tauri.conf.json ships the gateway via bundle.externalBin, which # resolves binaries/runvil-gateway- at bundle - # time; build and stage it before tauri-action runs. - - name: Build and stage the gateway sidecar + # time; stage the pinned release binary before tauri-action runs. + # + # gateway-pin/pin.json must name a published Gateway release before + # this job can run: the fetch verifies the asset's sha256 against the + # pin and refuses a placeholder. Fill it with + # `node tools/bump-gateway.mjs `. + - name: Stage the pinned Gateway sidecar shell: bash run: | set -e case "${{ matrix.name }}" in - windows) triple="x86_64-pc-windows-msvc"; ext=".exe" ;; - macos-arm) triple="aarch64-apple-darwin"; ext="" ;; - macos-intel) triple="x86_64-apple-darwin"; ext="" ;; + windows) triple="x86_64-pc-windows-msvc"; ext=".exe" ;; + macos-arm) triple="aarch64-apple-darwin"; ext="" ;; + macos-intel) triple="x86_64-apple-darwin"; ext="" ;; linux) triple="x86_64-unknown-linux-gnu"; ext="" ;; linux-arm) triple="aarch64-unknown-linux-gnu"; ext="" ;; esac - case "${{ matrix.args }}" in - *--target*) - cargo build --release --locked -p gateway --target "$triple" - src="target/$triple/release/runvil-gateway$ext" - ;; - *) - cargo build --release --locked -p gateway - src="target/release/runvil-gateway$ext" - ;; - esac - mkdir -p crates/workshop/desktop/binaries - cp "$src" "crates/workshop/desktop/binaries/runvil-gateway-$triple$ext" + node tools/fetch-gateway-sidecar.mjs --target "$triple" + staged="crates/workshop/desktop/binaries/runvil-gateway-$triple$ext" + [ -f "$staged" ] || { echo "no staged sidecar at $staged"; exit 1; } - name: Build the app uses: tauri-apps/tauri-action@84b9d35b5fc46c1e45415bdb6144030364f7ebc5 # v0 diff --git a/.github/workflows/release-workshop.yml b/.github/workflows/release-workshop.yml index b398a9743..239987df7 100644 --- a/.github/workflows/release-workshop.yml +++ b/.github/workflows/release-workshop.yml @@ -120,12 +120,6 @@ jobs: working-directory: crates/workshop run: npm ci - # The gateway sidecar build needs the config UI's esbuild pipeline - # (default `config-ui` feature). - - name: Install config UI dependencies - working-directory: crates/gateway/config-ui/ui - run: npm ci - - name: Install Tauri system packages if: startsWith(matrix.name, 'linux') run: | @@ -142,34 +136,31 @@ jobs: # tauri.conf.json ships the gateway via bundle.externalBin, which # resolves binaries/runvil-gateway- at bundle - # time; build and stage it before tauri-action runs. - - name: Build and stage the gateway sidecar + # time; stage the pinned release binary before tauri-action runs. + # + # gateway-pin/pin.json must name a published Gateway release before + # this job can run: the fetch verifies the asset's sha256 against the + # pin and refuses a placeholder. Fill it with + # `node tools/bump-gateway.mjs `. + - name: Stage the pinned Gateway sidecar shell: bash run: | set -e case "${{ matrix.name }}" in - windows) triple="x86_64-pc-windows-msvc"; ext=".exe" ;; - macos-arm) triple="aarch64-apple-darwin"; ext="" ;; - macos-intel) triple="x86_64-apple-darwin"; ext="" ;; + windows) triple="x86_64-pc-windows-msvc"; ext=".exe" ;; + macos-arm) triple="aarch64-apple-darwin"; ext="" ;; + macos-intel) triple="x86_64-apple-darwin"; ext="" ;; linux) triple="x86_64-unknown-linux-gnu"; ext="" ;; linux-arm) triple="aarch64-unknown-linux-gnu"; ext="" ;; esac - case "${{ matrix.args }}" in - *--target*) - cargo build --release --locked -p gateway --target "$triple" - src="target/$triple/release/runvil-gateway$ext" - ;; - *) - cargo build --release --locked -p gateway - src="target/release/runvil-gateway$ext" - ;; - esac - mkdir -p crates/workshop/desktop/binaries + node tools/fetch-gateway-sidecar.mjs --target "$triple" + # The staged Gateway must be the release the pin names. The pin + # is the only Gateway version authority now: Workshop and the + # Gateway release on their own schedules. staged="crates/workshop/desktop/binaries/runvil-gateway-$triple$ext" - cp "$src" "$staged" + [ -f "$staged" ] || { echo "no staged sidecar at $staged"; exit 1; } + expected="runvil-gateway $(node -e 'console.log(require("./gateway-pin/pin.json").version)')" output=$("$staged" --version) - version="${{ needs.prepare.outputs.version }}" - expected="runvil-gateway $version" [ "$output" = "$expected" ] || { echo "staged Gateway printed '$output', expected '$expected'"; exit 1; } - name: Build the signed release app diff --git a/.github/workflows/workshop-installer-smoke.yml b/.github/workflows/workshop-installer-smoke.yml index 60256f358..12d508389 100644 --- a/.github/workflows/workshop-installer-smoke.yml +++ b/.github/workflows/workshop-installer-smoke.yml @@ -7,6 +7,10 @@ on: - crates/build-workshop/** - crates/workshop/desktop/installer.nsi - crates/workshop/desktop/tauri*.conf.json + - gateway-pin/** + - tools/fetch-gateway-sidecar.mjs + - tools/gateway-archive.mjs + - tools/gateway-pin.mjs - tools/stage-gateway-sidecar.mjs workflow_dispatch: @@ -31,11 +35,12 @@ jobs: working-directory: crates/workshop run: npm ci - - name: Build featureless Gateway - run: cargo build --locked -p gateway --no-default-features - - - name: Stage Gateway sidecar - run: node tools/stage-gateway-sidecar.mjs stage --target x86_64-pc-windows-msvc --source target/debug/runvil-gateway.exe + # This job compiles the unsigned NSIS installer and installs and runs + # nothing, so the sidecar only has to exist: tauri-build needs a file + # at the externalBin path, and the bundler needs one to package. A + # stub costs nothing and needs no Gateway release. + - name: Stage a stub Gateway sidecar + run: node tools/fetch-gateway-sidecar.mjs --stub --target x86_64-pc-windows-msvc - name: Compile unsigned debug NSIS installer uses: tauri-apps/tauri-action@84b9d35b5fc46c1e45415bdb6144030364f7ebc5 # v0 diff --git a/crates/build-workshop/src/main.rs b/crates/build-workshop/src/main.rs index 40161203d..40825732d 100644 --- a/crates/build-workshop/src/main.rs +++ b/crates/build-workshop/src/main.rs @@ -1,5 +1,11 @@ -//! Builds the Runvil Gateway, stages it for Tauri, builds Workshop, -//! and removes the temporary staged sidecar. +//! Builds Workshop and stages its Gateway sidecar for Tauri. +//! +//! The sidecar comes from one of four places, in this order: the +//! `--gateway-binary` flag, the `RUNVIL_GATEWAY_SIDECAR` environment +//! variable, a sidecar already staged under `crates/workshop/desktop/binaries/`, +//! or a Gateway built from this workspace. Only the last compiles the +//! Gateway, so a machine with a downloaded or hand-staged Gateway builds +//! Workshop without touching Gateway source. use std::ffi::OsString; use std::fmt; @@ -17,16 +23,27 @@ mod runner; use pipeline::build_workshop; use runner::install_interrupt_handler; +/// The environment variable naming a Gateway binary to stage, so CI can +/// point at a staged file without passing a flag through Tauri. +const GATEWAY_SIDECAR_VARIABLE: &str = "RUNVIL_GATEWAY_SIDECAR"; + const USAGE: &str = "\ -Build Runvil Gateway and Workshop together. +Build Workshop, staging its Gateway sidecar. USAGE: - cargo workshop [--release] [--target ] + cargo workshop [--release] [--target ] [--gateway-binary ] OPTIONS: - --release Build both products with Cargo's release profile - --target Build both products for this target triple - -h, --help Print this help + --release Build Workshop with Cargo's release profile + --target Build for this target triple + --gateway-binary Stage this Gateway binary instead of building one + -h, --help Print this help + +The Gateway sidecar comes from --gateway-binary, then the +RUNVIL_GATEWAY_SIDECAR environment variable, then a sidecar already staged +under crates/workshop/desktop/binaries/, and only otherwise from +`cargo build -p gateway`. A sidecar that was already staged is left in +place, so the next build reuses it. "; #[derive(Clone, Copy, Debug, Eq, PartialEq)] @@ -48,6 +65,7 @@ impl Profile { struct BuildRequest { profile: Profile, target: Option, + gateway_binary: Option, } #[derive(Clone, Copy, Debug, Eq, PartialEq)] @@ -61,6 +79,7 @@ struct CommandSpec { program: PathBuf, args: Vec, current_dir: PathBuf, + env: Vec<(OsString, OsString)>, output_mode: OutputMode, } @@ -113,6 +132,10 @@ struct BuildEnvironment { target_root: PathBuf, cargo: PathBuf, node: PathBuf, + /// The Gateway binary named by `RUNVIL_GATEWAY_SIDECAR`, when set: the + /// path a CI job stages and points the build at, so no flag has to pass + /// through Tauri. + gateway_sidecar: Option, } impl BuildEnvironment { @@ -152,11 +175,21 @@ impl BuildEnvironment { .ok_or_else(|| { anyhow::anyhow!("Cargo did not provide the executable used for this command") })?; + let gateway_sidecar = match std::env::var_os(GATEWAY_SIDECAR_VARIABLE) { + Some(value) if value.is_empty() => { + return Err(anyhow::anyhow!( + "{GATEWAY_SIDECAR_VARIABLE} must not be empty" + )); + } + Some(value) => Some(PathBuf::from(value)), + None => None, + }; Ok(Self { workspace_root, target_root, cargo, node: PathBuf::from("node"), + gateway_sidecar, }) } } @@ -188,6 +221,7 @@ fn parse_arguments(args: &[String]) -> Result { let mut profile = Profile::Debug; let mut release_seen = false; let mut target = None; + let mut gateway_binary = None; let mut index = 0; while index < args.len() { match args[index].as_str() { @@ -214,6 +248,23 @@ fn parse_arguments(args: &[String]) -> Result { target = Some(value.clone()); index += 2; } + "--gateway-binary" if gateway_binary.is_some() => { + return Err(anyhow::anyhow!( + "duplicate argument `--gateway-binary`\n\n{USAGE}" + )); + } + "--gateway-binary" => { + let value = args.get(index + 1).ok_or_else(|| { + anyhow::anyhow!("argument `--gateway-binary` needs a path\n\n{USAGE}") + })?; + if value.starts_with('-') || value.trim().is_empty() { + return Err(anyhow::anyhow!( + "argument `--gateway-binary` needs a path, got `{value}`\n\n{USAGE}" + )); + } + gateway_binary = Some(PathBuf::from(value)); + index += 2; + } argument => { return Err(anyhow::anyhow!( "unsupported argument `{argument}`\n\n{USAGE}" @@ -221,7 +272,11 @@ fn parse_arguments(args: &[String]) -> Result { } } } - Ok(BuildRequest { profile, target }) + Ok(BuildRequest { + profile, + target, + gateway_binary, + }) } fn valid_target_triple(target: &str) -> bool { diff --git a/crates/build-workshop/src/main/pipeline.rs b/crates/build-workshop/src/main/pipeline.rs index c5a4286b7..5242ba054 100644 --- a/crates/build-workshop/src/main/pipeline.rs +++ b/crates/build-workshop/src/main/pipeline.rs @@ -5,10 +5,28 @@ use std::io; use std::path::PathBuf; use super::{ - BuildEnvironment, BuildError, BuildRequest, CommandRunner, CommandSpec, InterruptPolicy, - OutputMode, Profile, StepError, valid_target_triple, + BuildEnvironment, BuildError, BuildRequest, CommandRunner, CommandSpec, + GATEWAY_SIDECAR_VARIABLE, InterruptPolicy, OutputMode, Profile, StepError, valid_target_triple, }; +/// Where the staged Gateway sidecar comes from this run. +#[derive(Debug, PartialEq, Eq)] +pub(super) enum GatewaySource { + /// Build the Gateway from this workspace, then stage what Cargo wrote. + /// The default when nothing else supplies a binary. + Build, + /// Stage the binary at this path: `--gateway-binary`, or the + /// `RUNVIL_GATEWAY_SIDECAR` environment variable a CI job sets. + Given(PathBuf), + /// A sidecar file is already in place; use it untouched. Nothing is + /// built, staged, or removed, so a downloaded or hand-staged Gateway + /// survives the run and the next `cargo workshop` reuses it too. + /// + /// The flag is true when that file is a compile-only stub, so the run + /// says so instead of quietly producing an app with no Gateway. + Staged { stub: bool }, +} + pub(super) fn build_workshop( request: &BuildRequest, environment: &BuildEnvironment, @@ -21,8 +39,24 @@ pub(super) fn build_workshop( cleanup: None, })?, }; + let source = gateway_source(request, environment, &target); + if let GatewaySource::Staged { stub: true } = &source { + eprintln!( + "warning: reusing a compile-only stub Gateway at {}; it cannot run. \ + Run `node tools/fetch-gateway-sidecar.mjs --target {target}` for a real one, \ + or pass --gateway-binary.", + sidecar_path(environment, &target).display() + ); + } let mut staging_started = false; - let primary = build_products(request, &target, environment, runner, &mut staging_started); + let primary = build_products( + request, + &target, + &source, + environment, + runner, + &mut staging_started, + ); let mut primary = match primary { Err(error) if error.interrupted && !staging_started => { return Err(BuildError { @@ -39,13 +73,20 @@ pub(super) fn build_workshop( command_started: true, }); } - let cleanup = run_checked( - runner, - &sidecar_command(environment, "remove", &target, None), - "Gateway sidecar cleanup", - InterruptPolicy::AllowExisting, - ) - .map(|_| ()); + // Cleanup removes only what this run put there. A sidecar that was + // already staged belongs to whoever staged it, and deleting it would + // undo the download the next build is meant to reuse. + let cleanup = if matches!(source, GatewaySource::Staged { .. }) { + Ok(()) + } else { + run_checked( + runner, + &sidecar_command(environment, "remove", &target, None), + "Gateway sidecar cleanup", + InterruptPolicy::AllowExisting, + ) + .map(|_| ()) + }; if primary.is_ok() && cleanup.is_ok() && runner.interruption_observed() { primary = Err(StepError { message: "Workshop build interrupted after child completion".to_owned(), @@ -67,6 +108,64 @@ pub(super) fn build_workshop( } } +/// Decides where this run's sidecar comes from: the CLI flag, then the +/// environment variable, then a sidecar already in place, then a build. +/// The last resort is a build, so a checkout with no Gateway anywhere +/// still works the way it always has. +pub(super) fn gateway_source( + request: &BuildRequest, + environment: &BuildEnvironment, + target: &str, +) -> GatewaySource { + if let Some(path) = &request.gateway_binary { + return GatewaySource::Given(path.clone()); + } + if let Some(path) = &environment.gateway_sidecar { + return GatewaySource::Given(path.clone()); + } + let staged = sidecar_path(environment, target); + if staged.is_file() { + // `tools/fetch-gateway-sidecar.mjs` records what it staged beside + // the file. A stub is reused like any other, but the run says so: + // a developer who left one behind would otherwise build an app + // with no Gateway and no clue why. + let stub = sidecar_pin_is_stub(environment, target); + return GatewaySource::Staged { stub }; + } + GatewaySource::Build +} + +/// Whether the staged sidecar's marker says it is a compile-only stub. +/// A missing or unreadable marker means it was not staged by the fetch +/// script, so it is treated as a real Gateway and nothing is said. +fn sidecar_pin_is_stub(environment: &BuildEnvironment, target: &str) -> bool { + let mut marker = sidecar_path(environment, target).into_os_string(); + marker.push(".pinned"); + let Ok(text) = std::fs::read_to_string(PathBuf::from(marker)) else { + return false; + }; + // The marker is JSON written by the fetch script; a substring check is + // enough here and keeps this crate to the dependencies it already has. + text.contains("\"kind\": \"stub\"") || text.contains("\"kind\":\"stub\"") +} + +/// The path `tauri.conf.json`'s `externalBin` entry resolves for `target`: +/// Tauri's sidecar naming rule applied under the desktop crate. +/// +/// `tools/stage-gateway-sidecar.mjs` owns that rule; the +/// `sidecar_path_matches_the_staging_script` test runs the script and +/// compares, so the two cannot drift quietly. +pub(super) fn sidecar_path(environment: &BuildEnvironment, target: &str) -> PathBuf { + let extension = if is_windows(target) { ".exe" } else { "" }; + environment + .workspace_root + .join("crates") + .join("workshop") + .join("desktop") + .join("binaries") + .join(format!("runvil-gateway-{target}{extension}")) +} + fn discover_host_target( environment: &BuildEnvironment, runner: &mut impl CommandRunner, @@ -77,6 +176,7 @@ fn discover_host_target( program: environment.cargo.clone(), args: vec![OsString::from("-vV")], current_dir: environment.workspace_root.clone(), + env: Vec::new(), output_mode: OutputMode::Capture, }, "Cargo host discovery", @@ -97,27 +197,30 @@ fn discover_host_target( fn build_products( request: &BuildRequest, target: &str, + source: &GatewaySource, environment: &BuildEnvironment, runner: &mut impl CommandRunner, staging_started: &mut bool, ) -> Result<(), StepError> { - run_checked( - runner, - &cargo_build_command(environment, request, "gateway"), - "Gateway build", - InterruptPolicy::RejectExisting, - )?; - - let mut source = environment.target_root.clone(); - if request.target.is_some() { - source.push(target); - } - source.push(request.profile.directory()); - source.push(gateway_binary_name(target)); + let staged_source = match source { + GatewaySource::Staged { .. } => { + return build_workshop_product(request, environment, runner, None); + } + GatewaySource::Given(path) => path.clone(), + GatewaySource::Build => { + run_checked( + runner, + &cargo_build_command(environment, request, "gateway"), + "Gateway build", + InterruptPolicy::RejectExisting, + )?; + built_gateway_path(request, target, environment) + } + }; match run_checked( runner, - &sidecar_command(environment, "stage", target, Some(source)), + &sidecar_command(environment, "stage", target, Some(staged_source)), "Gateway sidecar staging", InterruptPolicy::RejectExisting, ) { @@ -127,9 +230,45 @@ fn build_products( return Err(error); } } + // The Workshop build is told where the sidecar is, so the desktop + // build script copies exactly this file rather than guessing from + // modification times. The path Tauri resolves is the same one. + let staged = sidecar_path(environment, target); + build_workshop_product(request, environment, runner, Some(staged)) +} + +/// The path Cargo wrote the Gateway binary to for this request's profile +/// and target. +fn built_gateway_path( + request: &BuildRequest, + target: &str, + environment: &BuildEnvironment, +) -> PathBuf { + let mut source = environment.target_root.clone(); + if request.target.is_some() { + source.push(target); + } + source.push(request.profile.directory()); + source.push(gateway_binary_name(target)); + source +} + +fn build_workshop_product( + request: &BuildRequest, + environment: &BuildEnvironment, + runner: &mut impl CommandRunner, + gateway_sidecar: Option, +) -> Result<(), StepError> { + let env = match gateway_sidecar { + Some(path) => vec![( + OsString::from(GATEWAY_SIDECAR_VARIABLE), + path.into_os_string(), + )], + None => Vec::new(), + }; run_checked( runner, - &cargo_build_command(environment, request, "workshop"), + &cargo_command(environment, request, "workshop", env), "Workshop build", InterruptPolicy::RejectExisting, )?; @@ -140,6 +279,16 @@ fn cargo_build_command( environment: &BuildEnvironment, request: &BuildRequest, package: &str, +) -> CommandSpec { + cargo_command(environment, request, package, Vec::new()) +} + +/// A `cargo build -p ` spec, with `env` passed to the child. +fn cargo_command( + environment: &BuildEnvironment, + request: &BuildRequest, + package: &str, + env: Vec<(OsString, OsString)>, ) -> CommandSpec { let mut args = vec![ OsString::from("build"), @@ -157,6 +306,7 @@ fn cargo_build_command( program: environment.cargo.clone(), args, current_dir: environment.workspace_root.clone(), + env, output_mode: OutputMode::Inherit, } } @@ -185,12 +335,17 @@ fn sidecar_command( program: environment.node.clone(), args, current_dir: environment.workspace_root.clone(), + env: Vec::new(), output_mode: OutputMode::Inherit, } } +fn is_windows(target: &str) -> bool { + target.split('-').any(|part| part == "windows") +} + fn gateway_binary_name(target: &str) -> &'static str { - if target.split('-').any(|part| part == "windows") { + if is_windows(target) { "runvil-gateway.exe" } else { "runvil-gateway" diff --git a/crates/build-workshop/src/main/runner.rs b/crates/build-workshop/src/main/runner.rs index bcb7085f8..aa83dafce 100644 --- a/crates/build-workshop/src/main/runner.rs +++ b/crates/build-workshop/src/main/runner.rs @@ -144,7 +144,8 @@ impl CommandRunner for ProcessRunner { let mut process = Command::new(&command.program); process .args(&command.args) - .current_dir(&command.current_dir); + .current_dir(&command.current_dir) + .envs(command.env.iter().map(|(key, value)| (key, value))); if command.output_mode == OutputMode::Capture { process.stdout(Stdio::piped()).stderr(Stdio::piped()); } diff --git a/crates/build-workshop/src/main/tests-failures.rs b/crates/build-workshop/src/main/tests-failures.rs index 075727b1e..2eae9985a 100644 --- a/crates/build-workshop/src/main/tests-failures.rs +++ b/crates/build-workshop/src/main/tests-failures.rs @@ -13,6 +13,7 @@ fn gateway_failure_still_removes_any_staged_sidecar() { &BuildRequest { profile: Profile::Debug, target: Some(triple.to_owned()), + gateway_binary: None, }, environment, &mut runner, @@ -60,6 +61,7 @@ fn workshop_failure_is_primary_when_cleanup_also_fails() { &BuildRequest { profile: Profile::Release, target: Some(triple.to_owned()), + gateway_binary: None, }, environment, &mut runner, @@ -84,6 +86,7 @@ fn stage_failure_is_followed_by_cleanup() { &BuildRequest { profile: Profile::Debug, target: Some("x86_64-unknown-linux-gnu".to_owned()), + gateway_binary: None, }, environment, &mut runner, @@ -108,6 +111,7 @@ fn interruption_before_staging_does_not_run_removal() { &BuildRequest { profile: Profile::Debug, target: Some("x86_64-pc-windows-msvc".to_owned()), + gateway_binary: None, }, environment, &mut runner, @@ -133,6 +137,7 @@ fn interruption_after_gateway_completion_does_not_run_removal() { &BuildRequest { profile: Profile::Debug, target: Some(triple.to_owned()), + gateway_binary: None, }, environment, &mut runner, @@ -168,6 +173,7 @@ fn interruption_during_staging_runs_target_cleanup_once() { &BuildRequest { profile: Profile::Debug, target: Some(triple.to_owned()), + gateway_binary: None, }, environment, &mut runner, @@ -204,6 +210,7 @@ fn interruption_raced_with_completion_runs_cleanup_once() { &BuildRequest { profile: Profile::Debug, target: Some(triple.to_owned()), + gateway_binary: None, }, environment, &mut runner, @@ -237,6 +244,7 @@ fn interruption_preserves_cleanup_failure_diagnostics() { &BuildRequest { profile: Profile::Debug, target: Some("x86_64-unknown-linux-gnu".to_owned()), + gateway_binary: None, }, environment, &mut runner, @@ -270,6 +278,7 @@ fn cleanup_failure_after_success_fails_the_command_clearly() { &BuildRequest { profile: Profile::Debug, target: Some("x86_64-unknown-linux-gnu".to_owned()), + gateway_binary: None, }, environment, &mut runner, @@ -294,6 +303,7 @@ fn malformed_host_output_fails_before_building() { &BuildRequest { profile: Profile::Debug, target: None, + gateway_binary: None, }, environment, &mut runner, diff --git a/crates/build-workshop/src/main/tests-gateway-source.rs b/crates/build-workshop/src/main/tests-gateway-source.rs new file mode 100644 index 000000000..edaec178e --- /dev/null +++ b/crates/build-workshop/src/main/tests-gateway-source.rs @@ -0,0 +1,356 @@ +//! Unit tests for where the sidecar comes from: the flag, the environment +//! variable, an already-staged file, and the build fallback. + +use super::*; + +use super::pipeline::{GatewaySource, gateway_source}; + +use std::ffi::OsStr; +use std::path::Path; + +/// A repository root with a stand-in staging script, plus the desktop +/// crate's directory, so a test can place a sidecar where the pipeline +/// looks for one. +fn repository() -> TempDir { + let temp = tempfile::tempdir().expect("temporary workspace"); + let tools = temp.path().join("tools"); + std::fs::create_dir_all(&tools).expect("tools directory"); + std::fs::write( + tools.join("stage-gateway-sidecar.mjs"), + "// stand-in; the path test runs the real script\n", + ) + .expect("staging script"); + std::fs::create_dir_all(temp.path().join("crates").join("workshop").join("desktop")) + .expect("desktop crate"); + temp +} + +fn environment_at(temp: &TempDir) -> BuildEnvironment { + BuildEnvironment { + workspace_root: temp.path().to_path_buf(), + target_root: temp.path().join("cargo-target"), + cargo: PathBuf::from("selected-cargo"), + node: PathBuf::from("selected-node"), + gateway_sidecar: None, + } +} + +fn request( + profile: Profile, + target: Option<&str>, + gateway_binary: Option, +) -> BuildRequest { + BuildRequest { + profile, + target: target.map(str::to_owned), + gateway_binary, + } +} + +/// Writes a file where the staged sidecar belongs, returning its path. +fn stage_fake_sidecar(environment: &BuildEnvironment, target: &str) -> PathBuf { + let path = super::super::pipeline::sidecar_path(environment, target); + std::fs::create_dir_all(path.parent().expect("sidecar parent")).expect("binaries directory"); + std::fs::write(&path, "staged gateway").expect("staged sidecar"); + path +} + +#[test] +fn a_given_gateway_binary_replaces_the_gateway_build() { + let test_environment = environment(); + let environment = &test_environment.environment; + let triple = "x86_64-unknown-linux-gnu"; + let given = PathBuf::from("/elsewhere/target/debug/runvil-gateway"); + let mut runner = FakeRunner::with_responses(vec![success(""), success(""), success("")]); + + build_workshop( + &request(Profile::Debug, Some(triple), Some(given.clone())), + environment, + &mut runner, + ) + .expect("Workshop build"); + + assert_eq!( + runner.commands, + vec![ + owned_command( + environment, + "selected-node", + &stage_arguments(environment, triple, given.to_str().expect("UTF-8 given")), + OutputMode::Inherit, + ), + workshop_command( + environment, + triple, + &["build", "-p", "workshop", "--target", triple] + ), + owned_command( + environment, + "selected-node", + &remove_arguments(environment, triple), + OutputMode::Inherit, + ), + ], + "no Gateway build runs when a binary is given" + ); +} + +#[test] +fn the_environment_variable_supplies_the_binary_when_no_flag_does() { + let test_environment = environment(); + let mut environment = test_environment.environment; + let triple = "aarch64-apple-darwin"; + let from_environment = PathBuf::from("/ci/staged/runvil-gateway"); + environment.gateway_sidecar = Some(from_environment.clone()); + let mut runner = FakeRunner::with_responses(vec![success(""), success(""), success("")]); + + build_workshop( + &request(Profile::Release, Some(triple), None), + &environment, + &mut runner, + ) + .expect("Workshop build"); + + assert_eq!( + runner.commands[0], + owned_command( + &environment, + "selected-node", + &stage_arguments( + &environment, + triple, + from_environment.to_str().expect("UTF-8 environment path"), + ), + OutputMode::Inherit, + ), + "the environment variable stages like the flag" + ); +} + +#[test] +fn the_flag_wins_over_the_environment_variable() { + let test_environment = environment(); + let mut environment = test_environment.environment; + environment.gateway_sidecar = Some(PathBuf::from("/ci/runvil-gateway")); + let triple = "x86_64-unknown-linux-gnu"; + let flagged = PathBuf::from("/local/runvil-gateway"); + let mut runner = FakeRunner::with_responses(vec![success(""), success(""), success("")]); + + build_workshop( + &request(Profile::Debug, Some(triple), Some(flagged.clone())), + &environment, + &mut runner, + ) + .expect("Workshop build"); + + assert_eq!( + runner.commands[0].args[5..], + strings(&[flagged.to_str().expect("UTF-8 flag")]) + ); +} + +#[test] +fn an_already_staged_sidecar_is_reused_without_building_or_removing_it() { + let temp = repository(); + let environment = environment_at(&temp); + let triple = "x86_64-pc-windows-msvc"; + let staged = stage_fake_sidecar(&environment, triple); + let mut runner = FakeRunner::with_responses(vec![success("")]); + + build_workshop( + &request(Profile::Debug, Some(triple), None), + &environment, + &mut runner, + ) + .expect("Workshop build"); + + assert_eq!( + runner.commands, + vec![command( + &environment, + "selected-cargo", + &["build", "-p", "workshop", "--target", triple], + OutputMode::Inherit, + )], + "a staged sidecar means one Workshop build and nothing else, and the \ + build script is left to use the staged file itself" + ); + assert_eq!( + std::fs::read_to_string(&staged).expect("the staged sidecar survives"), + "staged gateway", + "the run leaves the staged file for the next build to reuse" + ); +} + +#[test] +fn a_staged_sidecar_is_reused_for_the_host_target_too() { + let temp = repository(); + let environment = environment_at(&temp); + let triple = "x86_64-pc-windows-msvc"; + stage_fake_sidecar(&environment, triple); + let mut runner = FakeRunner::with_responses(vec![ + success("cargo 1.89.0\nhost: x86_64-pc-windows-msvc\n"), + success(""), + ]); + + build_workshop( + &request(Profile::Debug, None, None), + &environment, + &mut runner, + ) + .expect("Workshop build"); + + assert_eq!( + runner.commands.len(), + 2, + "host discovery then the Workshop build, with no Gateway build" + ); + assert_eq!( + runner.commands[1].args, + strings(&["build", "-p", "workshop"]), + "the already-staged file is reused as is" + ); +} + +#[test] +fn no_staged_sidecar_and_no_binary_falls_back_to_building_the_gateway() { + let temp = repository(); + let environment = environment_at(&temp); + let triple = "x86_64-unknown-linux-gnu"; + let mut runner = + FakeRunner::with_responses(vec![success(""), success(""), success(""), success("")]); + + build_workshop( + &request(Profile::Debug, Some(triple), None), + &environment, + &mut runner, + ) + .expect("Workshop build"); + + assert_eq!( + runner.commands[0].args, + strings(&["build", "-p", "gateway", "--target", triple]), + "the absent case still builds the Gateway when nothing is staged" + ); +} + +#[test] +fn a_staged_stub_is_still_reused_but_the_run_says_it_cannot_run() { + let temp = repository(); + let environment = environment_at(&temp); + let triple = "x86_64-pc-windows-msvc"; + let staged = stage_fake_sidecar(&environment, triple); + std::fs::write( + format!("{}.pinned", staged.display()), + "{\n \"kind\": \"stub\",\n \"tag\": null\n}\n", + ) + .expect("stub marker"); + let mut runner = FakeRunner::with_responses(vec![success("")]); + + build_workshop( + &request(Profile::Debug, Some(triple), None), + &environment, + &mut runner, + ) + .expect("Workshop build"); + + assert_eq!( + gateway_source( + &request(Profile::Debug, Some(triple), None), + &environment, + triple + ), + GatewaySource::Staged { stub: true }, + "a stub marker is read as a stub, so the run warns about it" + ); +} + +#[test] +fn a_marker_kind_other_than_stub_is_treated_as_a_real_gateway() { + let temp = repository(); + let environment = environment_at(&temp); + let triple = "x86_64-unknown-linux-gnu"; + let staged = stage_fake_sidecar(&environment, triple); + std::fs::write( + format!("{}.pinned", staged.display()), + "{\n \"kind\": \"release\",\n \"tag\": \"gateway-v0.4.0\"\n}\n", + ) + .expect("release marker"); + assert_eq!( + gateway_source( + &request(Profile::Debug, Some(triple), None), + &environment, + triple + ), + GatewaySource::Staged { stub: false } + ); +} + +#[test] +fn a_staged_sidecar_with_no_marker_counts_as_a_real_gateway() { + let temp = repository(); + let environment = environment_at(&temp); + let triple = "x86_64-unknown-linux-gnu"; + stage_fake_sidecar(&environment, triple); + assert_eq!( + gateway_source( + &request(Profile::Debug, Some(triple), None), + &environment, + triple + ), + GatewaySource::Staged { stub: false }, + "a hand-staged binary is not assumed to be a stub" + ); +} + +#[test] +fn the_sidecar_path_matches_the_staging_script() { + let temp = repository(); + let environment = environment_at(&temp); + let script = PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .parent() + .and_then(|crates| crates.parent()) + .expect("repository root") + .join("tools") + .join("stage-gateway-sidecar.mjs"); + for target in [ + "x86_64-pc-windows-msvc", + "aarch64-apple-darwin", + "x86_64-unknown-linux-gnu", + ] { + let output = std::process::Command::new("node") + .arg(&script) + .arg("path") + .arg("--target") + .arg(target) + .output() + .expect("the staging script runs"); + assert!(output.status.success(), "{output:?}"); + let from_script = String::from_utf8(output.stdout).expect("UTF-8 path"); + let ours = super::super::pipeline::sidecar_path(&environment, target); + let expected_name = format!( + "runvil-gateway-{target}{}", + if target.contains("windows") { + ".exe" + } else { + "" + } + ); + assert_eq!( + Path::new(from_script.trim()) + .file_name() + .expect("a file name"), + Path::new(&expected_name), + "the script and the pipeline agree on the staged file's name" + ); + assert_eq!( + ours.file_name().expect("a file name"), + Path::new(&expected_name), + "the pipeline names the staged file the way Tauri expects" + ); + assert_eq!( + ours.parent().expect("a parent").file_name(), + Some(OsStr::new("binaries")) + ); + } +} diff --git a/crates/build-workshop/src/main/tests.rs b/crates/build-workshop/src/main/tests.rs index 62b50876d..6acf16bc4 100644 --- a/crates/build-workshop/src/main/tests.rs +++ b/crates/build-workshop/src/main/tests.rs @@ -8,6 +8,8 @@ use super::*; #[path = "tests-failures.rs"] mod failures; +#[path = "tests-gateway-source.rs"] +mod source; #[derive(Debug)] enum FakeResponse { @@ -120,6 +122,7 @@ fn environment() -> TestEnvironment { target_root, cargo: PathBuf::from("selected-cargo"), node: PathBuf::from("selected-node"), + gateway_sidecar: None, }, } } @@ -138,10 +141,71 @@ fn command( program: PathBuf::from(program), args: strings(args), current_dir: environment.workspace_root.clone(), + env: Vec::new(), output_mode, } } +/// The staging script's absolute path, as the pipeline passes it. +fn staging_script(environment: &BuildEnvironment) -> String { + environment + .workspace_root + .join("tools") + .join("stage-gateway-sidecar.mjs") + .to_str() + .expect("UTF-8 script") + .to_owned() +} + +/// The `stage` command's arguments, spelled once for both order tests. +fn stage_arguments(environment: &BuildEnvironment, target: &str, source: &str) -> Vec { + vec![ + staging_script(environment), + "stage".to_owned(), + "--target".to_owned(), + target.to_owned(), + "--source".to_owned(), + source.to_owned(), + ] +} + +/// The `remove` command's arguments. +fn remove_arguments(environment: &BuildEnvironment, target: &str) -> Vec { + vec![ + staging_script(environment), + "remove".to_owned(), + "--target".to_owned(), + target.to_owned(), + ] +} + +/// A command spec from owned argument strings. +fn owned_command( + environment: &BuildEnvironment, + program: &str, + args: &[String], + output_mode: OutputMode, +) -> CommandSpec { + CommandSpec { + program: PathBuf::from(program), + args: args.iter().map(OsString::from).collect(), + current_dir: environment.workspace_root.clone(), + env: Vec::new(), + output_mode, + } +} + +/// The Workshop build's spec: the one command that carries the sidecar +/// path in its environment. +fn workshop_command(environment: &BuildEnvironment, target: &str, args: &[&str]) -> CommandSpec { + let mut spec = command(environment, "selected-cargo", args, OutputMode::Inherit); + spec.env = vec![( + OsString::from(GATEWAY_SIDECAR_VARIABLE), + super::pipeline::sidecar_path(environment, target).into_os_string(), + )]; + spec +} + #[test] fn parses_only_the_documented_profile_and_target_options() { assert_eq!( @@ -149,6 +213,7 @@ fn parses_only_the_documented_profile_and_target_options() { BuildRequest { profile: Profile::Debug, target: None, + gateway_binary: None, } ); assert_eq!( @@ -156,11 +221,14 @@ fn parses_only_the_documented_profile_and_target_options() { "--target".to_owned(), "aarch64-apple-darwin".to_owned(), "--release".to_owned(), + "--gateway-binary".to_owned(), + "../gateway/target/debug/runvil-gateway".to_owned(), ]) .expect("release target request"), BuildRequest { profile: Profile::Release, target: Some("aarch64-apple-darwin".to_owned()), + gateway_binary: Some(PathBuf::from("../gateway/target/debug/runvil-gateway")), } ); } @@ -191,6 +259,13 @@ fn rejects_duplicate_or_incomplete_options() { "--target".to_owned(), "x86_64-unknown-linux-gnu".to_owned(), ], + vec!["--gateway-binary".to_owned()], + vec![ + "--gateway-binary".to_owned(), + "a/runvil-gateway".to_owned(), + "--gateway-binary".to_owned(), + "b/runvil-gateway".to_owned(), + ], ] { assert!(parse_arguments(&args).is_err(), "{args:?}"); } @@ -212,6 +287,7 @@ fn default_build_derives_host_and_cleans_up_in_order() { &BuildRequest { profile: Profile::Debug, target: None, + gateway_binary: None, }, environment, &mut runner, @@ -232,44 +308,25 @@ fn default_build_derives_host_and_cleans_up_in_order() { &["build", "-p", "gateway"], OutputMode::Inherit, ), - command( + owned_command( environment, "selected-node", - &[ - environment - .workspace_root - .join("tools") - .join("stage-gateway-sidecar.mjs") - .to_str() - .expect("UTF-8 script"), - "stage", - "--target", + &stage_arguments( + environment, "x86_64-pc-windows-msvc", - "--source", source.to_str().expect("UTF-8 source"), - ], + ), OutputMode::Inherit, ), - command( + workshop_command( environment, - "selected-cargo", + "x86_64-pc-windows-msvc", &["build", "-p", "workshop"], - OutputMode::Inherit, ), - command( + owned_command( environment, "selected-node", - &[ - environment - .workspace_root - .join("tools") - .join("stage-gateway-sidecar.mjs") - .to_str() - .expect("UTF-8 script"), - "remove", - "--target", - "x86_64-pc-windows-msvc", - ], + &remove_arguments(environment, "x86_64-pc-windows-msvc"), OutputMode::Inherit, ), ] @@ -288,6 +345,7 @@ fn explicit_release_target_uses_target_output_without_a_host_probe() { &BuildRequest { profile: Profile::Release, target: Some(triple.to_owned()), + gateway_binary: None, }, environment, &mut runner, @@ -308,44 +366,21 @@ fn explicit_release_target_uses_target_output_without_a_host_probe() { &["build", "-p", "gateway", "--release", "--target", triple,], OutputMode::Inherit, ), - command( + owned_command( environment, "selected-node", - &[ - environment - .workspace_root - .join("tools") - .join("stage-gateway-sidecar.mjs") - .to_str() - .expect("UTF-8 script"), - "stage", - "--target", - triple, - "--source", - source.to_str().expect("UTF-8 source"), - ], + &stage_arguments(environment, triple, source.to_str().expect("UTF-8 source")), OutputMode::Inherit, ), - command( + workshop_command( environment, - "selected-cargo", + triple, &["build", "-p", "workshop", "--release", "--target", triple,], - OutputMode::Inherit, ), - command( + owned_command( environment, "selected-node", - &[ - environment - .workspace_root - .join("tools") - .join("stage-gateway-sidecar.mjs") - .to_str() - .expect("UTF-8 script"), - "remove", - "--target", - triple, - ], + &remove_arguments(environment, triple), OutputMode::Inherit, ), ] diff --git a/crates/workshop/desktop/build.rs b/crates/workshop/desktop/build.rs index df89a8989..dbd381493 100644 --- a/crates/workshop/desktop/build.rs +++ b/crates/workshop/desktop/build.rs @@ -1,35 +1,50 @@ -//! Build script for the workshop desktop app: refreshes the gateway -//! sidecar from the gateway cargo most recently built, then runs -//! tauri-build so `tauri::generate_context!` sees the config-derived -//! environment it requires. +//! Build script for the workshop desktop app: puts the Gateway sidecar in +//! the slot tauri-build copies from, then runs tauri-build so +//! `tauri::generate_context!` sees the config-derived environment it +//! requires. //! -//! Why the refresh exists: `tauri.conf.json` declares the gateway as an -//! `externalBin`, so tauri-build copies `binaries/runvil-gateway- +//! Why a sidecar step exists at all: `tauri.conf.json` declares the Gateway +//! as an `externalBin`, so tauri-build copies `binaries/runvil-gateway- //! ` over `target//runvil-gateway` on every -//! workshop build. That is the same path cargo writes the gateway crate's -//! own binary to, so without this step a `cargo build -p gateway` followed -//! by `cargo build -p workshop` ends with the stale sidecar copy in place -//! of the gateway just built. Copying the built gateway forward into -//! `binaries/` first makes that build order correct. +//! workshop build. That is the same path cargo writes the Gateway crate's +//! own binary to, so without a step here a `cargo build -p gateway` +//! followed by `cargo build -p workshop` ends with the stale sidecar copy +//! in place of the Gateway just built. +//! +//! Three sources fill that slot, in order: +//! +//! 1. `RUNVIL_GATEWAY_SIDECAR`, an explicit file some other step staged. +//! CI sets it, and so does `cargo workshop` when it has a downloaded or +//! locally built Gateway. The named file is copied in as is. +//! 2. The workspace's own `target//runvil-gateway`, copied +//! forward when it is newer than the sidecar already there. This keeps +//! the plain `cargo build -p gateway` then `cargo build -p workshop` +//! order correct. +//! 3. Whatever `tools/fetch-gateway-sidecar.mjs` left in place: a stub for +//! compile-only jobs or the pinned release binary. Nothing here touches +//! it, so a downloaded Gateway survives every rebuild. use std::env; use std::error::Error; use std::fs; use std::path::{Path, PathBuf}; +/// The variable naming a sidecar to install, shared with `build-workshop`. +const GATEWAY_SIDECAR_VARIABLE: &str = "RUNVIL_GATEWAY_SIDECAR"; + fn main() -> Result<(), Box> { build_ceiling::check()?; - refresh_gateway_sidecar()?; + install_gateway_sidecar()?; tauri_build::try_build(tauri_build::Attributes::new().app_manifest( tauri_build::AppManifest::new().commands(&["desktop_update_supported", "quit"]), ))?; Ok(()) } -/// Copies `target//runvil-gateway` into the sidecar slot -/// when it is newer than the copy there, so tauri-build ships the gateway -/// most recently built rather than whatever was placed by hand. -fn refresh_gateway_sidecar() -> Result<(), Box> { +/// Puts the sidecar where `bundle.externalBin` resolves it: an explicit +/// `RUNVIL_GATEWAY_SIDECAR` file when one is named, else the newest +/// workspace-built Gateway, else what is already staged. +fn install_gateway_sidecar() -> Result<(), Box> { let target = env::var("TARGET")?; let exe_suffix = if env::var("CARGO_CFG_TARGET_OS")? == "windows" { ".exe" @@ -41,6 +56,23 @@ fn refresh_gateway_sidecar() -> Result<(), Box> { .join("binaries") .join(format!("runvil-gateway-{target}{exe_suffix}")); + println!("cargo:rerun-if-env-changed={GATEWAY_SIDECAR_VARIABLE}"); + if let Some(named) = env::var_os(GATEWAY_SIDECAR_VARIABLE) { + let named = PathBuf::from(named); + if named.as_os_str().is_empty() { + return Err(format!("{GATEWAY_SIDECAR_VARIABLE} names no file").into()); + } + if !named.is_file() { + return Err(format!( + "{GATEWAY_SIDECAR_VARIABLE} names {}, which is not a file", + named.display() + ) + .into()); + } + copy_forward(&named, &sidecar)?; + return Ok(()); + } + let Some(profile_dir) = profile_dir_from_out_dir(&PathBuf::from(env::var("OUT_DIR")?)) else { return Ok(()); }; @@ -48,20 +80,32 @@ fn refresh_gateway_sidecar() -> Result<(), Box> { // A rebuilt gateway must re-run this script; a missing one is not an // error here because tauri-build reports the absent sidecar itself. println!("cargo:rerun-if-changed={}", built.display()); - if !built.is_file() { + // `tools/fetch-gateway-sidecar.mjs` marks what it staged: a release + // binary it verified against the pin, or a compile-only stub. That mark + // outranks any workspace build, because a stale + // `target//runvil-gateway` from an earlier day must not + // silently replace a downloaded release. + if sidecar_is_pinned(&sidecar) { + return Ok(()); + } + if !built.is_file() || sidecar_is_current(&built, &sidecar) { return Ok(()); } + copy_forward(&built, &sidecar)?; + Ok(()) +} + +/// Copies `source` over `sidecar`, creating the directory and reporting +/// the copy so a surprising sidecar is visible in the build log. +fn copy_forward(source: &Path, sidecar: &Path) -> std::io::Result<()> { if let Some(parent) = sidecar.parent() { fs::create_dir_all(parent)?; } - if sidecar_is_current(&built, &sidecar) { - return Ok(()); - } - fs::copy(&built, &sidecar)?; + fs::copy(source, sidecar)?; println!( - "cargo:warning=refreshed gateway sidecar {} from {}", + "cargo:warning=installed gateway sidecar {} from {}", sidecar.display(), - built.display() + source.display() ); Ok(()) } @@ -72,6 +116,14 @@ fn profile_dir_from_out_dir(out_dir: &Path) -> Option { out_dir.ancestors().nth(3).map(Path::to_path_buf) } +/// Whether `tools/fetch-gateway-sidecar.mjs` staged the sidecar and said +/// so: the file is then the authority, whatever a workspace build wrote. +fn sidecar_is_pinned(sidecar: &Path) -> bool { + let mut marker = sidecar.as_os_str().to_owned(); + marker.push(".pinned"); + Path::new(&marker).is_file() +} + /// The sidecar is current when it exists with the same length and a /// modification time no older than the built gateway's. fn sidecar_is_current(built: &Path, sidecar: &Path) -> bool { diff --git a/crates/workshop/gateway/Cargo.toml b/crates/workshop/gateway/Cargo.toml index 86ac736dd..6cc9135e6 100644 --- a/crates/workshop/gateway/Cargo.toml +++ b/crates/workshop/gateway/Cargo.toml @@ -44,6 +44,7 @@ workshop-gateway-discovery = { workspace = true, features = ["test-fixtures"] } [build-dependencies] build-ceiling.workspace = true +serde_json.workspace = true [lints] workspace = true diff --git a/crates/workshop/gateway/build.rs b/crates/workshop/gateway/build.rs index f62713c6f..7c8c80a14 100644 --- a/crates/workshop/gateway/build.rs +++ b/crates/workshop/gateway/build.rs @@ -1,5 +1,39 @@ -//! Runs the compile-time file-size check on this crate. +//! Embeds the Gateway pin's `min_gateway` so Workshop can compare it with +//! the version of the Gateway it attaches to, then runs the compile-time +//! file-size check. +//! +//! The pin is one file for the whole repository, and this crate is where +//! the Gateway version is known, so the value is baked into this crate's +//! environment and read back with `env!`. + +use std::path::PathBuf; fn main() -> Result<(), build_ceiling::Violations> { + embed_min_gateway(); build_ceiling::check() } + +/// Reads `gateway-pin/pin.json` and sets `RUNVIL_MIN_GATEWAY` to its +/// `min_gateway` value. A pin that is missing or still a placeholder sets +/// an empty value, which the comparison reads as "no minimum recorded": a +/// fresh checkout has to build before anyone can fill the pin in. +fn embed_min_gateway() { + let pin = pin_path(); + if let Some(path) = &pin { + println!("cargo:rerun-if-changed={}", path.display()); + } + let value = pin + .and_then(|path| std::fs::read_to_string(path).ok()) + .and_then(|text| serde_json::from_str::(&text).ok()) + .and_then(|pin| pin.get("min_gateway")?.as_str().map(str::to_owned)) + .filter(|value| value != "PLACEHOLDER") + .unwrap_or_default(); + println!("cargo:rustc-env=RUNVIL_MIN_GATEWAY={value}"); +} + +/// `/gateway-pin/pin.json`, from this crate's manifest directory. +fn pin_path() -> Option { + let manifest = PathBuf::from(std::env::var_os("CARGO_MANIFEST_DIR")?); + let root = manifest.parent()?.parent()?.parent()?; + Some(root.join("gateway-pin").join("pin.json")) +} diff --git a/crates/workshop/gateway/src/lib.rs b/crates/workshop/gateway/src/lib.rs index cb2f2864d..c5d22fe29 100644 --- a/crates/workshop/gateway/src/lib.rs +++ b/crates/workshop/gateway/src/lib.rs @@ -37,6 +37,7 @@ mod refresh; mod resolve; #[cfg(any(test, feature = "test-fixtures"))] pub mod test_gateway; +mod version; pub use binding::{GatewayBinding, GatewayPublicationError, GatewaySnapshot, GatewayUpdater}; pub use client::{ @@ -47,6 +48,9 @@ pub use handles::{GatewayHandles, GatewayTaskRegistrations, register, register_t pub use heartbeat::{GatewayHealth, Heartbeat, join_status, spawn as spawn_heartbeat}; pub use refresh::{refresh_catalog, refresh_profiles}; pub use resolve::{GatewaySource, ResolveError, ResolvedGateway, report, resolve}; +pub use version::{ + MIN_GATEWAY, Version, VersionCheck, announce as announce_version, report as report_version, +}; /// The Gateway discovery contract as Workshop reads it, re-exported whole /// from the fork that owns it. diff --git a/crates/workshop/gateway/src/version-tests.rs b/crates/workshop/gateway/src/version-tests.rs new file mode 100644 index 000000000..cc2eb0fcf --- /dev/null +++ b/crates/workshop/gateway/src/version-tests.rs @@ -0,0 +1,165 @@ +//! Unit tests for the Gateway version comparison. + +use super::*; + +use std::sync::Arc; + +use tokio::sync::broadcast; + +/// A recording status sink standing in for the status bus, and the push +/// facade the report writes through. +fn status_recorder() -> ( + Push, + broadcast::Receiver, + workshop_registry::Registration, +) { + let registry = workshop_registry::Registry::new(); + let (tx, rx) = broadcast::channel(16); + let sink = registry.register_sink::(Arc::new( + workshop_registry::StatusSinkAdapter::new(move |update| { + let _ = tx.send(update); + }), + )); + (registry.push(), rx, sink) +} + +#[test] +fn parses_a_plain_three_part_version() { + assert_eq!( + Version::parse("0.4.1"), + Some(Version { + major: 0, + minor: 4, + patch: 1 + }) + ); +} + +#[test] +fn parses_a_leading_v_and_a_prerelease_suffix() { + let expected = Version::parse("1.2.3"); + assert_eq!(Version::parse("v1.2.3"), expected); + assert_eq!(Version::parse("1.2.3-rc.1"), expected); + assert_eq!(Version::parse("1.2.3+build.7"), expected); +} + +#[test] +fn a_two_part_version_treats_the_missing_patch_as_zero() { + assert_eq!(Version::parse("2.5"), Version::parse("2.5.0")); +} + +#[test] +fn a_version_that_is_not_numbers_is_unreadable() { + for text in ["", " ", "unknown", "1.x.0", "1.2.3.4", "-1.0.0"] { + assert_eq!(Version::parse(text), None, "{text}"); + } +} + +#[test] +fn versions_compare_numerically_not_as_strings() { + let older = Version::parse("0.9.0").expect("a version"); + let newer = Version::parse("0.10.0").expect("a version"); + assert!(newer > older, "0.10.0 is newer than 0.9.0"); + assert!(newer.is_at_least(older)); + assert!(!older.is_at_least(newer)); + assert_eq!(older.to_string(), "0.9.0"); +} + +#[test] +fn an_equal_version_satisfies_the_minimum() { + assert_eq!( + VersionCheck::compare("0.4.0", "0.4.0"), + VersionCheck::Satisfied { + running: Version::parse("0.4.0").expect("a version"), + minimum: Version::parse("0.4.0").expect("a version"), + } + ); +} + +#[test] +fn an_older_gateway_is_reported_too_old() { + let check = VersionCheck::compare("0.3.9", "0.4.0"); + assert!(matches!(check, VersionCheck::TooOld { .. }), "{check:?}"); + assert!(check.is_problem()); +} + +#[test] +fn a_newer_gateway_satisfies_the_minimum() { + assert!(!VersionCheck::compare("2.0.0", "0.4.0").is_problem()); +} + +#[test] +fn an_empty_minimum_means_the_pin_recorded_none() { + let check = VersionCheck::compare("0.1.0", ""); + assert_eq!(check, VersionCheck::NoMinimumRecorded); + assert!( + !check.is_problem(), + "a placeholder pin must not warn about every Gateway" + ); +} + +#[test] +fn an_unreadable_version_is_reported_rather_than_assumed() { + let check = VersionCheck::compare("nightly", "0.4.0"); + assert!( + matches!(check, VersionCheck::Unreadable { .. }), + "an unreadable running version is a problem, not a pass: {check:?}" + ); + assert!(check.is_problem()); + assert!(VersionCheck::compare("0.4.0", "not a version").is_problem()); +} + +#[test] +fn a_too_old_gateway_warns_on_the_status_bar_and_names_both_versions() { + let (push, mut rx, _sink) = status_recorder(); + announce(&VersionCheck::compare("0.3.9", "0.4.0"), &push); + let frame = rx.try_recv().expect("the warning is pushed"); + assert_eq!(frame.label, "Gateway too old"); + assert!( + frame.description.contains("0.3.9") && frame.description.contains("0.4.0"), + "both versions are named: {}", + frame.description + ); +} + +#[test] +fn an_unreadable_version_warns_on_the_status_bar() { + let (push, mut rx, _sink) = status_recorder(); + announce(&VersionCheck::compare("nightly", "0.4.0"), &push); + let frame = rx.try_recv().expect("the warning is pushed"); + assert_eq!(frame.label, "Gateway version unknown"); + assert!( + frame.description.contains("nightly"), + "{}", + frame.description + ); +} + +#[test] +fn a_satisfied_check_says_nothing_on_the_status_bar() { + let (push, mut rx, _sink) = status_recorder(); + announce(&VersionCheck::compare("0.4.0", "0.4.0"), &push); + assert!( + rx.try_recv().is_err(), + "a healthy boot pushes no status line" + ); +} + +#[test] +fn the_pin_minimum_is_the_compiled_default() { + // Whatever the pin holds, the constant is its value, never a guess. A + // placeholder becomes an empty minimum, which the comparison reads as + // "no minimum recorded" rather than as a version to enforce. + assert!( + MIN_GATEWAY.is_empty() || Version::parse(MIN_GATEWAY).is_some(), + "the compiled minimum is empty or a version: {MIN_GATEWAY}" + ); + assert!( + !MIN_GATEWAY.contains("PLACEHOLDER"), + "a placeholder must never become a minimum to compare against" + ); + assert_eq!( + VersionCheck::against_pin("0.0.1"), + VersionCheck::compare("0.0.1", MIN_GATEWAY) + ); +} diff --git a/crates/workshop/gateway/src/version.rs b/crates/workshop/gateway/src/version.rs new file mode 100644 index 000000000..e6bcabed7 --- /dev/null +++ b/crates/workshop/gateway/src/version.rs @@ -0,0 +1,207 @@ +//! The Gateway version check: does the Gateway Workshop attached to meet +//! the lowest release the pin allows? +//! +//! After the split, Workshop and the Gateway no longer come from one +//! commit, so a running Gateway may be older than the pin. Nothing else +//! proves the API surface matches: the pin's `min_gateway` is the lowest +//! release with every operation Workshop calls, so a Gateway below it is +//! missing calls that exist only in later releases. +//! +//! Both versions are logged, and a Gateway below the minimum is announced +//! on the status bar through the same push facade every other startup +//! phase uses. The versions are compared as dotted numbers, never as +//! strings: `0.10.0` is newer than `0.9.0`, and a string compare says the +//! opposite. + +use std::cmp::Ordering; +use std::fmt; + +use workshop_protocol::Activity; +use workshop_registry::Push; + +/// The `min_gateway` value the pin recorded when Workshop was built, or +/// an empty string when the pin was still a placeholder. +pub const MIN_GATEWAY: &str = env!("RUNVIL_MIN_GATEWAY"); + +/// A dotted version: a major, a minor, and a patch number. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Version { + major: u64, + minor: u64, + patch: u64, +} + +impl Version { + /// Parses `major.minor.patch`, tolerating a leading `v` and ignoring a + /// prerelease or build suffix. Returns `None` for anything else, so a + /// version nobody can read is never silently treated as satisfying a + /// minimum. + #[must_use] + pub fn parse(text: &str) -> Option { + let trimmed = text.trim().trim_start_matches('v'); + let core = trimmed + .split(['-', '+']) + .next() + .filter(|core| !core.is_empty())?; + let mut parts = core.split('.'); + let major = parts.next()?.parse().ok()?; + let minor = parts.next()?.parse().ok()?; + let patch = parts.next().unwrap_or("0").parse().ok()?; + if parts.next().is_some() { + return None; + } + Some(Self { + major, + minor, + patch, + }) + } + + /// Whether this version is at least `floor`. + #[must_use] + pub fn is_at_least(self, floor: Self) -> bool { + self >= floor + } +} + +impl Ord for Version { + fn cmp(&self, other: &Self) -> Ordering { + (self.major, self.minor, self.patch).cmp(&(other.major, other.minor, other.patch)) + } +} + +impl PartialOrd for Version { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} + +impl fmt::Display for Version { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(formatter, "{}.{}.{}", self.major, self.minor, self.patch) + } +} + +/// What the check found. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum VersionCheck { + /// The pin recorded no minimum, because it is still a placeholder. + NoMinimumRecorded, + /// The running Gateway meets the pin's minimum. + Satisfied { + /// The Gateway's version, as the discovery file reported it. + running: Version, + /// The pin's `min_gateway`. + minimum: Version, + }, + /// The running Gateway is older than the pin's minimum. + TooOld { + /// The Gateway's version, as the discovery file reported it. + running: Version, + /// The pin's `min_gateway`. + minimum: Version, + }, + /// Either version could not be read as numbers. Reported rather than + /// assumed either way. + Unreadable { + /// The Gateway's version, verbatim. + running: String, + /// The pin's `min_gateway`, verbatim. + minimum: String, + }, +} + +impl VersionCheck { + /// Compares a running Gateway's version against a minimum, both as + /// text. An empty `minimum` means the pin recorded none. + #[must_use] + pub fn compare(running: &str, minimum: &str) -> Self { + if minimum.trim().is_empty() { + return Self::NoMinimumRecorded; + } + match (Version::parse(running), Version::parse(minimum)) { + (Some(running), Some(minimum)) if running.is_at_least(minimum) => { + Self::Satisfied { running, minimum } + } + (Some(running), Some(minimum)) => Self::TooOld { running, minimum }, + _ => Self::Unreadable { + running: running.to_owned(), + minimum: minimum.to_owned(), + }, + } + } + + /// The check against the pin's recorded minimum. + #[must_use] + pub fn against_pin(running: &str) -> Self { + Self::compare(running, MIN_GATEWAY) + } + + /// Whether this check should be shown to the user. + #[must_use] + pub fn is_problem(&self) -> bool { + matches!(self, Self::TooOld { .. } | Self::Unreadable { .. }) + } +} + +/// Logs both versions and, when the running Gateway is below the pin's +/// minimum or its version cannot be read, warns on the status bar. A +/// satisfied check logs and says nothing: every healthy boot looks like +/// that, and a status line for it would be noise. +pub fn report(running: &str, push: &Push) { + announce(&VersionCheck::against_pin(running), push); +} + +/// Logs `check` and warns on the status bar when it is a problem. Split +/// from [`report`] so a test can drive any outcome, including one the +/// committed pin cannot currently produce. +pub fn announce(check: &VersionCheck, push: &Push) { + match check { + VersionCheck::NoMinimumRecorded => { + tracing::info!( + "the Gateway pin records no minimum version; skipping the version check" + ); + } + VersionCheck::Satisfied { running, minimum } => { + tracing::info!( + gateway_version = %running, + minimum_gateway = %minimum, + "the Gateway meets the pin's minimum version" + ); + } + VersionCheck::TooOld { running, minimum } => { + tracing::warn!( + gateway_version = %running, + minimum_gateway = %minimum, + "the Gateway is older than the pin's minimum version" + ); + push.push_status_update( + "Gateway too old", + format!( + "the running Gateway is {running} but this Workshop needs at least {minimum}; \ + install a newer Gateway" + ), + Activity::General, + ); + } + VersionCheck::Unreadable { running, minimum } => { + tracing::warn!( + gateway_version = running, + minimum_gateway = minimum, + "a Gateway version could not be compared with the pin's minimum" + ); + push.push_status_update( + "Gateway version unknown", + format!( + "could not compare the running Gateway's version ({running}) with the \ + required {minimum}" + ), + Activity::General, + ); + } + } +} + +#[cfg(test)] +#[path = "version-tests.rs"] +mod tests; diff --git a/crates/workshop/server/src/app/compose.rs b/crates/workshop/server/src/app/compose.rs index bde559c1f..464e090c8 100644 --- a/crates/workshop/server/src/app/compose.rs +++ b/crates/workshop/server/src/app/compose.rs @@ -117,6 +117,13 @@ fn register_gateway( // Startup phases are reported as they run; with no client connected // yet these land on an empty bus, ready for the first session. workshop_gateway::report(gateway, push); + // The Gateway is now a separate release, so the running one may be + // older than this Workshop needs. Its version arrives in the discovery + // file; an explicit config has no discovery file, so there is nothing + // to compare and the pin's minimum is simply not checked. + if let Some(identity) = gateway.identity() { + workshop_gateway::report_version(identity.version(), push); + } let gateway_binding = GatewayBinding::new_with_identity( gateway.base_url(), gateway.api_key(), diff --git a/gateway-pin/README.md b/gateway-pin/README.md new file mode 100644 index 000000000..bb1b023d2 --- /dev/null +++ b/gateway-pin/README.md @@ -0,0 +1,46 @@ +# The Gateway pin + +`pin.json` here records the one Gateway release Workshop obtains its +sidecar from, and the checksums that prove the bytes. `openapi.yaml` and +`asyncapi.yaml` are the committed spec copies the same release published. + +## What a person must fill in + +Every field in `pin.json` is a placeholder right now, because no Gateway +release exists yet. Before any release or nightly job can run: + +1. Publish a Gateway release. It must attach, for all five target triples, + the binary archive cargo-dist builds; `sidecar.json`; `openapi.yaml`; and + `asyncapi.yaml`. +2. Run `node tools/bump-gateway.mjs `. It downloads that release's + manifest and specs, checks the specs against the manifest's hashes, and + rewrites `pin.json` and the two spec copies in one step. Nothing is + written if any download or check fails. + +`bump-gateway.mjs` fills every field, so there is nothing to hand-edit. If +you must edit by hand, these are the fields and what each one means: + +- `tag`: the release tag, for example `gateway-v0.4.0`. +- `version`: the Gateway version that tag carries, without the tag prefix. +- `min_gateway`: the lowest Gateway release that has every operation + Workshop calls. Workshop warns in the status bar when it attaches to an + older Gateway. +- `executable`: the executable's name inside each archive, `runvil-gateway`. + Tauri renames it per triple when the sidecar is staged. +- `spec.openapi.sha256` and `spec.asyncapi.sha256`: the sha256 of the two + committed spec files beside this file. +- `targets..asset`: the release asset name for that triple. +- `targets..sha256`: that asset's sha256. +- `targets..size`: that asset's size in bytes. + +Every consumer refuses a placeholder by name. `tools/fetch-gateway-sidecar.mjs` +will not download, so a release job fails at the fetch rather than shipping +a Workshop with no Gateway. + +## What reads the pin + +- `tools/fetch-gateway-sidecar.mjs` downloads, verifies, and stages one + target's binary. `--stub` writes a placeholder instead and needs no pin. +- `tools/bump-gateway.mjs` rewrites this file from a published release. +- `crates/workshop/gateway/build.rs` embeds `min_gateway` so the running + Gateway's version can be compared with it at attach. diff --git a/gateway-pin/asyncapi.yaml b/gateway-pin/asyncapi.yaml new file mode 100644 index 000000000..892cd6101 --- /dev/null +++ b/gateway-pin/asyncapi.yaml @@ -0,0 +1,8 @@ +# PLACEHOLDER - the committed AsyncAPI spec copy + +This file is a placeholder. `gateway-pin/pin.json` records the sha256 this +file must have, and `tools/bump-gateway.mjs` replaces both the file and that +hash from a published Gateway release. Nothing reads this file until the pin +is filled in. + +Do not hand-edit it. Run `node tools/bump-gateway.mjs `. diff --git a/gateway-pin/openapi.yaml b/gateway-pin/openapi.yaml new file mode 100644 index 000000000..44ef5e4a9 --- /dev/null +++ b/gateway-pin/openapi.yaml @@ -0,0 +1,8 @@ +# PLACEHOLDER - the committed OpenAPI spec copy + +This file is a placeholder. `gateway-pin/pin.json` records the sha256 this +file must have, and `tools/bump-gateway.mjs` replaces both the file and that +hash from a published Gateway release. Nothing reads this file until the pin +is filled in. + +Do not hand-edit it. Run `node tools/bump-gateway.mjs `. diff --git a/gateway-pin/pin.json b/gateway-pin/pin.json new file mode 100644 index 000000000..a49f6cc4a --- /dev/null +++ b/gateway-pin/pin.json @@ -0,0 +1,45 @@ +{ + "pin_format": 1, + "repository": "cppalliance/runvil-gateway", + "tag": "PLACEHOLDER", + "version": "PLACEHOLDER", + "executable": "runvil-gateway", + "min_gateway": "PLACEHOLDER", + "spec": { + "openapi": { + "path": "gateway-pin/openapi.yaml", + "sha256": "PLACEHOLDER" + }, + "asyncapi": { + "path": "gateway-pin/asyncapi.yaml", + "sha256": "PLACEHOLDER" + } + }, + "targets": { + "x86_64-pc-windows-msvc": { + "asset": "PLACEHOLDER", + "sha256": "PLACEHOLDER", + "size": 0 + }, + "aarch64-apple-darwin": { + "asset": "PLACEHOLDER", + "sha256": "PLACEHOLDER", + "size": 0 + }, + "x86_64-apple-darwin": { + "asset": "PLACEHOLDER", + "sha256": "PLACEHOLDER", + "size": 0 + }, + "x86_64-unknown-linux-gnu": { + "asset": "PLACEHOLDER", + "sha256": "PLACEHOLDER", + "size": 0 + }, + "aarch64-unknown-linux-gnu": { + "asset": "PLACEHOLDER", + "sha256": "PLACEHOLDER", + "size": 0 + } + } +} diff --git a/tools/bump-gateway.mjs b/tools/bump-gateway.mjs new file mode 100644 index 000000000..6e67897a8 --- /dev/null +++ b/tools/bump-gateway.mjs @@ -0,0 +1,342 @@ +// Moves the Gateway pin to a published release. +// +// Given a release tag, this script asks `gh` for the release, downloads its +// `sidecar.json` manifest and the two spec files, checks the specs against +// the manifest's recorded hashes, and only then rewrites `gateway-pin/pin.json` +// and the committed spec copies. Nothing is written until every download and +// check has succeeded, so a wrong tag or a missing asset leaves the pin +// exactly as it was. +// +// The manifest is the `sidecar.json` the Gateway repository attaches to each +// release. It must record, for each of the five release targets, an asset +// name, its sha256, and its size, plus the version, `min_gateway`, the +// executable's name, and the two spec hashes. A release without one cannot +// be pinned. +// +// Usage: +// node tools/bump-gateway.mjs [--repo ] [--dry-run] +// +// `--dry-run` performs every download and check, prints the diff, and writes +// nothing. + +import { spawnSync } from "node:child_process"; +import { createHash } from "node:crypto"; +import { mkdtempSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; + +import { + DEFAULT_RELEASE_REPOSITORY, + PLACEHOLDER, + TARGETS, + pinPath, + renderPin, + specPath, +} from "./gateway-pin.mjs"; + +const REPOSITORY_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), ".."); + +/** The manifest every pinnable Gateway release attaches. */ +export const MANIFEST_ASSET = "sidecar.json"; + +/** The spec files every pinnable Gateway release attaches, and their pin keys. */ +export const SPEC_ASSETS = [ + { name: "openapi.yaml", key: "openapi" }, + { name: "asyncapi.yaml", key: "asyncapi" }, +]; + +/** Lowercase hex sha256 of `bytes`. */ +export function sha256Hex(bytes) { + return createHash("sha256").update(bytes).digest("hex"); +} + +/** Runs `gh` and returns its stdout, failing with the message `gh` printed. */ +function runGh(args) { + const result = spawnSync("gh", args, { encoding: "utf8" }); + if (result.error !== undefined && result.error !== null) { + throw new Error(`could not run gh ${args.join(" ")}: ${result.error.message}`); + } + if (result.status !== 0) { + const detail = (result.stderr || result.stdout || "").trim(); + throw new Error(`gh ${args.join(" ")} failed${detail === "" ? "" : `: ${detail}`}`); + } + return result.stdout; +} + +/** + * Refuses a manifest that cannot produce a usable pin, naming every field + * that is missing or mistyped. A short manifest is a bug in the Gateway's + * release, not something to paper over here. + */ +export function validateManifest(manifest, tag) { + const problems = []; + const named = (value) => typeof value === "string" && value.length > 0; + if (!named(manifest["version"])) { + problems.push("version"); + } else if (!tag.endsWith(manifest["version"])) { + problems.push(`version ${manifest["version"]} does not match tag ${tag}`); + } + for (const field of ["min_gateway", "executable"]) { + if (!named(manifest[field])) { + problems.push(field); + } + } + for (const { key } of SPEC_ASSETS) { + if (!named(manifest["spec"]?.[key]?.["sha256"])) { + problems.push(`spec.${key}.sha256`); + } + } + for (const target of TARGETS) { + const entry = manifest["targets"]?.[target]; + if (!named(entry?.["asset"])) { + problems.push(`targets.${target}.asset`); + } + if (!named(entry?.["sha256"])) { + problems.push(`targets.${target}.sha256`); + } + if (!Number.isInteger(entry?.["size"]) || entry["size"] <= 0) { + problems.push(`targets.${target}.size`); + } + } + if (problems.length > 0) { + throw new Error(`${MANIFEST_ASSET} for ${tag} cannot be pinned: ${problems.join(", ")}`); + } + return manifest; +} + +/** Builds the pin object for `tag` from a validated manifest. */ +export function buildPin(manifest, tag, repository) { + const targets = {}; + for (const target of TARGETS) { + const entry = manifest["targets"][target]; + targets[target] = { + asset: entry["asset"], + sha256: entry["sha256"], + size: entry["size"], + }; + } + const spec = {}; + for (const { key } of SPEC_ASSETS) { + spec[key] = { + path: `gateway-pin/${SPEC_ASSETS.find((asset) => asset.key === key).name}`, + sha256: manifest["spec"][key]["sha256"], + }; + } + return { + pin_format: 1, + repository, + tag, + version: manifest["version"], + executable: manifest["executable"], + min_gateway: manifest["min_gateway"], + spec, + targets, + }; +} + +/** + * A readable diff of `before` and `after`: two lines of context around the + * lines that differ, marked `-` and `+` and prefixed with the file. Text + * that did not change reports as unchanged. + */ +export function renderDiff(name, before, after) { + if (before === after) { + return `${name}: unchanged`; + } + const oldLines = before.split("\n"); + const newLines = after.split("\n"); + let prefix = 0; + while ( + prefix < oldLines.length && + prefix < newLines.length && + oldLines[prefix] === newLines[prefix] + ) { + prefix += 1; + } + let suffix = 0; + while ( + suffix < oldLines.length - prefix && + suffix < newLines.length - prefix && + oldLines[oldLines.length - 1 - suffix] === newLines[newLines.length - 1 - suffix] + ) { + suffix += 1; + } + const lines = [`--- ${name}`, `+++ ${name}`]; + for (const line of oldLines.slice(Math.max(0, prefix - 2), prefix)) { + lines.push(` ${line}`); + } + for (const line of oldLines.slice(prefix, oldLines.length - suffix)) { + lines.push(`-${line}`); + } + for (const line of newLines.slice(prefix, newLines.length - suffix)) { + lines.push(`+${line}`); + } + const tail = oldLines.length - suffix; + for (const line of oldLines.slice(tail, tail + 2)) { + lines.push(` ${line}`); + } + return lines.join("\n"); +} + +/** Writes `text` to `path` in one step: a sibling temp file, then a rename. */ +function writeAtomic(path, text) { + const temporary = `${path}.bump-tmp`; + writeFileSync(temporary, text); + renameSync(temporary, path); +} + +function readIfPresent(path) { + try { + return readFileSync(path, "utf8"); + } catch { + return ""; + } +} + +/** + * Downloads a release's manifest and specs and returns the new pin text and + * the spec texts, without touching the repository. `gh` is a seam for the + * tests. + */ +export function downloadRelease({ tag, repository = DEFAULT_RELEASE_REPOSITORY, gh = runGh }) { + const scratch = mkdtempSync(join(tmpdir(), "runvil-bump-gateway-")); + try { + try { + gh(["release", "view", tag, "--repo", repository, "--json", "tagName,assets"]); + } catch (error) { + throw new Error( + `Gateway release ${tag} was not found in ${repository}: ${error.message}\n` + + "nothing was written; list the tags with " + + `\`gh release list --repo ${repository}\``, + ); + } + const names = [MANIFEST_ASSET, ...SPEC_ASSETS.map((spec) => spec.name)]; + const patterns = names.flatMap((name) => ["--pattern", name]); + gh(["release", "download", tag, "--repo", repository, "--dir", scratch, ...patterns]); + let manifest; + try { + manifest = JSON.parse(readFileSync(join(scratch, MANIFEST_ASSET), "utf8")); + } catch (error) { + throw new Error(`${MANIFEST_ASSET} for ${tag} is not valid JSON: ${error.message}`); + } + validateManifest(manifest, tag); + const specs = {}; + for (const { name, key } of SPEC_ASSETS) { + const bytes = readFileSync(join(scratch, name)); + const actual = sha256Hex(bytes); + const recorded = manifest["spec"][key]["sha256"]; + if (actual !== recorded) { + throw new Error( + `${name} for ${tag} has sha256 ${actual}, but ${MANIFEST_ASSET} records ${recorded}`, + ); + } + specs[key] = { name, text: bytes.toString("utf8") }; + } + const pin = buildPin(manifest, tag, repository); + if (Object.values(pin["targets"]).some((entry) => entry["sha256"] === PLACEHOLDER)) { + throw new Error(`${MANIFEST_ASSET} for ${tag} still names a placeholder checksum`); + } + return { pin, pinText: renderPin(pin), specs }; + } finally { + rmSync(scratch, { force: true, recursive: true }); + } +} + +/** + * Runs the bump: downloads, checks, prints the diff, and writes the spec + * copies and then the pin. `dryRun` stops before the writes. + */ +export function bumpGateway({ + root = REPOSITORY_ROOT, + tag, + repository = DEFAULT_RELEASE_REPOSITORY, + gh = runGh, + dryRun = false, +}) { + if (typeof tag !== "string" || tag.length === 0) { + throw new Error("bump-gateway needs a release tag, for example `gateway-v0.4.0`"); + } + const { pin, pinText, specs } = downloadRelease({ tag, repository, gh }); + const diffs = [ + renderDiff(pinPath(root), readIfPresent(pinPath(root)), pinText), + ...SPEC_ASSETS.map(({ name, key }) => + renderDiff(specPath(root, name), readIfPresent(specPath(root, name)), specs[key].text), + ), + ]; + if (!dryRun) { + // The spec copies land first: the pin is the authority that says they + // are current, so it never names a spec that is not yet written. + for (const { name, key } of SPEC_ASSETS) { + writeAtomic(specPath(root, name), specs[key].text); + } + writeAtomic(pinPath(root), pinText); + } + return { pin, diffs, written: !dryRun }; +} + +function parseArguments(args) { + let tag; + let repository = DEFAULT_RELEASE_REPOSITORY; + let dryRun = false; + let repoSeen = false; + for (let index = 0; index < args.length; index += 1) { + const name = args[index]; + if (name === "--dry-run") { + if (dryRun) { + throw new Error("duplicate bump argument: --dry-run"); + } + dryRun = true; + continue; + } + if (name === "--repo") { + const value = args[index + 1]; + if (value === undefined) { + throw new Error("missing value for bump argument: --repo"); + } + if (repoSeen) { + throw new Error("duplicate bump argument: --repo"); + } + repository = value; + repoSeen = true; + index += 1; + continue; + } + if (name.startsWith("-")) { + throw new Error(`unsupported bump argument: ${name}`); + } + if (tag !== undefined) { + throw new Error(`unexpected second tag: ${name}`); + } + tag = name; + } + if (tag === undefined) { + throw new Error("usage: bump-gateway.mjs [--repo ] [--dry-run]"); + } + return { tag, repository, dryRun }; +} + +function main(args) { + const parsed = parseArguments(args); + const result = bumpGateway(parsed); + for (const diff of result.diffs) { + console.log(diff); + } + console.log( + result.written + ? `pinned Gateway ${result.pin.tag} (${result.pin.version})` + : `dry run: Gateway ${result.pin.tag} would be pinned; nothing was written`, + ); +} + +if ( + process.argv[1] !== undefined && + import.meta.url === pathToFileURL(resolve(process.argv[1])).href +) { + try { + main(process.argv.slice(2)); + } catch (error) { + console.error(error instanceof Error ? error.message : String(error)); + process.exitCode = 1; + } +} diff --git a/tools/bump-gateway.test.mjs b/tools/bump-gateway.test.mjs new file mode 100644 index 000000000..f108964dc --- /dev/null +++ b/tools/bump-gateway.test.mjs @@ -0,0 +1,239 @@ +import assert from "node:assert/strict"; +import { createHash } from "node:crypto"; +import { existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; + +import { + MANIFEST_ASSET, + SPEC_ASSETS, + bumpGateway, + buildPin, + downloadRelease, + renderDiff, + validateManifest, +} from "./bump-gateway.mjs"; +import { PLACEHOLDER, TARGETS, pinPath, readPin, specPath } from "./gateway-pin.mjs"; + +const TAG = "gateway-v0.4.0"; +const OPENAPI = "openapi: 3.1.0\ninfo:\n title: Runvil Gateway\n"; +const ASYNCAPI = "asyncapi: 3.0.0\ninfo:\n title: Runvil Gateway realtime\n"; + +function sha256(text) { + return createHash("sha256").update(text).digest("hex"); +} + +/** A manifest shaped the way the Gateway repository publishes one. */ +function manifest() { + const targets = {}; + for (const target of TARGETS) { + targets[target] = { + asset: `runvil-gateway-${target}.tar.gz`, + sha256: "e".repeat(64), + size: 4096, + }; + } + return { + version: "0.4.0", + min_gateway: "0.4.0", + executable: "runvil-gateway", + spec: { + openapi: { sha256: sha256(OPENAPI) }, + asyncapi: { sha256: sha256(ASYNCAPI) }, + }, + targets, + }; +} + +/** A sandbox repository root with a placeholder pin and placeholder specs. */ +function sandbox() { + const base = mkdtempSync(join(tmpdir(), "runvil-bump-gateway-")); + const root = join(base, "repo"); + mkdirSync(join(root, "gateway-pin"), { recursive: true }); + writeFileSync( + pinPath(root), + `${JSON.stringify( + { + pin_format: 1, + repository: "cppalliance/runvil-gateway", + tag: PLACEHOLDER, + version: PLACEHOLDER, + executable: "runvil-gateway", + min_gateway: PLACEHOLDER, + spec: { + openapi: { path: "gateway-pin/openapi.yaml", sha256: PLACEHOLDER }, + asyncapi: { path: "gateway-pin/asyncapi.yaml", sha256: PLACEHOLDER }, + }, + targets: Object.fromEntries( + TARGETS.map((target) => [target, { asset: PLACEHOLDER, sha256: PLACEHOLDER, size: 0 }]), + ), + }, + null, + 2, + )}\n`, + ); + for (const { name } of SPEC_ASSETS) { + writeFileSync(specPath(root, name), "# PLACEHOLDER\n"); + } + return { base, root }; +} + +/** + * A `gh` stand-in: `release view` succeeds for `foundTag`, and `release + * download` materializes the requested patterns into `--dir`. + */ +function fakeGh({ foundTag = TAG, body = manifest(), specs = { "openapi.yaml": OPENAPI, "asyncapi.yaml": ASYNCAPI }, calls = [] } = {}) { + return (args) => { + calls.push(args); + if (args[0] === "release" && args[1] === "view") { + if (args[2] !== foundTag) { + throw new Error(`gh release view ${args[2]} failed: release not found`); + } + return "{}"; + } + if (args[0] === "release" && args[1] === "download") { + const dir = args[args.indexOf("--dir") + 1]; + const patterns = args.flatMap((arg, index) => (arg === "--pattern" ? [args[index + 1]] : [])); + for (const pattern of patterns) { + if (pattern === MANIFEST_ASSET) { + writeFileSync(join(dir, MANIFEST_ASSET), JSON.stringify(body, null, 2)); + } else if (specs[pattern] !== undefined) { + writeFileSync(join(dir, pattern), specs[pattern]); + } else { + throw new Error(`no asset named ${pattern}`); + } + } + return ""; + } + throw new Error(`unexpected gh call: ${args.join(" ")}`); + }; +} + +test("a bump rewrites the pin and both spec copies", () => { + const { base, root } = sandbox(); + try { + const result = bumpGateway({ root, tag: TAG, gh: fakeGh() }); + assert.equal(result.written, true); + const pin = readPin(root); + assert.equal(pin["tag"], TAG); + assert.equal(pin["version"], "0.4.0"); + assert.equal(pin["min_gateway"], "0.4.0"); + assert.equal(pin["spec"]["openapi"]["sha256"], sha256(OPENAPI)); + assert.equal(readFileSync(specPath(root, "openapi.yaml"), "utf8"), OPENAPI); + assert.equal(readFileSync(specPath(root, "asyncapi.yaml"), "utf8"), ASYNCAPI); + assert.equal(pin["targets"]["aarch64-apple-darwin"]["size"], 4096); + } finally { + rmSync(base, { force: true, recursive: true }); + } +}); + +test("a tag that cannot be found writes nothing at all", () => { + const { base, root } = sandbox(); + try { + const before = readFileSync(pinPath(root), "utf8"); + assert.throws( + () => bumpGateway({ root, tag: "gateway-v9.9.9", gh: fakeGh() }), + /Gateway release gateway-v9\.9\.9 was not found/, + ); + assert.equal(readFileSync(pinPath(root), "utf8"), before, "the pin is untouched"); + assert.equal(readFileSync(specPath(root, "openapi.yaml"), "utf8"), "# PLACEHOLDER\n"); + assert.deepEqual( + readdirSync(join(root, "gateway-pin")).sort(), + ["asyncapi.yaml", "openapi.yaml", "pin.json"], + "no temp file is left behind", + ); + } finally { + rmSync(base, { force: true, recursive: true }); + } +}); + +test("a spec that disagrees with the manifest hash fails before any write", () => { + const { base, root } = sandbox(); + try { + const before = readFileSync(pinPath(root), "utf8"); + assert.throws( + () => + bumpGateway({ + root, + tag: TAG, + gh: fakeGh({ specs: { "openapi.yaml": "tampered", "asyncapi.yaml": ASYNCAPI } }), + }), + /openapi\.yaml for gateway-v0\.4\.0 has sha256 [0-9a-f]{64}, but sidecar\.json records [0-9a-f]{64}/, + ); + assert.equal(readFileSync(pinPath(root), "utf8"), before); + assert.equal(readFileSync(specPath(root, "openapi.yaml"), "utf8"), "# PLACEHOLDER\n"); + } finally { + rmSync(base, { force: true, recursive: true }); + } +}); + +test("a dry run prints the diff and writes nothing", () => { + const { base, root } = sandbox(); + try { + const before = readFileSync(pinPath(root), "utf8"); + const result = bumpGateway({ root, tag: TAG, gh: fakeGh(), dryRun: true }); + assert.equal(result.written, false); + assert.equal(readFileSync(pinPath(root), "utf8"), before); + assert.ok(result.diffs.some((diff) => diff.includes("+ \"tag\": \"gateway-v0.4.0\"")), result.diffs.join("\n")); + } finally { + rmSync(base, { force: true, recursive: true }); + } +}); + +test("a manifest missing a target is refused by field name", () => { + const body = manifest(); + delete body["targets"]["aarch64-unknown-linux-gnu"]; + assert.throws( + () => validateManifest(body, TAG), + /targets\.aarch64-unknown-linux-gnu\.asset, targets\.aarch64-unknown-linux-gnu\.sha256, targets\.aarch64-unknown-linux-gnu\.size/, + ); +}); + +test("a manifest whose version disagrees with the tag is refused", () => { + const body = manifest(); + body["version"] = "0.5.0"; + assert.throws(() => validateManifest(body, TAG), /version 0\.5\.0 does not match tag gateway-v0\.4\.0/); +}); + +test("a manifest naming a placeholder checksum is refused rather than pinned", () => { + const body = manifest(); + body["targets"]["x86_64-pc-windows-msvc"]["sha256"] = PLACEHOLDER; + assert.throws(() => downloadRelease({ tag: TAG, gh: fakeGh({ body }) }), /still names a placeholder checksum/); +}); + +test("the built pin carries every five target entries in release order", () => { + const pin = buildPin(manifest(), TAG, "cppalliance/runvil-gateway"); + assert.deepEqual(Object.keys(pin["targets"]), TARGETS); + assert.equal(pin["pin_format"], 1); + assert.equal(pin["executable"], "runvil-gateway"); + assert.equal(pin["spec"]["asyncapi"]["path"], "gateway-pin/asyncapi.yaml"); +}); + +test("the diff shows only the changed lines with a little context", () => { + const before = "a\nb\nc\nd\n"; + const after = "a\nb\nCHANGED\nd\n"; + const diff = renderDiff("pin.json", before, after); + assert.ok(diff.includes("-c"), diff); + assert.ok(diff.includes("+CHANGED"), diff); + assert.ok(diff.includes(" b"), diff); + assert.equal(renderDiff("pin.json", before, before), "pin.json: unchanged"); +}); + +test("a download leaves no scratch directory behind", () => { + const before = readdirSync(tmpdir()).filter((name) => name.startsWith("runvil-bump-gateway-")); + downloadRelease({ tag: TAG, gh: fakeGh() }); + const after = readdirSync(tmpdir()).filter((name) => name.startsWith("runvil-bump-gateway-")); + assert.deepEqual(after, before); +}); + +test("a failed gh download propagates its message", () => { + const gh = (args) => { + if (args[1] === "view") { + return "{}"; + } + throw new Error("gh release download failed: HTTP 404"); + }; + assert.throws(() => downloadRelease({ tag: TAG, gh }), /HTTP 404/); + assert.equal(existsSync(join(tmpdir(), "does-not-exist")), false); +}); diff --git a/tools/fetch-gateway-sidecar.mjs b/tools/fetch-gateway-sidecar.mjs new file mode 100644 index 000000000..c11e10d84 --- /dev/null +++ b/tools/fetch-gateway-sidecar.mjs @@ -0,0 +1,313 @@ +// Obtains the Gateway sidecar Workshop bundles: either the pinned release +// binary for one target, or a stub for compile-only jobs. +// +// The pin in `gateway-pin/pin.json` names the release and the sha256 of +// every asset. This script downloads (or reuses a cached copy of) one +// asset, checks its size and sha256 against the pin, extracts the +// executable, and hands it to `stage-gateway-sidecar.mjs`, which owns the +// Tauri naming rule and the destination. A mismatch fails loudly with both +// hashes, because a silently wrong Gateway is worse than no build. +// +// Usage: +// node tools/fetch-gateway-sidecar.mjs [--target ] [--cache-dir ] +// [--verify-attestation] [--stub] +// +// `--stub` writes a placeholder file at the path Tauri expects and +// downloads nothing, so a job that only compiles the app needs no release. +// `--verify-attestation` additionally asks `gh release verify-asset` about +// the downloaded archive. + +import { spawnSync } from "node:child_process"; +import { createHash } from "node:crypto"; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { homedir } from "node:os"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; + +import { extractExecutable } from "./gateway-archive.mjs"; +import { + PLACEHOLDER, + TARGETS, + placeholderFields, + placeholderReport, + readPin, + releaseAssetUrl, + requireFilledPin, + targetEntry, +} from "./gateway-pin.mjs"; +import { + gatewayBinaryName, + markGatewaySidecarPinned, + stageGatewaySidecar, +} from "./stage-gateway-sidecar.mjs"; + +const REPOSITORY_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), ".."); + +/** The stub's bytes: a recognizable text, never runnable as a Gateway. */ +const STUB_CONTENT = + "This is a placeholder staged by tools/fetch-gateway-sidecar.mjs --stub.\n" + + "It stands in for the Gateway sidecar so compile-only jobs need no release.\n" + + "It is not an executable. Stage the real pinned binary before shipping.\n"; + +/** The host triple a build on this machine targets. */ +export function hostTarget() { + const key = `${process.platform}-${process.arch}`; + const triples = { + "win32-x64": "x86_64-pc-windows-msvc", + "win32-arm64": "aarch64-pc-windows-msvc", + "darwin-arm64": "aarch64-apple-darwin", + "darwin-x64": "x86_64-apple-darwin", + "linux-x64": "x86_64-unknown-linux-gnu", + "linux-arm64": "aarch64-unknown-linux-gnu", + }; + const triple = triples[key]; + if (triple === undefined) { + throw new Error(`no Gateway target matches this machine (${key}); pass --target `); + } + return triple; +} + +/** The default download cache: `RUNVIL_GATEWAY_CACHE`, else the user's cache directory. */ +export function defaultCacheDir() { + const fromEnvironment = process.env["RUNVIL_GATEWAY_CACHE"]; + if (fromEnvironment !== undefined && fromEnvironment !== "") { + return fromEnvironment; + } + const base = + process.platform === "win32" + ? process.env["LOCALAPPDATA"] ?? join(homedir(), "AppData", "Local") + : process.env["XDG_CACHE_HOME"] ?? join(homedir(), ".cache"); + return join(base, "runvil", "gateway-sidecar"); +} + +/** Lowercase hex sha256 of `bytes`. */ +export function sha256Hex(bytes) { + return createHash("sha256").update(bytes).digest("hex"); +} + +/** + * Throws unless `bytes` match the pin's recorded size and sha256, naming + * both hashes and both sizes. + */ +export function verifyAsset(bytes, entry, asset) { + if (bytes.length !== entry.size) { + throw new Error( + `Gateway asset ${asset} is ${bytes.length} bytes, but the pin records ${entry.size} bytes`, + ); + } + const actual = sha256Hex(bytes); + if (actual !== entry.sha256) { + throw new Error( + `Gateway asset ${asset} sha256 is ${actual}, but the pin records ${entry.sha256}`, + ); + } + return actual; +} + +/** Downloads `url` and returns its bytes, failing on any non-200 answer. */ +async function downloadBytes(url) { + const response = await fetch(url, { redirect: "follow" }); + if (!response.ok) { + throw new Error(`downloading ${url} failed: ${response.status} ${response.statusText}`); + } + return Buffer.from(await response.arrayBuffer()); +} + +/** How a `gh` subprocess is run: a seam the tests replace. */ +function runGh(args) { + const result = spawnSync("gh", args, { encoding: "utf8" }); + if (result.error !== undefined && result.error !== null) { + throw new Error(`could not run gh ${args.join(" ")}: ${result.error.message}`); + } + if (result.status !== 0) { + const detail = (result.stderr || result.stdout || "").trim(); + throw new Error(`gh ${args.join(" ")} failed${detail === "" ? "" : `: ${detail}`}`); + } + return result.stdout; +} + +/** + * Ensures the pinned archive for `target` is in the cache and verified, + * returning its path. A cache hit is re-verified on every run: the pin is + * the authority, not the cache. + */ +async function ensureArchive(pin, target, cacheDir, { download }) { + const entry = targetEntry(pin, target); + const url = releaseAssetUrl(pin, entry.asset); + const cached = join(cacheDir, pin.tag, entry.asset); + if (existsSync(cached)) { + const bytes = readFileSync(cached); + try { + verifyAsset(bytes, entry, entry.asset); + return { path: cached, downloaded: false, url }; + } catch { + // A cached file that no longer matches the pin is stale: refetch it + // rather than failing, so a re-pinned cache heals itself. + rmSync(cached, { force: true }); + } + } + const bytes = await download(url); + verifyAsset(bytes, entry, entry.asset); + mkdirSync(dirname(cached), { recursive: true }); + writeFileSync(cached, bytes); + return { path: cached, downloaded: true, url }; +} + +/** + * Obtains and stages the sidecar. Returns what was staged and how. + * + * `download` and `gh` are seams for the tests; production uses `fetch` and + * the real `gh` executable. + */ +export async function fetchGatewaySidecar(options = {}) { + const { + root = REPOSITORY_ROOT, + target = hostTarget(), + cacheDir = defaultCacheDir(), + stub = false, + verifyAttestation = false, + download = downloadBytes, + gh = runGh, + } = options; + const binary = gatewayBinaryName(target); + if (!TARGETS.includes(target)) { + throw new Error( + `unsupported Gateway target ${target}; the pin records ${TARGETS.join(", ")}`, + ); + } + if (stub) { + return stageStub({ root, target, cacheDir, binary }); + } + const pin = readPin(root); + if (pin.executable === PLACEHOLDER) { + throw new Error("gateway-pin/pin.json still holds a placeholder in executable"); + } + try { + requireFilledPin(pin, { source: "gateway-pin/pin.json", target }); + } catch (error) { + // A person running this before the pin is filled needs the whole list, + // not just the fields one target reads, so the report follows the + // refusal. + throw new Error(`${error.message}\n${placeholderReport(pin, { source: "gateway-pin/pin.json" })}`); + } + const entry = targetEntry(pin, target); + const archive = await ensureArchive(pin, target, cacheDir, { download }); + if (verifyAttestation) { + gh(["release", "verify-asset", pin.tag, archive.path, "--repo", pin.repository]); + } + const extracted = extractExecutable(entry.asset, readFileSync(archive.path), binary); + if (extracted === null) { + throw new Error(`Gateway asset ${entry.asset} holds no ${binary}`); + } + const scratch = mkdtempSync(join(cacheDir, "extract-")); + try { + const source = join(scratch, binary); + writeFileSync(source, extracted); + const staged = stageGatewaySidecar({ root, target, source }); + markGatewaySidecarPinned({ root, target, kind: "release", tag: pin.tag }); + return { + staged, + target, + tag: pin.tag, + version: pin.version, + asset: entry.asset, + url: archive.url, + downloaded: archive.downloaded, + stub: false, + }; + } finally { + rmSync(scratch, { force: true, recursive: true }); + } +} + +/** Writes the placeholder file and stages it, downloading nothing. */ +function stageStub({ root, target, cacheDir, binary }) { + const directory = join(cacheDir, "stub"); + mkdirSync(directory, { recursive: true }); + const source = join(directory, binary); + writeFileSync(source, STUB_CONTENT); + const staged = stageGatewaySidecar({ root, target, source }); + markGatewaySidecarPinned({ root, target, kind: "stub" }); + return { + staged, + target, + tag: null, + version: null, + asset: null, + url: null, + downloaded: false, + stub: true, + }; +} + +function parseArguments(args) { + const values = new Map(); + const flagNames = new Set(["--stub", "--verify-attestation"]); + const valueNames = new Set(["--target", "--cache-dir"]); + const flags = new Set(); + for (let index = 0; index < args.length; index += 1) { + const name = args[index]; + if (flagNames.has(name)) { + if (flags.has(name) || values.has(name)) { + throw new Error(`duplicate fetch argument: ${name}`); + } + flags.add(name); + continue; + } + if (!valueNames.has(name)) { + throw new Error( + "usage: fetch-gateway-sidecar.mjs [--target ] [--cache-dir ] " + + "[--verify-attestation] [--stub]", + ); + } + const value = args[index + 1]; + if (value === undefined) { + throw new Error(`missing value for fetch argument: ${name}`); + } + if (values.has(name)) { + throw new Error(`duplicate fetch argument: ${name}`); + } + values.set(name, value); + index += 1; + } + return { + target: values.get("--target"), + cacheDir: values.get("--cache-dir"), + stub: flags.has("--stub"), + verifyAttestation: flags.has("--verify-attestation"), + }; +} + +async function main(args) { + const parsed = parseArguments(args); + const options = { stub: parsed.stub, verifyAttestation: parsed.verifyAttestation }; + if (parsed.target !== undefined) { + options.target = parsed.target; + } + if (parsed.cacheDir !== undefined) { + options.cacheDir = parsed.cacheDir; + } + const result = await fetchGatewaySidecar(options); + if (result.stub) { + console.log(`staged a stub for ${result.target} at ${result.staged}`); + console.log("this file is not a runnable Gateway; never ship a build made with --stub"); + return; + } + console.log( + `${result.downloaded ? "downloaded" : "reused"} ${result.asset} (${result.url}), ` + + `sha256 verified against the pin for ${result.tag}`, + ); + console.log(`staged ${result.target} from Gateway ${result.version} at ${result.staged}`); +} + +if ( + process.argv[1] !== undefined && + import.meta.url === pathToFileURL(resolve(process.argv[1])).href +) { + try { + await main(process.argv.slice(2)); + } catch (error) { + console.error(error instanceof Error ? error.message : String(error)); + process.exitCode = 1; + } +} diff --git a/tools/fetch-gateway-sidecar.test.mjs b/tools/fetch-gateway-sidecar.test.mjs new file mode 100644 index 000000000..3992954b1 --- /dev/null +++ b/tools/fetch-gateway-sidecar.test.mjs @@ -0,0 +1,355 @@ +import assert from "node:assert/strict"; +import { createHash } from "node:crypto"; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { gzipSync } from "node:zlib"; +import test from "node:test"; + +import { + fetchGatewaySidecar, + hostTarget, + sha256Hex, + verifyAsset, +} from "./fetch-gateway-sidecar.mjs"; +import { PLACEHOLDER, TARGETS, parsePin, placeholderReport, renderPin } from "./gateway-pin.mjs"; + +const WINDOWS = "x86_64-pc-windows-msvc"; +const LINUX = "x86_64-unknown-linux-gnu"; +const BINARY = "runvil-gateway"; +const EXECUTABLE_BYTES = "the gateway executable"; + +/** A gzipped tar holding one `runvil-gateway` under a release directory name. */ +function releaseArchive() { + const header = Buffer.alloc(512, 0); + header.write("runvil-gateway-x86_64-unknown-linux-gnu/runvil-gateway", 0, 100, "utf8"); + header.write("0000644\0", 100, "utf8"); + header.write("0000000\0", 108, "utf8"); + header.write("0000000\0", 116, "utf8"); + const size = Buffer.byteLength(EXECUTABLE_BYTES); + header.write(`${size.toString(8).padStart(11, "0")}\0`, 124, "utf8"); + header.write("00000000000\0", 136, "utf8"); + header.write(" ", 148, "utf8"); + header.write("0", 156, "utf8"); + header.write("ustar\0", 257, "utf8"); + header.write("00", 263, "utf8"); + const body = Buffer.alloc(512, 0); + body.write(EXECUTABLE_BYTES, 0, "utf8"); + return gzipSync(Buffer.concat([header, body, Buffer.alloc(1024, 0)])); +} + +/** A pin whose Linux target matches `bytes`. */ +function pinFor(bytes) { + const targets = {}; + for (const target of TARGETS) { + targets[target] = { asset: "PLACEHOLDER", sha256: PLACEHOLDER, size: 0 }; + } + targets[LINUX] = { + asset: "runvil-gateway-x86_64-unknown-linux-gnu.tar.gz", + sha256: sha256Hex(bytes), + size: bytes.length, + }; + return { + pin_format: 1, + repository: "cppalliance/runvil-gateway", + tag: "gateway-v0.4.0", + version: "0.4.0", + executable: "runvil-gateway", + min_gateway: "0.4.0", + spec: { + openapi: { path: "gateway-pin/openapi.yaml", sha256: "a".repeat(64) }, + asyncapi: { path: "gateway-pin/asyncapi.yaml", sha256: "b".repeat(64) }, + }, + targets, + }; +} + +/** A pin whose every field is still a placeholder. */ +function placeholderPin() { + return { + pin_format: 1, + repository: "cppalliance/runvil-gateway", + tag: PLACEHOLDER, + version: PLACEHOLDER, + executable: BINARY, + min_gateway: PLACEHOLDER, + spec: { + openapi: { path: "gateway-pin/openapi.yaml", sha256: PLACEHOLDER }, + asyncapi: { path: "gateway-pin/asyncapi.yaml", sha256: PLACEHOLDER }, + }, + targets: Object.fromEntries( + TARGETS.map((target) => [target, { asset: PLACEHOLDER, sha256: PLACEHOLDER, size: 0 }]), + ), + }; +} + +/** A pin whose every field is filled, so a consumer may use it. */ +function filledPin() { + const pin = pinFor(Buffer.from("unused")); + for (const target of TARGETS) { + pin["targets"][target] = { + asset: `runvil-gateway-${target}.tar.gz`, + sha256: "d".repeat(64), + size: 10, + }; + } + return pin; +} + +/** A temporary repository root holding `pin`, with a download cache beside it. */ +function sandbox(pin = pinFor(releaseArchive())) { + const base = mkdtempSync(join(tmpdir(), "runvil-fetch-gateway-")); + const root = join(base, "repo"); + mkdirSync(join(root, "gateway-pin"), { recursive: true }); + writeFileSync(join(root, "gateway-pin", "pin.json"), renderPin(pin)); + return { base, root, cache: join(base, "cache") }; +} + +function cleanup(base) { + rmSync(base, { force: true, recursive: true }); +} + +test("stub mode stages a file at Tauri's path without downloading", async () => { + const { base, root, cache } = sandbox(); + try { + let downloads = 0; + const result = await fetchGatewaySidecar({ + root, + target: LINUX, + cacheDir: cache, + stub: true, + download: async () => { + downloads += 1; + return Buffer.alloc(0); + }, + }); + assert.equal(downloads, 0, "the stub path downloads nothing"); + assert.equal( + result.staged, + join(root, "crates", "workshop", "desktop", "binaries", "runvil-gateway-x86_64-unknown-linux-gnu"), + ); + assert.ok(existsSync(result.staged)); + assert.match(readFileSync(result.staged, "utf8"), /not an executable/); + assert.equal(result.stub, true); + assert.ok( + existsSync(`${result.staged}.pinned`), + "a stub is marked so the desktop build script leaves it in place", + ); + } finally { + cleanup(base); + } +}); + +test("stub mode needs no pin, so a fresh checkout can compile", async () => { + const base = mkdtempSync(join(tmpdir(), "runvil-fetch-gateway-")); + try { + const result = await fetchGatewaySidecar({ + root: base, + target: WINDOWS, + cacheDir: join(base, "cache"), + stub: true, + }); + assert.equal( + result.staged, + join(base, "crates", "workshop", "desktop", "binaries", "runvil-gateway-x86_64-pc-windows-msvc.exe"), + ); + } finally { + cleanup(base); + } +}); + +test("a real fetch verifies the archive, extracts it, and stages it", async () => { + const archive = releaseArchive(); + const { base, root, cache } = sandbox(pinFor(archive)); + try { + const result = await fetchGatewaySidecar({ + root, + target: LINUX, + cacheDir: cache, + download: async () => archive, + }); + assert.equal(result.downloaded, true); + assert.equal(result.tag, "gateway-v0.4.0"); + assert.equal(readFileSync(result.staged, "utf8"), EXECUTABLE_BYTES); + assert.ok( + existsSync(`${result.staged}.pinned`), + "a verified download is marked so the desktop build script keeps it", + ); + // The archive is cached under the tag, so the next run refetches nothing. + assert.ok(existsSync(join(cache, "gateway-v0.4.0", "runvil-gateway-x86_64-unknown-linux-gnu.tar.gz"))); + let downloads = 0; + const second = await fetchGatewaySidecar({ + root, + target: LINUX, + cacheDir: cache, + download: async () => { + downloads += 1; + return archive; + }, + }); + assert.equal(downloads, 0, "the cached archive is reused"); + assert.equal(second.downloaded, false); + } finally { + cleanup(base); + } +}); + +test("a checksum mismatch fails loudly and reports both hashes", async () => { + const archive = releaseArchive(); + const pin = pinFor(archive); + pin["targets"][LINUX]["sha256"] = createHash("sha256").update("other").digest("hex"); + const { base, root, cache } = sandbox(pin); + try { + await assert.rejects( + () => fetchGatewaySidecar({ root, target: LINUX, cacheDir: cache, download: async () => archive }), + (error) => { + assert.match(error.message, /sha256 is [0-9a-f]{64}, but the pin records [0-9a-f]{64}/); + assert.ok(error.message.includes(sha256Hex(archive)), error.message); + assert.ok(error.message.includes(pin["targets"][LINUX]["sha256"]), error.message); + return true; + }, + ); + assert.equal( + existsSync(join(root, "crates", "workshop", "desktop", "binaries", "runvil-gateway-x86_64-unknown-linux-gnu")), + false, + "a mismatch stages nothing", + ); + } finally { + cleanup(base); + } +}); + +test("a size mismatch fails before the hash is reported as the problem", () => { + const bytes = Buffer.from("payload"); + assert.throws( + () => verifyAsset(bytes, { sha256: sha256Hex(bytes), size: 99 }, "asset.tar.gz"), + /is 7 bytes, but the pin records 99 bytes/, + ); +}); + +test("a placeholder pin refuses to fetch and names the fields to fill", async () => { + const { base, root, cache } = sandbox(parsePin(renderPin(placeholderPin()))); + try { + await assert.rejects( + () => fetchGatewaySidecar({ root, target: LINUX, cacheDir: cache, download: async () => Buffer.alloc(0) }), + /still holds placeholders/, + ); + } finally { + cleanup(base); + } +}); + +test("an archive missing the executable names what it expected", async () => { + const header = Buffer.alloc(512, 0); + header.write("pkg/README.md", 0, 100, "utf8"); + header.write("0000644\0", 100, "utf8"); + header.write("0000000\0", 108, "utf8"); + header.write("0000000\0", 116, "utf8"); + header.write("00000000003\0", 124, "utf8"); + header.write("00000000000\0", 136, "utf8"); + header.write(" ", 148, "utf8"); + header.write("0", 156, "utf8"); + const body = Buffer.alloc(512, 0); + body.write("abc", 0, "utf8"); + const archive = gzipSync(Buffer.concat([header, body, Buffer.alloc(1024, 0)])); + const { base, root, cache } = sandbox(pinFor(archive)); + try { + await assert.rejects( + () => fetchGatewaySidecar({ root, target: LINUX, cacheDir: cache, download: async () => archive }), + /holds no runvil-gateway/, + ); + } finally { + cleanup(base); + } +}); + +test("attestation verification calls gh release verify-asset once", async () => { + const archive = releaseArchive(); + const { base, root, cache } = sandbox(pinFor(archive)); + try { + const calls = []; + await fetchGatewaySidecar({ + root, + target: LINUX, + cacheDir: cache, + verifyAttestation: true, + download: async () => archive, + gh: (args) => { + calls.push(args); + return ""; + }, + }); + assert.equal(calls.length, 1); + assert.deepEqual(calls[0].slice(0, 2), ["release", "verify-asset"]); + assert.ok(calls[0].includes("gateway-v0.4.0")); + assert.ok(calls[0].includes("cppalliance/runvil-gateway")); + } finally { + cleanup(base); + } +}); + +test("attestation is skipped unless asked for", async () => { + const archive = releaseArchive(); + const { base, root, cache } = sandbox(pinFor(archive)); + try { + let calls = 0; + await fetchGatewaySidecar({ + root, + target: LINUX, + cacheDir: cache, + download: async () => archive, + gh: () => { + calls += 1; + return ""; + }, + }); + assert.equal(calls, 0); + } finally { + cleanup(base); + } +}); + +test("the placeholder report names every unfilled field and the complete case", () => { + const report = placeholderReport(placeholderPin(), { source: "gateway-pin/pin.json" }); + assert.match(report, /gateway-pin\/pin\.json still has 20 unfilled field/); + for (const field of [ + "tag", + "version", + "min_gateway", + "spec.openapi.sha256", + "spec.asyncapi.sha256", + "targets.aarch64-unknown-linux-gnu.sha256", + "targets.x86_64-pc-windows-msvc.size", + ]) { + assert.ok(report.includes(field), `${field} missing from:\n${report}`); + } + assert.equal(placeholderReport(filledPin()), "the Gateway pin is complete"); +}); + +test("host triple is one of the five released targets or a named refusal", () => { + const triple = hostTarget(); + assert.ok(TARGETS.includes(triple) || triple === "aarch64-pc-windows-msvc", triple); +}); + +test("a target the pin does not record is refused before any download", async () => { + const { base, root, cache } = sandbox(); + try { + let downloads = 0; + await assert.rejects( + () => + fetchGatewaySidecar({ + root, + target: "sparc-unknown-linux-gnu", + cacheDir: cache, + download: async () => { + downloads += 1; + return Buffer.alloc(0); + }, + }), + /unsupported Gateway target sparc-unknown-linux-gnu/, + ); + assert.equal(downloads, 0); + } finally { + cleanup(base); + } +}); diff --git a/tools/gateway-archive.mjs b/tools/gateway-archive.mjs new file mode 100644 index 000000000..7ca6e184e --- /dev/null +++ b/tools/gateway-archive.mjs @@ -0,0 +1,131 @@ +// Reading one executable out of a release archive. +// +// The Gateway's releases are built by cargo-dist, which publishes a zip for +// Windows and a gzipped tar elsewhere. Node ships no tar or zip reader, and +// adding a dependency to run one build step is not worth it, so this module +// reads exactly the two shapes cargo-dist writes and refuses everything +// else by name. + +import { gunzipSync, inflateRawSync } from "node:zlib"; +import { basename } from "node:path"; + +const TAR_BLOCK = 512; +const ZIP_LOCAL_HEADER = 0x04034b50; +const ZIP_CENTRAL_HEADER = 0x02014b50; +const ZIP_END_OF_CENTRAL_DIRECTORY = 0x06054b50; +const ZIP_STORED = 0; +const ZIP_DEFLATED = 8; +const ZIP64_SENTINEL = 0xffffffff; + +/** The archive formats a Gateway release asset may use, by extension. */ +export const ARCHIVE_FORMATS = [".tar.gz", ".tgz", ".zip"]; + +/** + * Extracts the file named `wanted` (matched on its base name) from `bytes`, + * choosing the reader by the asset's extension. Returns `null` when the + * archive holds no such file, so the caller names what it expected. + */ +export function extractExecutable(assetName, bytes, wanted) { + const lower = assetName.toLowerCase(); + if (lower.endsWith(".zip")) { + return extractZip(bytes, wanted); + } + if (lower.endsWith(".tar.gz") || lower.endsWith(".tgz")) { + return extractTarGz(bytes, wanted); + } + throw new Error( + `unsupported Gateway archive ${assetName}; expected one of ${ARCHIVE_FORMATS.join(", ")}`, + ); +} + +/** Reads a NUL-terminated string from `buffer` at `offset`, at most `length` bytes. */ +function readString(buffer, offset, length) { + const slice = buffer.subarray(offset, offset + length); + const end = slice.indexOf(0); + return slice.toString("utf8", 0, end === -1 ? slice.length : end); +} + +/** Extracts the file named `wanted` from a gzipped tar archive. */ +export function extractTarGz(bytes, wanted) { + const tar = gunzipSync(bytes); + let offset = 0; + while (offset + TAR_BLOCK <= tar.length) { + const header = tar.subarray(offset, offset + TAR_BLOCK); + if (header.every((byte) => byte === 0)) { + break; + } + const name = readString(header, 0, 100); + const sizeText = readString(header, 124, 12).trim(); + const size = sizeText === "" ? 0 : Number.parseInt(sizeText, 8); + if (!Number.isFinite(size) || size < 0) { + throw new Error(`malformed tar header for ${name || ""}`); + } + const type = header[156] === 0 ? "0" : String.fromCharCode(header[156]); + const prefix = readString(header, 345, 155); + const full = prefix === "" ? name : `${prefix}/${name}`; + offset += TAR_BLOCK; + const data = tar.subarray(offset, offset + size); + offset += Math.ceil(size / TAR_BLOCK) * TAR_BLOCK; + // Only regular files carry an executable; "0" is the plain-file type + // and a zero byte is what some writers put there instead. + if ((type === "0" || type === "7") && basename(full) === wanted) { + return Buffer.from(data); + } + } + return null; +} + +/** Extracts the file named `wanted` from a zip archive. */ +export function extractZip(bytes, wanted) { + const directory = findZipCentralDirectory(bytes); + let offset = directory.offset; + for (let index = 0; index < directory.count; index += 1) { + if (bytes.readUInt32LE(offset) !== ZIP_CENTRAL_HEADER) { + throw new Error("malformed zip central directory"); + } + const method = bytes.readUInt16LE(offset + 10); + const compressedSize = bytes.readUInt32LE(offset + 20); + const nameLength = bytes.readUInt16LE(offset + 28); + const extraLength = bytes.readUInt16LE(offset + 30); + const commentLength = bytes.readUInt16LE(offset + 32); + const localOffset = bytes.readUInt32LE(offset + 42); + const name = bytes.toString("utf8", offset + 46, offset + 46 + nameLength); + offset += 46 + nameLength + extraLength + commentLength; + if (compressedSize === ZIP64_SENTINEL || localOffset === ZIP64_SENTINEL) { + throw new Error(`zip64 archives are not supported (${name}); publish a plain zip`); + } + if (basename(name) !== wanted) { + continue; + } + if (bytes.readUInt32LE(localOffset) !== ZIP_LOCAL_HEADER) { + throw new Error(`malformed zip local header for ${name}`); + } + const localNameLength = bytes.readUInt16LE(localOffset + 26); + const localExtraLength = bytes.readUInt16LE(localOffset + 28); + const dataStart = localOffset + 30 + localNameLength + localExtraLength; + const data = bytes.subarray(dataStart, dataStart + compressedSize); + if (method === ZIP_STORED) { + return Buffer.from(data); + } + if (method === ZIP_DEFLATED) { + return inflateRawSync(data); + } + throw new Error(`unsupported zip compression method ${method} for ${name}`); + } + return null; +} + +/** Locates the zip end-of-central-directory record and the directory it points at. */ +function findZipCentralDirectory(bytes) { + const lowest = Math.max(0, bytes.length - 22 - 0xffff); + for (let offset = bytes.length - 22; offset >= lowest; offset -= 1) { + if (bytes.readUInt32LE(offset) !== ZIP_END_OF_CENTRAL_DIRECTORY) { + continue; + } + return { + count: bytes.readUInt16LE(offset + 10), + offset: bytes.readUInt32LE(offset + 16), + }; + } + throw new Error("not a zip archive: no end-of-central-directory record"); +} diff --git a/tools/gateway-archive.test.mjs b/tools/gateway-archive.test.mjs new file mode 100644 index 000000000..8dd68f902 --- /dev/null +++ b/tools/gateway-archive.test.mjs @@ -0,0 +1,120 @@ +import assert from "node:assert/strict"; +import { gzipSync } from "node:zlib"; +import test from "node:test"; + +import { + ARCHIVE_FORMATS, + extractExecutable, + extractTarGz, + extractZip, +} from "./gateway-archive.mjs"; + +const BLOCK = 512; + +/** One tar header block for a regular file of `size` bytes. */ +function tarHeader(name, size) { + const header = Buffer.alloc(BLOCK, 0); + header.write(name, 0, 100, "utf8"); + header.write("0000644\0", 100, "utf8"); + header.write("0000000\0", 108, "utf8"); + header.write("0000000\0", 116, "utf8"); + header.write(`${size.toString(8).padStart(11, "0")}\0`, 124, "utf8"); + header.write("00000000000\0", 136, "utf8"); + header.write(" ", 148, "utf8"); + header.write("0", 156, "utf8"); + header.write("ustar\0", 257, "utf8"); + header.write("00", 263, "utf8"); + return header; +} + +/** A gzipped tar archive holding `files`, each `{ name, content }`. */ +function tarGz(files) { + const blocks = []; + for (const file of files) { + const body = Buffer.from(file.content, "utf8"); + blocks.push(tarHeader(file.name, body.length)); + const padded = Buffer.alloc(Math.ceil(body.length / BLOCK) * BLOCK, 0); + body.copy(padded); + blocks.push(padded); + } + blocks.push(Buffer.alloc(BLOCK * 2, 0)); + return gzipSync(Buffer.concat(blocks)); +} + +/** A stored (uncompressed) zip archive holding `files`. */ +function storedZip(files) { + const locals = []; + const central = []; + let offset = 0; + for (const file of files) { + const body = Buffer.from(file.content, "utf8"); + const name = Buffer.from(file.name, "utf8"); + const local = Buffer.alloc(30, 0); + local.writeUInt32LE(0x04034b50, 0); + local.writeUInt16LE(20, 4); + local.writeUInt32LE(0, 14); + local.writeUInt32LE(body.length, 18); + local.writeUInt32LE(body.length, 22); + local.writeUInt16LE(name.length, 26); + locals.push(local, name, body); + const entry = Buffer.alloc(46, 0); + entry.writeUInt32LE(0x02014b50, 0); + entry.writeUInt16LE(20, 4); + entry.writeUInt16LE(20, 6); + entry.writeUInt32LE(0, 16); + entry.writeUInt32LE(body.length, 20); + entry.writeUInt32LE(body.length, 24); + entry.writeUInt16LE(name.length, 28); + entry.writeUInt32LE(offset, 42); + central.push(entry, name); + offset += 30 + name.length + body.length; + } + const directory = Buffer.concat(central); + const localBytes = Buffer.concat(locals); + const end = Buffer.alloc(22, 0); + end.writeUInt32LE(0x06054b50, 0); + end.writeUInt16LE(files.length, 8); + end.writeUInt16LE(files.length, 10); + end.writeUInt32LE(directory.length, 12); + end.writeUInt32LE(localBytes.length, 16); + return Buffer.concat([localBytes, directory, end]); +} + +test("reads the executable out of a gzipped tar by its base name", () => { + const bytes = tarGz([ + { name: "runvil-gateway-x86_64-unknown-linux-gnu/README.md", content: "read me" }, + { name: "runvil-gateway-x86_64-unknown-linux-gnu/runvil-gateway", content: "ELF" }, + ]); + assert.equal(extractTarGz(bytes, "runvil-gateway").toString("utf8"), "ELF"); +}); + +test("reads the executable out of a zip by its base name", () => { + const bytes = storedZip([ + { name: "runvil-gateway-x86_64-pc-windows-msvc/LICENSE", content: "license" }, + { name: "runvil-gateway-x86_64-pc-windows-msvc/runvil-gateway.exe", content: "MZ" }, + ]); + assert.equal(extractZip(bytes, "runvil-gateway.exe").toString("utf8"), "MZ"); +}); + +test("an archive without the executable reports nothing rather than a guess", () => { + const bytes = tarGz([{ name: "pkg/README.md", content: "read me" }]); + assert.equal(extractTarGz(bytes, "runvil-gateway"), null); + assert.equal(extractZip(storedZip([{ name: "a.txt", content: "x" }]), "runvil-gateway"), null); +}); + +test("the extension picks the reader", () => { + const tar = tarGz([{ name: "b/runvil-gateway", content: "bin" }]); + assert.equal(extractExecutable("x.tar.gz", tar, "runvil-gateway").toString(), "bin"); + assert.equal(extractExecutable("x.tgz", tar, "runvil-gateway").toString(), "bin"); + const zip = storedZip([{ name: "b/runvil-gateway.exe", content: "bin" }]); + assert.equal(extractExecutable("x.zip", zip, "runvil-gateway.exe").toString(), "bin"); +}); + +test("an unsupported archive extension is refused by name", () => { + assert.throws(() => extractExecutable("x.7z", Buffer.alloc(0), "runvil-gateway"), /unsupported Gateway archive x\.7z/); + assert.deepEqual(ARCHIVE_FORMATS, [".tar.gz", ".tgz", ".zip"]); +}); + +test("a non-zip buffer is refused rather than read as garbage", () => { + assert.throws(() => extractZip(Buffer.alloc(64, 7), "runvil-gateway.exe"), /not a zip archive/); +}); diff --git a/tools/gateway-pin.mjs b/tools/gateway-pin.mjs new file mode 100644 index 000000000..b48852f36 --- /dev/null +++ b/tools/gateway-pin.mjs @@ -0,0 +1,208 @@ +// The Gateway pin: the one file that records the release Workshop obtains +// its sidecar from, plus the checksums that prove the bytes. +// +// Two scripts read it. `fetch-gateway-sidecar.mjs` downloads one target's +// asset and verifies it against the recorded sha256; `bump-gateway.mjs` +// rewrites it from a published release. Both refuse a placeholder: a pin +// that names nothing must fail loudly, because a silent empty download +// would ship a Workshop without its Gateway. + +import { readFileSync } from "node:fs"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const REPOSITORY_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), ".."); + +/** The directory holding the pin and the committed spec copies. */ +export const PIN_DIRECTORY = "gateway-pin"; + +/** The pin's file name inside {@link PIN_DIRECTORY}. */ +export const PIN_FILE_NAME = "pin.json"; + +/** The value that marks a field nobody has filled in yet. */ +export const PLACEHOLDER = "PLACEHOLDER"; + +/** Every target triple a Gateway release must publish. */ +export const TARGETS = [ + "x86_64-pc-windows-msvc", + "aarch64-apple-darwin", + "x86_64-apple-darwin", + "x86_64-unknown-linux-gnu", + "aarch64-unknown-linux-gnu", +]; + +/** The repository that publishes Gateway releases. */ +export const DEFAULT_RELEASE_REPOSITORY = "cppalliance/runvil-gateway"; + +/** The absolute path of the pin file for the repository at `root`. */ +export function pinPath(root = REPOSITORY_ROOT) { + return join(root, PIN_DIRECTORY, PIN_FILE_NAME); +} + +/** The absolute path of one committed spec copy for the repository at `root`. */ +export function specPath(root = REPOSITORY_ROOT, name) { + return join(root, PIN_DIRECTORY, name); +} + +function requireObject(value, field, source) { + if (value === null || typeof value !== "object" || Array.isArray(value)) { + throw new Error(`${source}: ${field} must be a JSON object`); + } + return value; +} + +function requireString(value, field, source) { + if (typeof value !== "string" || value.length === 0) { + throw new Error(`${source}: ${field} must be a non-empty string`); + } + return value; +} + +function requireNumber(value, field, source) { + if (typeof value !== "number" || !Number.isInteger(value) || value < 0) { + throw new Error(`${source}: ${field} must be a non-negative integer`); + } + return value; +} + +/** + * Parses and shape-checks pin text. Shape checks only: a placeholder + * value passes here and is refused later by the consumer that would have + * used it, so a fresh pin stays readable while nobody can fetch from it. + */ +export function parsePin(text, source = PIN_FILE_NAME) { + let pin; + try { + pin = JSON.parse(text); + } catch (error) { + throw new Error(`${source}: not valid JSON: ${error.message}`); + } + requireObject(pin, "the pin", source); + if (pin["pin_format"] !== 1) { + throw new Error(`${source}: pin_format must be 1, got ${JSON.stringify(pin["pin_format"])}`); + } + requireString(pin["repository"], "repository", source); + requireString(pin["tag"], "tag", source); + requireString(pin["version"], "version", source); + requireString(pin["executable"], "executable", source); + requireString(pin["min_gateway"], "min_gateway", source); + const spec = requireObject(pin["spec"], "spec", source); + for (const name of ["openapi", "asyncapi"]) { + const entry = requireObject(spec[name], `spec.${name}`, source); + requireString(entry["path"], `spec.${name}.path`, source); + requireString(entry["sha256"], `spec.${name}.sha256`, source); + } + const targets = requireObject(pin["targets"], "targets", source); + for (const target of TARGETS) { + const entry = requireObject(targets[target], `targets.${target}`, source); + requireString(entry["asset"], `targets.${target}.asset`, source); + requireString(entry["sha256"], `targets.${target}.sha256`, source); + requireNumber(entry["size"], `targets.${target}.size`, source); + } + for (const target of Object.keys(targets)) { + if (!TARGETS.includes(target)) { + throw new Error(`${source}: targets.${target} is not one of the five release targets`); + } + } + return pin; +} + +/** Reads and shape-checks the pin file of the repository at `root`. */ +export function readPin(root = REPOSITORY_ROOT) { + const path = pinPath(root); + let text; + try { + text = readFileSync(path, "utf8"); + } catch (error) { + throw new Error(`cannot read the Gateway pin at ${path}: ${error.message}`); + } + return parsePin(text, path); +} + +/** Renders a pin canonically: two-space indent and a trailing newline. */ +export function renderPin(pin) { + return `${JSON.stringify(pin, null, 2)}\n`; +} + +/** One target's entry, refusing a triple the pin does not record. */ +export function targetEntry(pin, target) { + const entry = pin["targets"]?.[target]; + if (entry === undefined) { + throw new Error( + `the Gateway pin records no target ${target}; recorded: ${Object.keys(pin["targets"] ?? {}).join(", ")}`, + ); + } + return entry; +} + +/** + * Every field of `pin` that is still a placeholder, as dotted paths. An + * empty list means the pin names a release a consumer may use. + */ +export function placeholderFields(pin) { + const found = []; + for (const field of ["tag", "version", "min_gateway"]) { + if (pin[field] === PLACEHOLDER) { + found.push(field); + } + } + for (const name of ["openapi", "asyncapi"]) { + if (pin["spec"]?.[name]?.["sha256"] === PLACEHOLDER) { + found.push(`spec.${name}.sha256`); + } + } + for (const [target, entry] of Object.entries(pin["targets"] ?? {})) { + if (entry.asset === PLACEHOLDER) { + found.push(`targets.${target}.asset`); + } + if (entry.sha256 === PLACEHOLDER) { + found.push(`targets.${target}.sha256`); + } + if (entry.size === 0) { + found.push(`targets.${target}.size`); + } + } + return found; +} + +/** + * Refuses a pin a consumer cannot use, naming the file and every field + * still to fill in. `target` narrows the refusal to the fields that + * target's download needs, so a half-filled pin fails only where it must. + */ +export function requireFilledPin(pin, { source = PIN_FILE_NAME, target } = {}) { + let missing = placeholderFields(pin); + if (target !== undefined) { + missing = missing.filter( + (field) => !field.startsWith("targets.") || field.startsWith(`targets.${target}.`), + ); + } + if (missing.length > 0) { + throw new Error( + `${source} still holds placeholders (${missing.join(", ")}); ` + + "run `node tools/bump-gateway.mjs ` against a published Gateway release", + ); + } +} + +/** + * A reader-facing report of every field still to fill in, or a sentence + * saying the pin is complete. `tools/fetch-gateway-sidecar.mjs` prints this + * when a person runs it before the pin is filled, so the failure names the + * work rather than only the symptom. + */ +export function placeholderReport(pin, { source = PIN_FILE_NAME } = {}) { + const fields = placeholderFields(pin); + if (fields.length === 0) { + return "the Gateway pin is complete"; + } + return [ + `${source} still has ${fields.length} unfilled field(s):`, + ...fields.map((field) => ` ${field}`), + ].join("\n"); +} + +/** The release download URL of one asset. */ +export function releaseAssetUrl(pin, asset) { + return `https://github.com/${pin["repository"]}/releases/download/${pin["tag"]}/${asset}`; +} diff --git a/tools/gateway-pin.test.mjs b/tools/gateway-pin.test.mjs new file mode 100644 index 000000000..c5b2aeb6c --- /dev/null +++ b/tools/gateway-pin.test.mjs @@ -0,0 +1,169 @@ +import assert from "node:assert/strict"; +import { mkdtempSync, readFileSync, rmSync, writeFileSync, mkdirSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; + +import { + PIN_DIRECTORY, + PLACEHOLDER, + TARGETS, + parsePin, + placeholderFields, + readPin, + releaseAssetUrl, + renderPin, + requireFilledPin, + specPath, + targetEntry, +} from "./gateway-pin.mjs"; + +const WINDOWS = "x86_64-pc-windows-msvc"; +const LINUX = "x86_64-unknown-linux-gnu"; + +/** A filled pin: every field a consumer reads names a real release. */ +function filledPin() { + const targets = {}; + for (const target of TARGETS) { + targets[target] = { + asset: `runvil-gateway-${target}.tar.gz`, + sha256: "a".repeat(64), + size: 1234, + }; + } + return { + pin_format: 1, + repository: "cppalliance/runvil-gateway", + tag: "gateway-v0.4.0", + version: "0.4.0", + executable: "runvil-gateway", + min_gateway: "0.4.0", + spec: { + openapi: { path: "gateway-pin/openapi.yaml", sha256: "b".repeat(64) }, + asyncapi: { path: "gateway-pin/asyncapi.yaml", sha256: "c".repeat(64) }, + }, + targets, + }; +} + +/** The committed pin, read once: the repository's real file. */ +function committedPin() { + return readPin(); +} + +/** A temporary repository root holding `pin` at the pin path. */ +function repositoryWith(pin) { + const root = mkdtempSync(join(tmpdir(), "runvil-gateway-pin-")); + mkdirSync(join(root, PIN_DIRECTORY), { recursive: true }); + writeFileSync(join(root, PIN_DIRECTORY, "pin.json"), renderPin(pin)); + return root; +} + +test("the committed pin records all five release targets", () => { + const pin = committedPin(); + assert.deepEqual(Object.keys(pin["targets"]).sort(), [...TARGETS].sort()); + assert.equal(pin["executable"], "runvil-gateway"); +}); + +test("the committed pin is still a placeholder and says which fields are unfilled", () => { + const fields = placeholderFields(committedPin()); + assert.ok(fields.includes("tag"), `${fields}`); + assert.ok(fields.includes("min_gateway"), `${fields}`); + for (const target of TARGETS) { + assert.ok(fields.includes(`targets.${target}.sha256`), `${fields}`); + } + assert.ok(fields.includes("spec.openapi.sha256"), `${fields}`); +}); + +test("a placeholder pin is refused by name, not fetched", () => { + const pin = committedPin(); + assert.throws( + () => requireFilledPin(pin, { source: "gateway-pin/pin.json" }), + /still holds placeholders/, + ); +}); + +test("a target-scoped refusal names only that target's unfilled fields", () => { + const pin = filledPin(); + pin["targets"][WINDOWS]["sha256"] = PLACEHOLDER; + assert.equal(requireFilledPin(pin, { target: LINUX }), undefined); + assert.throws(() => requireFilledPin(pin, { target: WINDOWS }), /targets\.x86_64-pc-windows-msvc\.sha256/); +}); + +test("a filled pin passes the refusal", () => { + assert.equal(requireFilledPin(filledPin(), { target: WINDOWS }), undefined); + assert.deepEqual(placeholderFields(filledPin()), []); +}); + +test("parsing rejects an unknown pin format", () => { + const text = renderPin({ ...filledPin(), pin_format: 2 }); + assert.throws(() => parsePin(text, "pin.json"), /pin_format must be 1/); +}); + +test("parsing rejects a missing target", () => { + const pin = filledPin(); + delete pin["targets"][LINUX]; + assert.throws(() => parsePin(renderPin(pin), "pin.json"), /targets\.x86_64-unknown-linux-gnu/); +}); + +test("parsing rejects a target outside the five released triples", () => { + const pin = filledPin(); + pin["targets"]["aarch64-pc-windows-msvc"] = { + asset: "x", + sha256: "d".repeat(64), + size: 1, + }; + assert.throws(() => parsePin(renderPin(pin), "pin.json"), /not one of the five release targets/); +}); + +test("parsing rejects a negative size rather than trusting it", () => { + const pin = filledPin(); + pin["targets"][LINUX]["size"] = -1; + assert.throws(() => parsePin(renderPin(pin), "pin.json"), /size must be a non-negative integer/); +}); + +test("an unknown target triple is refused by name", () => { + assert.throws(() => targetEntry(filledPin(), "sparc-unknown-linux-gnu"), /records no target/); +}); + +test("rendering is canonical, so a rewrite only changes what changed", () => { + const pin = filledPin(); + const rendered = renderPin(pin); + assert.ok(rendered.endsWith("}\n")); + assert.equal(renderPin(JSON.parse(rendered)), rendered); +}); + +test("the release URL names the pinned tag and asset", () => { + assert.equal( + releaseAssetUrl(filledPin(), "runvil-gateway-aarch64-apple-darwin.tar.gz"), + "https://github.com/cppalliance/runvil-gateway/releases/download/gateway-v0.4.0/runvil-gateway-aarch64-apple-darwin.tar.gz", + ); +}); + +test("reading a repository's pin finds the file under gateway-pin", () => { + const root = repositoryWith(filledPin()); + try { + const pin = readPin(root); + assert.equal(pin["tag"], "gateway-v0.4.0"); + assert.equal(specPath(root, "openapi.yaml"), join(root, "gateway-pin", "openapi.yaml")); + } finally { + rmSync(root, { force: true, recursive: true }); + } +}); + +test("a missing pin file reports the path it looked at", () => { + const root = mkdtempSync(join(tmpdir(), "runvil-gateway-pin-")); + try { + assert.throws(() => readPin(root), /cannot read the Gateway pin at .*pin\.json/); + } finally { + rmSync(root, { force: true, recursive: true }); + } +}); + +test("the committed spec copies exist where the pin says they are", () => { + const pin = committedPin(); + for (const name of ["openapi", "asyncapi"]) { + const path = specPath(undefined, pin["spec"][name]["path"].split("/").at(-1)); + assert.ok(readFileSync(path, "utf8").includes("PLACEHOLDER"), path); + } +}); \ No newline at end of file diff --git a/tools/stage-gateway-sidecar.mjs b/tools/stage-gateway-sidecar.mjs index 068bea947..fac019631 100644 --- a/tools/stage-gateway-sidecar.mjs +++ b/tools/stage-gateway-sidecar.mjs @@ -3,7 +3,9 @@ import { existsSync, lstatSync, mkdirSync, + readFileSync, rmSync, + writeFileSync, } from "node:fs"; import { basename, dirname, join, resolve } from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; @@ -29,7 +31,12 @@ export function gatewaySidecarName(target) { return targetNames(target).sidecar; } -function sidecarPath(root, target) { +/** + * The file tauri.conf.json's `externalBin` entry resolves for `target`. + * This is the one owner of that naming rule: the fetch script, the build + * pipeline, and CI all ask for the path rather than spelling it again. + */ +export function gatewaySidecarPath({ root = REPOSITORY_ROOT, target }) { return join( root, "crates", @@ -55,22 +62,74 @@ export function stageGatewaySidecar({ root = REPOSITORY_ROOT, target, source }) ); } - const destination = sidecarPath(root, target); + const destination = gatewaySidecarPath({ root, target }); mkdirSync(dirname(destination), { recursive: true }); copyFileSync(sourcePath, destination); return destination; } +/** + * Marks the staged sidecar as authoritative: the desktop build script then + * leaves it alone instead of refreshing it from a workspace-built Gateway. + * Without this, a stale `target//runvil-gateway` silently replaces + * a downloaded and checksummed release binary. + * + * `kind` records which of the two it was, `release` or `stub`, so a + * developer running `cargo workshop` can be told when the sidecar it reuses + * is only a placeholder. + */ +export function markGatewaySidecarPinned({ root = REPOSITORY_ROOT, target, kind = "release", tag = null }) { + const marker = gatewaySidecarPinMarkerPath({ root, target }); + mkdirSync(dirname(marker), { recursive: true }); + writeFileSync( + marker, + `${JSON.stringify({ kind, tag }, null, 2)}\n`, + ); + return marker; +} + +/** + * The marker's record, or `null` when the sidecar was not staged by the + * fetch script. A malformed marker still counts as a marker: the desktop + * build script only needs to know the file is not its to refresh, and an + * unknown kind is reported as unknown rather than assumed to be a release. + */ +export function readGatewaySidecarPin({ root = REPOSITORY_ROOT, target }) { + const marker = gatewaySidecarPinMarkerPath({ root, target }); + let text; + try { + text = readFileSync(marker, "utf8"); + } catch { + return null; + } + try { + const parsed = JSON.parse(text); + return { kind: typeof parsed["kind"] === "string" ? parsed["kind"] : "unknown", tag: parsed["tag"] ?? null }; + } catch { + return { kind: "unknown", tag: null }; + } +} + +/** + * The marker beside the staged sidecar saying it is authoritative. Named + * so the sidecar's own name stays exactly what Tauri expects; the marker + * sits next to it and is never bundled. + */ +export function gatewaySidecarPinMarkerPath({ root = REPOSITORY_ROOT, target }) { + return `${gatewaySidecarPath({ root, target })}.pinned`; +} + export function removeGatewaySidecar({ root = REPOSITORY_ROOT, target }) { - const destination = sidecarPath(root, target); + const destination = gatewaySidecarPath({ root, target }); rmSync(destination, { force: true }); + rmSync(gatewaySidecarPinMarkerPath({ root, target }), { force: true }); return destination; } function parseArguments(args) { const [action, ...options] = args; - if (action !== "stage" && action !== "remove") { - throw new Error("usage: stage-gateway-sidecar.mjs --target [--source ]"); + if (action !== "stage" && action !== "remove" && action !== "path") { + throw new Error("usage: stage-gateway-sidecar.mjs --target [--source ]"); } const values = new Map(); @@ -94,14 +153,18 @@ function parseArguments(args) { if (action === "stage" && source === undefined) { throw new Error("missing required sidecar argument: --source"); } - if (action === "remove" && source !== undefined) { - throw new Error("remove does not accept --source"); + if (action !== "stage" && source !== undefined) { + throw new Error(`${action} does not accept --source`); } return { action, source, target }; } function main(args) { const { action, source, target } = parseArguments(args); + if (action === "path") { + console.log(gatewaySidecarPath({ target })); + return; + } const path = action === "stage" ? stageGatewaySidecar({ source, target }) diff --git a/tools/stage-gateway-sidecar.test.mjs b/tools/stage-gateway-sidecar.test.mjs index f314ce4a8..140cd26ee 100644 --- a/tools/stage-gateway-sidecar.test.mjs +++ b/tools/stage-gateway-sidecar.test.mjs @@ -7,6 +7,9 @@ import test from "node:test"; import { gatewayBinaryName, gatewaySidecarName, + gatewaySidecarPath, + markGatewaySidecarPinned, + readGatewaySidecarPin, removeGatewaySidecar, stageGatewaySidecar, } from "./stage-gateway-sidecar.mjs"; @@ -58,6 +61,32 @@ test("rejects a malformed target triple", () => { assert.throws(() => gatewaySidecarName("../outside"), /invalid target triple/); }); +test("reports the one path tauri.conf.json's externalBin resolves", () => { + const root = join("repo"); + assert.equal( + gatewaySidecarPath({ root, target: "x86_64-pc-windows-msvc" }), + join( + root, + "crates", + "workshop", + "desktop", + "binaries", + "runvil-gateway-x86_64-pc-windows-msvc.exe", + ), + ); + assert.equal( + gatewaySidecarPath({ root, target: "aarch64-apple-darwin" }), + join( + root, + "crates", + "workshop", + "desktop", + "binaries", + "runvil-gateway-aarch64-apple-darwin", + ), + ); +}); + test("rejects a missing source binary", () => { const root = mkdtempSync(join(tmpdir(), "runvil-sidecar-")); try { @@ -128,6 +157,7 @@ test("stages and removes the real source file under Tauri's target name", () => staged, ); assert.throws(() => readFileSync(staged), /ENOENT/); + assert.throws(() => readFileSync(`${staged}.pinned`), /ENOENT/); assert.equal( removeGatewaySidecar({ root, @@ -139,3 +169,57 @@ test("stages and removes the real source file under Tauri's target name", () => rmSync(root, { force: true, recursive: true }); } }); + +test("marking a staged sidecar pinned records which kind it was", () => { + const root = mkdtempSync(join(tmpdir(), "runvil-sidecar-")); + const source = join(root, "target", "debug", "runvil-gateway"); + try { + mkdirSync(join(root, "target", "debug"), { recursive: true }); + writeFileSync(source, "downloaded gateway"); + const target = "x86_64-unknown-linux-gnu"; + const staged = stageGatewaySidecar({ root, target, source }); + const marker = markGatewaySidecarPinned({ root, target, kind: "release", tag: "gateway-v0.4.0" }); + assert.equal(marker, `${staged}.pinned`); + assert.deepEqual(readGatewaySidecarPin({ root, target }), { + kind: "release", + tag: "gateway-v0.4.0", + }); + // Removal takes the marker with the sidecar, so a later build is free + // to refresh from the workspace again. + removeGatewaySidecar({ root, target }); + assert.throws(() => readFileSync(marker), /ENOENT/); + assert.equal(readGatewaySidecarPin({ root, target }), null); + } finally { + rmSync(root, { force: true, recursive: true }); + } +}); + +test("a stub marker is distinguishable from a release marker", () => { + const root = mkdtempSync(join(tmpdir(), "runvil-sidecar-")); + const source = join(root, "target", "debug", "runvil-gateway"); + const target = "x86_64-apple-darwin"; + try { + mkdirSync(join(root, "target", "debug"), { recursive: true }); + writeFileSync(source, "placeholder"); + stageGatewaySidecar({ root, target, source }); + markGatewaySidecarPinned({ root, target, kind: "stub" }); + assert.equal(readGatewaySidecarPin({ root, target }).kind, "stub"); + } finally { + rmSync(root, { force: true, recursive: true }); + } +}); + +test("a hand-written or corrupt marker reads as unknown, not as a release", () => { + const root = mkdtempSync(join(tmpdir(), "runvil-sidecar-")); + const source = join(root, "target", "debug", "runvil-gateway"); + const target = "x86_64-apple-darwin"; + try { + mkdirSync(join(root, "target", "debug"), { recursive: true }); + writeFileSync(source, "placeholder"); + const staged = stageGatewaySidecar({ root, target, source }); + writeFileSync(`${staged}.pinned`, "this sidecar came from somewhere\n"); + assert.equal(readGatewaySidecarPin({ root, target }).kind, "unknown"); + } finally { + rmSync(root, { force: true, recursive: true }); + } +}); From 4cf1430adc4af54478c333dc0332725c1e2c6a76 Mon Sep 17 00:00:00 2001 From: Vinnie Falco Date: Fri, 30 Oct 2026 18:27:00 -0700 Subject: [PATCH 3/4] Warn instead of passing when the Gateway pin is unfilled `announce` now logs the `NoMinimumRecorded` case at `tracing::warn!` and pushes a status line in a release build. The new `Build` type separates a debug build, which reaches this state on every launch while the pin is a placeholder, from a release build, which is what an installer ships. - The split is decided at compile time by `Build::current` from `cfg!(debug_assertions)`, so the build script emits no second value and needs no change. - The status line goes through `push_status_update`, the same call the older-Gateway warning uses, so it reaches the first session through the retained status bus. - The `Satisfied` arm is unchanged and still pushes nothing, so a filled pin that passes the check stays quiet. --- crates/workshop/gateway/src/version-tests.rs | 45 ++++++++++++++++ crates/workshop/gateway/src/version.rs | 55 ++++++++++++++++++-- 2 files changed, 97 insertions(+), 3 deletions(-) diff --git a/crates/workshop/gateway/src/version-tests.rs b/crates/workshop/gateway/src/version-tests.rs index cc2eb0fcf..41a7c80da 100644 --- a/crates/workshop/gateway/src/version-tests.rs +++ b/crates/workshop/gateway/src/version-tests.rs @@ -145,6 +145,51 @@ fn a_satisfied_check_says_nothing_on_the_status_bar() { ); } +#[test] +fn an_unfilled_pin_warns_on_the_status_bar_in_a_release_build() { + let (push, mut rx, _sink) = status_recorder(); + announce_for(&VersionCheck::NoMinimumRecorded, &push, Build::Release); + let frame = rx.try_recv().expect("the unfilled pin is pushed"); + assert_eq!(frame.label, "Gateway version unchecked"); + assert!( + frame.description.contains("no minimum version"), + "the description names the cause: {}", + frame.description + ); + assert!( + frame.description.contains("nothing checks"), + "the description names the consequence: {}", + frame.description + ); +} + +#[test] +fn an_unfilled_pin_stays_off_the_status_bar_in_a_debug_build() { + let (push, mut rx, _sink) = status_recorder(); + announce_for(&VersionCheck::NoMinimumRecorded, &push, Build::Debug); + assert!( + rx.try_recv().is_err(), + "a developer's checkout must not repeat this line on every launch" + ); +} + +#[test] +fn the_public_entry_point_follows_the_build_it_was_compiled_into() { + let (push, mut rx, _sink) = status_recorder(); + announce(&VersionCheck::NoMinimumRecorded, &push); + if cfg!(debug_assertions) { + assert!( + rx.try_recv().is_err(), + "this test build is a debug build, so the pin reaches the log only" + ); + } else { + let frame = rx + .try_recv() + .expect("a release build shows the unfilled pin"); + assert_eq!(frame.label, "Gateway version unchecked"); + } +} + #[test] fn the_pin_minimum_is_the_compiled_default() { // Whatever the pin holds, the constant is its value, never a guess. A diff --git a/crates/workshop/gateway/src/version.rs b/crates/workshop/gateway/src/version.rs index e6bcabed7..b8e9e4b60 100644 --- a/crates/workshop/gateway/src/version.rs +++ b/crates/workshop/gateway/src/version.rs @@ -12,6 +12,13 @@ //! phase uses. The versions are compared as dotted numbers, never as //! strings: `0.10.0` is newer than `0.9.0`, and a string compare says the //! opposite. +//! +//! A pin that records no minimum turns the check off, which is a problem +//! with the build rather than with the Gateway. A release build says so on +//! the status bar: nothing then guards the Gateway it ships. A debug build +//! says so in the log only, because it reaches that state on every launch +//! while the pin is still a placeholder, and a status line for it would be +//! noise a developer learns to ignore. use std::cmp::Ordering; use std::fmt; @@ -155,12 +162,54 @@ pub fn report(running: &str, push: &Push) { /// Logs `check` and warns on the status bar when it is a problem. Split /// from [`report`] so a test can drive any outcome, including one the /// committed pin cannot currently produce. +/// +/// An unfilled pin reaches the status bar in a release build only; see +/// [`announce_for`]. pub fn announce(check: &VersionCheck, push: &Push) { + announce_for(check, push, Build::current()); +} + +/// Which kind of build is asking. An unfilled pin is a defect in a +/// shipped build and a normal state in a developer's checkout, so the +/// status bar is told about the first and not the second. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum Build { + /// A debug build: the developer's own `cargo build` or `cargo test`. + Debug, + /// A release build, which is what an installer ships. + Release, +} + +impl Build { + /// The build this code was compiled into. + fn current() -> Self { + if cfg!(debug_assertions) { + Self::Debug + } else { + Self::Release + } + } +} + +/// Logs `check` and warns on the status bar when it is a problem. `build` +/// selects whether an unfilled pin also reaches the status bar. +fn announce_for(check: &VersionCheck, push: &Push, build: Build) { match check { VersionCheck::NoMinimumRecorded => { - tracing::info!( - "the Gateway pin records no minimum version; skipping the version check" - ); + tracing::warn!("the Gateway pin records no minimum version; the version check is off"); + // A release build is what an installer ships. An unfilled pin + // there leaves the app with no version guard at all, so the + // user sees it. A debug build is a developer's own checkout, + // where the pin stays a placeholder until a Gateway release + // exists, so it stays in the log. + if build == Build::Release { + push.push_status_update( + "Gateway version unchecked", + "this Workshop was built from a pin with no minimum version, so nothing \ + checks the Gateway it attaches to", + Activity::General, + ); + } } VersionCheck::Satisfied { running, minimum } => { tracing::info!( From 2643dbec8d41e2ee26307c8e193469d1d18b98a4 Mon Sep 17 00:00:00 2001 From: Vinnie Falco Date: Fri, 30 Oct 2026 18:37:42 -0700 Subject: [PATCH 4/4] Stop linking a public doc to a private function The rustdoc on announce named announce_for, which is private, so cargo doc with -D warnings rejected workshop-gateway and the docs job failed. The sentence now names announce_for as plain code instead of a link. - The same text broke twice, as announce and as its re-export announce_version. - cargo doc --workspace --no-deps --all-features, the runvil and harness facade builds, and the runvil-engine private-items build all exit 0 with RUSTDOCFLAGS=-D warnings. --- crates/workshop/gateway/src/version.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/workshop/gateway/src/version.rs b/crates/workshop/gateway/src/version.rs index b8e9e4b60..b323567d2 100644 --- a/crates/workshop/gateway/src/version.rs +++ b/crates/workshop/gateway/src/version.rs @@ -163,8 +163,8 @@ pub fn report(running: &str, push: &Push) { /// from [`report`] so a test can drive any outcome, including one the /// committed pin cannot currently produce. /// -/// An unfilled pin reaches the status bar in a release build only; see -/// [`announce_for`]. +/// An unfilled pin reaches the status bar in a release build only; the +/// build split lives in `announce_for`. pub fn announce(check: &VersionCheck, push: &Push) { announce_for(check, push, Build::current()); }