mirror of
https://github.com/codewhale-hq/Codewhale.git
synced 2026-11-09 09:59:45 -10:00
fix(runtime): address stream-end client review findings
Address PR #6649 review comments 4112230294, 4112230296, and 4112230301. Mobile stream statuses bypassed the locale catalog. Render them through shared MessageIds in all 15 complete packs, reading settings off the async runtime and quoting the injected JSON safely. Exercise the rendered JS against each locale. Other mobile shell copy remains English. The SDK's open-ended journal event names prevented stream.end narrowing. Export and document isThreadStreamEnd; cover runtime classification and all three TypeScript iterator overloads without closing the journal name set. Expose x-codewhale-stream-end through the existing CORS layer. The focused regression exercises the middleware in-process for default, configured, and rejected origins. Update and synchronize the existing Unreleased bullet. Validation (all local/offline): CARGO_BUILD_JOBS=4 CARGO_NET_OFFLINE=true cargo test -p codewhale-tui --lib -- mobile_stream_statuses_use_selected_locale cors_layer replay_failure_over_http_sends_stream_end_then_eof test result: FAILED. 4 passed; 2 failed; 0 ignored; 0 measured; 13472 filtered out; finished in 0.05s The mobile locale regression passed. The two failures were socket binds denied by the sandbox. Three older CORS tests returned early on that same restriction; their reported passes do not establish HTTP coverage. Reworked the new CORS test to use the actual middleware without sockets. The existing HTTP replay acceptance test remains unverified here. CARGO_BUILD_JOBS=4 CARGO_NET_OFFLINE=true cargo test -p codewhale-tui --lib -- cors_layer_exposes_stream_end_for_allowed_origins test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 13477 filtered out; finished in 0.01s CARGO_BUILD_JOBS=4 CARGO_NET_OFFLINE=true cargo test -p codewhale-localization --lib -- message_id_list_english_pack_stay_in_exact_sync shipped_complete_packs_have_raw_key_parity_with_english test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 48 filtered out; finished in 0.06s npm --offline --prefix npm/runtime-sdk test # tests 16 # pass 16 # fail 0 # skipped 0 tsc --noEmit --strict --target ES2022 --module NodeNext npm/runtime-sdk/test/thread-events.types.ts Exit 0. The same fixture with the predicate changed to boolean fails with TS2322/TS2339 (exit 2), confirming the narrowing regression. node --input-type=module (inline VM check against the original mobile HTML) Confirmed the pre-fix Japanese replay status returns English. python3 scripts/check-tui-locale-parity.py [tui-locale-parity] PASS (2393 keys in all 15 complete packs) cargo fmt --all; sh scripts/sync-changelog.sh; git diff --check completed. The TUI test linker warned about the large compact-unwind table. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
1 parent
8d3c1b7d81
commit
60a2c9887c
26 files changed
+342
-18
No files matched your search
+4
-1
@@ -65,7 +65,10 @@ quieter, and Fleet runs can be checked before they spend anything.
|
||||
on shutdown, so clients can tell a Runtime error from a dropped connection
|
||||
and resume from the right event. A replay worker crash no longer ends
|
||||
history early and skips events without a detectable gap, and the mobile
|
||||
page resumes from its last event instead of replaying from zero.
|
||||
page resumes from its last event instead of replaying from zero, with stream
|
||||
statuses in the configured UI language. Cross-origin clients can read the
|
||||
stream-end capability header, and the SDK exports `isThreadStreamEnd` for
|
||||
TypeScript narrowing.
|
||||
- `codewhale exec --auto` no longer exits 141 with no output when a child
|
||||
it writes to, such as a stdio MCP server, closes its pipe early. Headless
|
||||
exec now ignores SIGPIPE while it runs, as the interactive TUI already did,
|
||||
|
||||
@@ -1,4 +1,11 @@
|
||||
{
|
||||
"MobileStreamReplayFailed": "El Runtime no ha pogut llegir l'historial d'aquesta conversa — tornant-ho a provar",
|
||||
"MobileStreamCatchUpFailed": "El Runtime s'ha endarrerit i no s'ha pogut posar al dia — tornant-ho a provar",
|
||||
"MobileStreamRuntimeShutdown": "El Runtime s'està tancant — reconnectant",
|
||||
"MobileStreamEnded": "El Runtime ha finalitzat el flux d'esdeveniments — reconnectant",
|
||||
"MobileStreamClosed": "El Runtime ha tancat el flux d'esdeveniments d'aquesta conversa; torna a obrir-la",
|
||||
"MobileStreamReconnecting": "Connexió perduda; reconnectant",
|
||||
"MobileStreamConnected": "Connectat",
|
||||
"PetHabitatTitle": "CODEWHALE / WHALESONG · Un hàbitat compartit",
|
||||
"PetHabitatHints": "{back} enrere · {sound} so · {browser} aparença · {window} finestra",
|
||||
"PetHabitatQueued": "Enviat a l’hàbitat compartit. /pet on obre la vista completa.",
|
||||
|
||||
@@ -1,4 +1,11 @@
|
||||
{
|
||||
"MobileStreamReplayFailed": "Runtime konnte den Verlauf dieser Unterhaltung nicht lesen — erneuter Versuch",
|
||||
"MobileStreamCatchUpFailed": "Runtime ist zurückgefallen und konnte nicht aufholen — erneuter Versuch",
|
||||
"MobileStreamRuntimeShutdown": "Runtime wird heruntergefahren — Verbindung wird wiederhergestellt",
|
||||
"MobileStreamEnded": "Runtime hat den Ereignisstrom beendet — Verbindung wird wiederhergestellt",
|
||||
"MobileStreamClosed": "Runtime hat den Ereignisstrom dieser Unterhaltung geschlossen; öffne die Unterhaltung erneut",
|
||||
"MobileStreamReconnecting": "Verbindung verloren; Verbindung wird wiederhergestellt",
|
||||
"MobileStreamConnected": "Verbunden",
|
||||
"PetHabitatTitle": "CODEWHALE / WHALESONG · Ein gemeinsamer Lebensraum",
|
||||
"PetHabitatHints": "{back} zurück · {sound} Ton · {browser} Aussehen · {window} Fenster",
|
||||
"PetHabitatQueued": "An den gemeinsamen Lebensraum gesendet. /pet on öffnet die Vollansicht.",
|
||||
|
||||
@@ -1,4 +1,11 @@
|
||||
{
|
||||
"MobileStreamReplayFailed": "Runtime could not read this conversation's history — retrying",
|
||||
"MobileStreamCatchUpFailed": "Runtime fell behind and could not catch up — retrying",
|
||||
"MobileStreamRuntimeShutdown": "Runtime is shutting down — reconnecting",
|
||||
"MobileStreamEnded": "Runtime ended the event stream — reconnecting",
|
||||
"MobileStreamClosed": "Runtime closed this conversation's event stream; reopen the thread",
|
||||
"MobileStreamReconnecting": "Connection lost; reconnecting",
|
||||
"MobileStreamConnected": "Connected",
|
||||
"PetHabitatTitle": "CODEWHALE / WHALESONG · One shared habitat",
|
||||
"PetHabitatHints": "{back} back · {sound} sound · {browser} appearance · {window} window",
|
||||
"PetHabitatQueued": "Sent to the shared habitat. /pet on opens the full view.",
|
||||
|
||||
@@ -1,4 +1,11 @@
|
||||
{
|
||||
"MobileStreamReplayFailed": "El Runtime no pudo leer el historial de esta conversación — reintentando",
|
||||
"MobileStreamCatchUpFailed": "El Runtime se atrasó y no pudo ponerse al día — reintentando",
|
||||
"MobileStreamRuntimeShutdown": "El Runtime se está cerrando — reconectando",
|
||||
"MobileStreamEnded": "El Runtime terminó el flujo de eventos — reconectando",
|
||||
"MobileStreamClosed": "El Runtime cerró el flujo de eventos de esta conversación; vuelve a abrirla",
|
||||
"MobileStreamReconnecting": "Se perdió la conexión; reconectando",
|
||||
"MobileStreamConnected": "Conectado",
|
||||
"PetHabitatTitle": "CODEWHALE / WHALESONG · Un hábitat compartido",
|
||||
"PetHabitatHints": "{back} volver · {sound} sonido · {browser} apariencia · {window} ventana",
|
||||
"PetHabitatQueued": "Enviado al hábitat compartido. /pet on abre la vista completa.",
|
||||
|
||||
@@ -1,4 +1,11 @@
|
||||
{
|
||||
"MobileStreamReplayFailed": "Le Runtime n’a pas pu lire l’historique de cette conversation — nouvelle tentative",
|
||||
"MobileStreamCatchUpFailed": "Le Runtime a pris du retard et n’a pas pu le rattraper — nouvelle tentative",
|
||||
"MobileStreamRuntimeShutdown": "Le Runtime est en cours d’arrêt — reconnexion",
|
||||
"MobileStreamEnded": "Le Runtime a terminé le flux d’événements — reconnexion",
|
||||
"MobileStreamClosed": "Le Runtime a fermé le flux d’événements de cette conversation ; rouvrez-la",
|
||||
"MobileStreamReconnecting": "Connexion perdue ; reconnexion",
|
||||
"MobileStreamConnected": "Connecté",
|
||||
"PetHabitatTitle": "CODEWHALE / WHALESONG · Un habitat partagé",
|
||||
"PetHabitatHints": "{back} retour · {sound} son · {browser} apparence · {window} fenêtre",
|
||||
"PetHabitatQueued": "Envoyé à l’habitat partagé. /pet on ouvre la vue complète.",
|
||||
|
||||
@@ -1,4 +1,11 @@
|
||||
{
|
||||
"MobileStreamReplayFailed": "Runtime इस बातचीत का इतिहास नहीं पढ़ सका — फिर से कोशिश हो रही है",
|
||||
"MobileStreamCatchUpFailed": "Runtime पीछे रह गया और छूटी हुई घटनाएँ प्राप्त नहीं कर सका — फिर से कोशिश हो रही है",
|
||||
"MobileStreamRuntimeShutdown": "Runtime बंद हो रहा है — फिर से कनेक्ट हो रहा है",
|
||||
"MobileStreamEnded": "Runtime ने इवेंट स्ट्रीम समाप्त कर दी — फिर से कनेक्ट हो रहा है",
|
||||
"MobileStreamClosed": "Runtime ने इस बातचीत की इवेंट स्ट्रीम बंद कर दी; बातचीत फिर से खोलें",
|
||||
"MobileStreamReconnecting": "कनेक्शन टूट गया; फिर से कनेक्ट हो रहा है",
|
||||
"MobileStreamConnected": "कनेक्ट हो गया",
|
||||
"PetHabitatTitle": "CODEWHALE / WHALESONG · एक साझा आवास",
|
||||
"PetHabitatHints": "{back} वापस · {sound} ध्वनि · {browser} रूप · {window} विंडो",
|
||||
"PetHabitatQueued": "साझा आवास को भेजा गया। /pet on से पूरा दृश्य खुलता है।",
|
||||
|
||||
@@ -1,4 +1,11 @@
|
||||
{
|
||||
"MobileStreamReplayFailed": "Runtime tidak dapat membaca riwayat percakapan ini — mencoba lagi",
|
||||
"MobileStreamCatchUpFailed": "Runtime tertinggal dan tidak dapat mengejar — mencoba lagi",
|
||||
"MobileStreamRuntimeShutdown": "Runtime sedang dimatikan — menghubungkan kembali",
|
||||
"MobileStreamEnded": "Runtime mengakhiri aliran peristiwa — menghubungkan kembali",
|
||||
"MobileStreamClosed": "Runtime menutup aliran peristiwa percakapan ini; buka kembali percakapan",
|
||||
"MobileStreamReconnecting": "Koneksi terputus; menghubungkan kembali",
|
||||
"MobileStreamConnected": "Terhubung",
|
||||
"PetHabitatTitle": "CODEWHALE / WHALESONG · Satu habitat bersama",
|
||||
"PetHabitatHints": "{back} kembali · {sound} suara · {browser} tampilan · {window} jendela",
|
||||
"PetHabitatQueued": "Dikirim ke habitat bersama. /pet on membuka tampilan penuh.",
|
||||
|
||||
@@ -1,4 +1,11 @@
|
||||
{
|
||||
"MobileStreamReplayFailed": "Runtime がこの会話の履歴を読み込めませんでした — 再試行中",
|
||||
"MobileStreamCatchUpFailed": "Runtime のイベント処理が遅れ、追いつけませんでした — 再試行中",
|
||||
"MobileStreamRuntimeShutdown": "Runtime は終了処理中です — 再接続中",
|
||||
"MobileStreamEnded": "Runtime がイベントストリームを終了しました — 再接続中",
|
||||
"MobileStreamClosed": "Runtime がこの会話のイベントストリームを閉じました。スレッドを開き直してください",
|
||||
"MobileStreamReconnecting": "接続が切れました。再接続中",
|
||||
"MobileStreamConnected": "接続済み",
|
||||
"PetHabitatTitle": "CODEWHALE / WHALESONG · 共有ハビタット",
|
||||
"PetHabitatHints": "{back} 戻る · {sound} 音 · {browser} 外観 · {window} ウィンドウ",
|
||||
"PetHabitatQueued": "共有ハビタットに送信しました。/pet on で全画面表示を開きます。",
|
||||
|
||||
@@ -1,4 +1,11 @@
|
||||
{
|
||||
"MobileStreamReplayFailed": "Runtime이 이 대화의 기록을 읽지 못했습니다 — 다시 시도 중",
|
||||
"MobileStreamCatchUpFailed": "Runtime의 이벤트 처리가 지연되어 따라잡지 못했습니다 — 다시 시도 중",
|
||||
"MobileStreamRuntimeShutdown": "Runtime을 종료하고 있습니다 — 다시 연결 중",
|
||||
"MobileStreamEnded": "Runtime이 이벤트 스트림을 종료했습니다 — 다시 연결 중",
|
||||
"MobileStreamClosed": "Runtime이 이 대화의 이벤트 스트림을 닫았습니다. 스레드를 다시 여세요",
|
||||
"MobileStreamReconnecting": "연결이 끊어졌습니다. 다시 연결 중",
|
||||
"MobileStreamConnected": "연결됨",
|
||||
"PetHabitatTitle": "CODEWHALE / WHALESONG · 하나의 공유 서식지",
|
||||
"PetHabitatHints": "{back} 뒤로 · {sound} 소리 · {browser} 외관 · {window} 창",
|
||||
"PetHabitatQueued": "공유 서식지로 보냈습니다. /pet on 명령으로 전체 화면을 엽니다.",
|
||||
|
||||
@@ -1,4 +1,11 @@
|
||||
{
|
||||
"MobileStreamReplayFailed": "O Runtime não conseguiu ler o histórico desta conversa — tentando novamente",
|
||||
"MobileStreamCatchUpFailed": "O Runtime ficou para trás e não conseguiu recuperar os eventos — tentando novamente",
|
||||
"MobileStreamRuntimeShutdown": "O Runtime está sendo encerrado — reconectando",
|
||||
"MobileStreamEnded": "O Runtime encerrou o fluxo de eventos — reconectando",
|
||||
"MobileStreamClosed": "O Runtime fechou o fluxo de eventos desta conversa; reabra a conversa",
|
||||
"MobileStreamReconnecting": "Conexão perdida; reconectando",
|
||||
"MobileStreamConnected": "Conectado",
|
||||
"PetHabitatTitle": "CODEWHALE / WHALESONG · Um habitat compartilhado",
|
||||
"PetHabitatHints": "{back} voltar · {sound} som · {browser} aparência · {window} janela",
|
||||
"PetHabitatQueued": "Enviado ao habitat compartilhado. /pet on abre a visualização completa.",
|
||||
|
||||
@@ -1,4 +1,11 @@
|
||||
{
|
||||
"MobileStreamReplayFailed": "Runtime не удалось прочитать историю этой беседы — повторная попытка",
|
||||
"MobileStreamCatchUpFailed": "Runtime отстал и не смог получить пропущенные события — повторная попытка",
|
||||
"MobileStreamRuntimeShutdown": "Runtime завершает работу — переподключение",
|
||||
"MobileStreamEnded": "Runtime завершил поток событий — переподключение",
|
||||
"MobileStreamClosed": "Runtime закрыл поток событий этой беседы; откройте её заново",
|
||||
"MobileStreamReconnecting": "Соединение потеряно; переподключение",
|
||||
"MobileStreamConnected": "Подключено",
|
||||
"PetHabitatTitle": "CODEWHALE / WHALESONG · Общая среда обитания",
|
||||
"PetHabitatHints": "{back} назад · {sound} звук · {browser} внешний вид · {window} окно",
|
||||
"PetHabitatQueued": "Отправлено в общую среду обитания. /pet on открывает полный вид.",
|
||||
|
||||
@@ -1,4 +1,11 @@
|
||||
{
|
||||
"MobileStreamReplayFailed": "Runtime не вдалося прочитати історію цієї розмови — повторна спроба",
|
||||
"MobileStreamCatchUpFailed": "Runtime відстав і не зміг отримати пропущені події — повторна спроба",
|
||||
"MobileStreamRuntimeShutdown": "Runtime завершує роботу — повторне підключення",
|
||||
"MobileStreamEnded": "Runtime завершив потік подій — повторне підключення",
|
||||
"MobileStreamClosed": "Runtime закрив потік подій цієї розмови; відкрийте її знову",
|
||||
"MobileStreamReconnecting": "З’єднання втрачено; повторне підключення",
|
||||
"MobileStreamConnected": "Підключено",
|
||||
"PetHabitatTitle": "CODEWHALE / WHALESONG · Спільне середовище",
|
||||
"PetHabitatHints": "{back} назад · {sound} звук · {browser} вигляд · {window} вікно",
|
||||
"PetHabitatQueued": "Надіслано до спільного середовища. /pet on відкриває повний вигляд.",
|
||||
|
||||
@@ -1,4 +1,11 @@
|
||||
{
|
||||
"MobileStreamReplayFailed": "Runtime không thể đọc lịch sử cuộc trò chuyện này — đang thử lại",
|
||||
"MobileStreamCatchUpFailed": "Runtime bị chậm và không thể bắt kịp — đang thử lại",
|
||||
"MobileStreamRuntimeShutdown": "Runtime đang tắt — đang kết nối lại",
|
||||
"MobileStreamEnded": "Runtime đã kết thúc luồng sự kiện — đang kết nối lại",
|
||||
"MobileStreamClosed": "Runtime đã đóng luồng sự kiện của cuộc trò chuyện này; hãy mở lại cuộc trò chuyện",
|
||||
"MobileStreamReconnecting": "Mất kết nối; đang kết nối lại",
|
||||
"MobileStreamConnected": "Đã kết nối",
|
||||
"PetHabitatTitle": "CODEWHALE / WHALESONG · Một môi trường sống chung",
|
||||
"PetHabitatHints": "{back} quay lại · {sound} âm thanh · {browser} giao diện · {window} cửa sổ",
|
||||
"PetHabitatQueued": "Đã gửi đến môi trường sống chung. /pet on mở chế độ xem toàn màn hình.",
|
||||
|
||||
@@ -1,4 +1,11 @@
|
||||
{
|
||||
"MobileStreamReplayFailed": "Runtime 无法读取此对话的历史记录 — 正在重试",
|
||||
"MobileStreamCatchUpFailed": "Runtime 处理落后且无法追赶 — 正在重试",
|
||||
"MobileStreamRuntimeShutdown": "Runtime 正在关闭 — 正在重新连接",
|
||||
"MobileStreamEnded": "Runtime 已结束事件流 — 正在重新连接",
|
||||
"MobileStreamClosed": "Runtime 已关闭此对话的事件流;请重新打开会话",
|
||||
"MobileStreamReconnecting": "连接已断开;正在重新连接",
|
||||
"MobileStreamConnected": "已连接",
|
||||
"PetHabitatTitle": "CODEWHALE / WHALESONG · 共享栖息地",
|
||||
"PetHabitatHints": "{back} 返回 · {sound} 声音 · {browser} 外观 · {window} 窗口",
|
||||
"PetHabitatQueued": "已发送到共享栖息地。使用 /pet on 打开全屏视图。",
|
||||
|
||||
@@ -1,4 +1,11 @@
|
||||
{
|
||||
"MobileStreamReplayFailed": "Runtime 無法讀取此對話的歷史記錄 — 正在重試",
|
||||
"MobileStreamCatchUpFailed": "Runtime 處理落後且無法追趕 — 正在重試",
|
||||
"MobileStreamRuntimeShutdown": "Runtime 正在關閉 — 正在重新連線",
|
||||
"MobileStreamEnded": "Runtime 已結束事件串流 — 正在重新連線",
|
||||
"MobileStreamClosed": "Runtime 已關閉此對話的事件串流;請重新開啟對話",
|
||||
"MobileStreamReconnecting": "連線已中斷;正在重新連線",
|
||||
"MobileStreamConnected": "已連線",
|
||||
"PetHabitatTitle": "CODEWHALE / WHALESONG · 共享棲息地",
|
||||
"PetHabitatHints": "{back} 返回 · {sound} 聲音 · {browser} 外觀 · {window} 視窗",
|
||||
"PetHabitatQueued": "已傳送至共享棲息地。使用 /pet on 開啟全螢幕檢視。",
|
||||
|
||||
@@ -120,6 +120,13 @@ impl Locale {
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum MessageId {
|
||||
MobileStreamReplayFailed,
|
||||
MobileStreamCatchUpFailed,
|
||||
MobileStreamRuntimeShutdown,
|
||||
MobileStreamEnded,
|
||||
MobileStreamClosed,
|
||||
MobileStreamReconnecting,
|
||||
MobileStreamConnected,
|
||||
SessionArchiveExported,
|
||||
SessionArchiveSizes,
|
||||
SessionArchiveNoArtifacts,
|
||||
@@ -2623,6 +2630,13 @@ pub enum MessageId {
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub const ALL_MESSAGE_IDS: &[MessageId] = &[
|
||||
MessageId::MobileStreamReplayFailed,
|
||||
MessageId::MobileStreamCatchUpFailed,
|
||||
MessageId::MobileStreamRuntimeShutdown,
|
||||
MessageId::MobileStreamEnded,
|
||||
MessageId::MobileStreamClosed,
|
||||
MessageId::MobileStreamReconnecting,
|
||||
MessageId::MobileStreamConnected,
|
||||
MessageId::SessionArchiveExported,
|
||||
MessageId::SessionArchiveSizes,
|
||||
MessageId::SessionArchiveNoArtifacts,
|
||||
|
||||
@@ -65,7 +65,10 @@ quieter, and Fleet runs can be checked before they spend anything.
|
||||
on shutdown, so clients can tell a Runtime error from a dropped connection
|
||||
and resume from the right event. A replay worker crash no longer ends
|
||||
history early and skips events without a detectable gap, and the mobile
|
||||
page resumes from its last event instead of replaying from zero.
|
||||
page resumes from its last event instead of replaying from zero, with stream
|
||||
statuses in the configured UI language. Cross-origin clients can read the
|
||||
stream-end capability header, and the SDK exports `isThreadStreamEnd` for
|
||||
TypeScript narrowing.
|
||||
- `codewhale exec --auto` no longer exits 141 with no output when a child
|
||||
it writes to, such as a stdio MCP server, closes its pipe early. Headless
|
||||
exec now ignores SIGPIPE while it runs, as the interactive TUI already did,
|
||||
|
||||
@@ -1696,18 +1696,22 @@ pub fn build_router(state: RuntimeApiState) -> Router {
|
||||
.with_state(state)
|
||||
}
|
||||
|
||||
async fn mobile_page(State(state): State<RuntimeApiState>, req: Request) -> Response {
|
||||
async fn mobile_page(State(state): State<RuntimeApiState>) -> Result<Response, ApiError> {
|
||||
if !state.mobile_enabled {
|
||||
return (
|
||||
return Ok((
|
||||
StatusCode::NOT_FOUND,
|
||||
"mobile control is disabled; start with `codewhale serve --mobile`",
|
||||
)
|
||||
.into_response();
|
||||
.into_response());
|
||||
}
|
||||
let _ = req;
|
||||
let mut response = Html(MOBILE_HTML).into_response();
|
||||
let settings = tokio::task::spawn_blocking(crate::settings::Settings::load_read_only)
|
||||
.await
|
||||
.map_err(|err| ApiError::internal(format!("mobile settings task failed: {err}")))?
|
||||
.map_err(|err| ApiError::internal(format!("mobile settings unavailable: {err}")))?;
|
||||
let locale = codewhale_localization::resolve_locale(&settings.locale);
|
||||
let mut response = Html(mobile_html(locale)).into_response();
|
||||
secure_mobile_response(&mut response);
|
||||
response
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
@@ -10234,6 +10238,28 @@ async fn clear_memory(
|
||||
|
||||
const MOBILE_HTML: &str = include_str!("runtime_mobile.html");
|
||||
|
||||
// Only stream statuses are localized here; the rest of the mobile shell is
|
||||
// still English. Reload the page after changing the Runtime's UI locale.
|
||||
fn mobile_html(locale: codewhale_localization::Locale) -> String {
|
||||
use codewhale_localization::{MessageId, tr};
|
||||
|
||||
let messages = json!({
|
||||
"replay_failed": tr(locale, MessageId::MobileStreamReplayFailed),
|
||||
"catch_up_failed": tr(locale, MessageId::MobileStreamCatchUpFailed),
|
||||
"runtime_shutdown": tr(locale, MessageId::MobileStreamRuntimeShutdown),
|
||||
"ended": tr(locale, MessageId::MobileStreamEnded),
|
||||
"closed": tr(locale, MessageId::MobileStreamClosed),
|
||||
"reconnecting": tr(locale, MessageId::MobileStreamReconnecting),
|
||||
"connected": tr(locale, MessageId::MobileStreamConnected),
|
||||
});
|
||||
// JSON quoting protects JS strings; escaping '<' also prevents a catalog
|
||||
// value from closing the enclosing script element.
|
||||
MOBILE_HTML.replace(
|
||||
"__CODEWHALE_STREAM_MESSAGES__",
|
||||
&messages.to_string().replace('<', "\\u003c"),
|
||||
)
|
||||
}
|
||||
|
||||
/// Built-in dev origins always allowed by the runtime API (whalescale#255).
|
||||
const DEFAULT_CORS_ORIGINS: &[&str] = &[
|
||||
"http://localhost:3000",
|
||||
@@ -10278,6 +10304,7 @@ fn cors_layer(extra_origins: &[String]) -> CorsLayer {
|
||||
HeaderName::from_static("x-codewhale-runtime-token"),
|
||||
HeaderName::from_static("x-deepseek-runtime-token"),
|
||||
])
|
||||
.expose_headers([HeaderName::from_static("x-codewhale-stream-end")])
|
||||
}
|
||||
|
||||
fn map_task_err(err: anyhow::Error) -> ApiError {
|
||||
|
||||
@@ -7878,6 +7878,41 @@ fn cors_layer_skips_invalid_origins() {
|
||||
let _ = cors_layer(&extras);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cors_layer_exposes_stream_end_for_allowed_origins() -> Result<()> {
|
||||
use axum::handler::Handler;
|
||||
|
||||
let handler = (|| async { ([("x-codewhale-stream-end", "1")], "event: stream.end\n\n") })
|
||||
.layer(cors_layer(&["http://localhost:5173".to_string()]));
|
||||
for origin in ["http://localhost:1420", "http://localhost:5173"] {
|
||||
let request = Request::builder()
|
||||
.uri("/events")
|
||||
.header("Origin", origin)
|
||||
.body(axum::body::Body::empty())?;
|
||||
let response = handler.clone().call(request, ()).await;
|
||||
assert_eq!(response.headers()["access-control-allow-origin"], origin);
|
||||
assert_eq!(response.headers()["x-codewhale-stream-end"], "1");
|
||||
assert!(
|
||||
response.headers()["access-control-expose-headers"]
|
||||
.to_str()?
|
||||
.split(',')
|
||||
.any(|name| name.trim().eq_ignore_ascii_case("x-codewhale-stream-end"))
|
||||
);
|
||||
}
|
||||
let request = Request::builder()
|
||||
.uri("/events")
|
||||
.header("Origin", "http://malicious.example")
|
||||
.body(axum::body::Body::empty())?;
|
||||
let rejected = handler.call(request, ()).await;
|
||||
assert!(
|
||||
rejected
|
||||
.headers()
|
||||
.get("access-control-allow-origin")
|
||||
.is_none()
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// #562 / whalescale#256 — `PATCH /v1/threads/{id}` accepts the new
|
||||
/// fields (allow_shell, trust_mode, auto_approve, model, mode, title,
|
||||
/// system_prompt). Legacy mode aliases remain accepted as one-way inputs and
|
||||
@@ -8941,6 +8976,64 @@ async fn mobile_runtime_router_starts_without_duplicate_method_routes() -> Resul
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mobile_stream_statuses_use_selected_locale() -> Result<()> {
|
||||
use codewhale_localization::{Locale, MessageId, tr};
|
||||
|
||||
for &locale in Locale::shipped_complete() {
|
||||
let html = mobile_html(locale);
|
||||
let script = html
|
||||
.split_once("<script>")
|
||||
.context("mobile script")?
|
||||
.1
|
||||
.split_once("boot().catch")
|
||||
.context("mobile startup")?
|
||||
.0;
|
||||
let runtime = rquickjs::Runtime::new()?;
|
||||
let context = rquickjs::Context::full(&runtime)?;
|
||||
context.with(|ctx| -> Result<()> {
|
||||
ctx.eval::<(), _>(script)?;
|
||||
for (expression, message) in [
|
||||
(
|
||||
"streamEndStatus('replay_failed')",
|
||||
MessageId::MobileStreamReplayFailed,
|
||||
),
|
||||
(
|
||||
"streamEndStatus('catch_up_failed')",
|
||||
MessageId::MobileStreamCatchUpFailed,
|
||||
),
|
||||
(
|
||||
"streamEndStatus('runtime_shutdown')",
|
||||
MessageId::MobileStreamRuntimeShutdown,
|
||||
),
|
||||
(
|
||||
"streamEndStatus('future_reason')",
|
||||
MessageId::MobileStreamEnded,
|
||||
),
|
||||
("streamMessages.closed", MessageId::MobileStreamClosed),
|
||||
(
|
||||
"streamMessages.reconnecting",
|
||||
MessageId::MobileStreamReconnecting,
|
||||
),
|
||||
("streamMessages.connected", MessageId::MobileStreamConnected),
|
||||
] {
|
||||
let actual: String = ctx.eval(expression)?;
|
||||
assert_eq!(actual, tr(locale, message), "{}: {message:?}", locale.tag());
|
||||
if locale != Locale::En {
|
||||
assert_ne!(
|
||||
actual,
|
||||
tr(Locale::En, message),
|
||||
"{}: {message:?}",
|
||||
locale.tag()
|
||||
);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn mobile_page_is_available_only_when_enabled() -> Result<()> {
|
||||
let tmp = tempfile::tempdir()?;
|
||||
|
||||
@@ -260,6 +260,7 @@
|
||||
</main>
|
||||
|
||||
<script>
|
||||
const streamMessages = __CODEWHALE_STREAM_MESSAGES__;
|
||||
const $ = (id) => document.getElementById(id);
|
||||
const MAX_VISIBLE_EVENTS = 100;
|
||||
const LEGACY_RUNTIME_TOKEN_STORAGE_KEY = "codewhale_runtime_token";
|
||||
@@ -584,13 +585,13 @@
|
||||
function streamEndStatus(reason) {
|
||||
switch (reason) {
|
||||
case "replay_failed":
|
||||
return "Runtime could not read this conversation's history \u2014 retrying";
|
||||
return streamMessages.replay_failed;
|
||||
case "catch_up_failed":
|
||||
return "Runtime fell behind and could not catch up \u2014 retrying";
|
||||
return streamMessages.catch_up_failed;
|
||||
case "runtime_shutdown":
|
||||
return "Runtime is shutting down \u2014 reconnecting";
|
||||
return streamMessages.runtime_shutdown;
|
||||
default:
|
||||
return "Runtime ended the event stream \u2014 reconnecting";
|
||||
return streamMessages.ended;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -610,7 +611,7 @@
|
||||
openThreadEvents(id, cursor).catch((err) => {
|
||||
if (state.threadId !== id) return;
|
||||
if (err instanceof TypeError) {
|
||||
setStatus("Connection lost; reconnecting", "warn");
|
||||
setStatus(streamMessages.reconnecting, "warn");
|
||||
scheduleResume(id, cursor);
|
||||
} else {
|
||||
setStatus(err.message, "bad");
|
||||
@@ -672,7 +673,7 @@
|
||||
state.lastSeq = Math.max(state.lastSeq, data.last_seq);
|
||||
}
|
||||
if (data.retryable !== true) {
|
||||
setStatus("Runtime closed this conversation's event stream; reopen the thread", "bad");
|
||||
setStatus(streamMessages.closed, "bad");
|
||||
return;
|
||||
}
|
||||
setStatus(streamEndStatus(data.reason), "warn");
|
||||
@@ -684,7 +685,7 @@
|
||||
state.openedAt = Date.now();
|
||||
window.setTimeout(() => {
|
||||
if (state.source === source && source.readyState === EventSource.OPEN) {
|
||||
setStatus("Connected", "ok");
|
||||
setStatus(streamMessages.connected, "ok");
|
||||
}
|
||||
}, 400);
|
||||
};
|
||||
@@ -693,7 +694,7 @@
|
||||
source.onerror = () => {
|
||||
if (state.source !== source || state.threadId !== id) return;
|
||||
source.close();
|
||||
setStatus("Connection lost; reconnecting", "warn");
|
||||
setStatus(streamMessages.reconnecting, "warn");
|
||||
scheduleResume(id, state.lastSeq > 0 ? state.lastSeq : resumeFrom);
|
||||
};
|
||||
}
|
||||
@@ -751,7 +752,7 @@
|
||||
$("steer").onclick = () => steerTurn().catch((err) => setStatus(err.message, "bad"));
|
||||
$("interrupt").onclick = () => interruptTurn().catch((err) => setStatus(err.message, "bad"));
|
||||
await loadThreads();
|
||||
setStatus("Connected", "ok");
|
||||
setStatus(streamMessages.connected, "ok");
|
||||
}
|
||||
|
||||
boot().catch((err) => setStatus(err.message, "bad"));
|
||||
|
||||
@@ -108,3 +108,19 @@ Runtimes that send it advertise `x-codewhale-stream-end: 1`. If the iterator
|
||||
ends without it, the connection was lost: resume from the last `seq` you
|
||||
accepted. The full client rule is in
|
||||
[`docs/RUNTIME_API.md`](../../docs/RUNTIME_API.md#ending-and-resuming-a-thread-stream).
|
||||
|
||||
Use `isThreadStreamEnd` to narrow iterator items in TypeScript. Journal event
|
||||
names remain open-ended, so checking `event === "stream.end"` alone cannot
|
||||
narrow the union:
|
||||
|
||||
```ts
|
||||
import { isThreadStreamEnd } from "@codewhale/runtime-sdk";
|
||||
|
||||
for await (const item of client.threadEvents(threadId)) {
|
||||
if (isThreadStreamEnd(item)) {
|
||||
console.log(item.reason, item.last_seq, item.retryable);
|
||||
} else {
|
||||
console.log(item.seq, item.payload);
|
||||
}
|
||||
}
|
||||
```
|
||||
Vendored
+5
@@ -337,6 +337,11 @@ export interface ThreadStreamEnd {
|
||||
retryable: boolean;
|
||||
}
|
||||
|
||||
/** Narrow an item yielded by threadEvents; journal event names are open-ended. */
|
||||
export function isThreadStreamEnd(
|
||||
event: ThreadRuntimeEvent | ThreadStreamProgress | ThreadStreamEnd,
|
||||
): event is ThreadStreamEnd;
|
||||
|
||||
export interface ThreadEventOptions {
|
||||
sinceSeq?: number;
|
||||
replayLimit?: number;
|
||||
|
||||
@@ -199,6 +199,11 @@ export function createRuntimeClient(options = {}) {
|
||||
return new CodeWhaleRuntimeClient(options);
|
||||
}
|
||||
|
||||
/** Distinguish a stream-end transport frame from thread journal/progress events. */
|
||||
export function isThreadStreamEnd(event) {
|
||||
return event.event === "stream.end";
|
||||
}
|
||||
|
||||
function normalizeBaseUrl(value) {
|
||||
return value.endsWith("/") ? value : `${value}/`;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { CodeWhaleRuntimeClient } from '../index.js';
|
||||
import { CodeWhaleRuntimeClient, isThreadStreamEnd } from '../index.js';
|
||||
|
||||
function clientFor(chunks, inspect = () => {}, headers = {}) {
|
||||
return new CodeWhaleRuntimeClient({ token: 'fixture-token', fetch: async (url, init) => {
|
||||
@@ -62,3 +62,14 @@ test('a server-ended thread stream yields its typed stream.end frame last', asyn
|
||||
// The journal frame's SSE id becomes its cursor; stream.end carries none.
|
||||
assert.deepEqual(await collect(client.threadEvents('t', { sinceSeq: 9 })), [{ ...record, cursor: '17' }, end]);
|
||||
});
|
||||
|
||||
test('isThreadStreamEnd distinguishes end frames from journal and progress events', async () => {
|
||||
const record = { seq: 17, event: 'future.journal.event', thread_id: 't', timestamp: '2026-09-12T00:00:00Z', payload: {} };
|
||||
const progress = { event: 'stream.progress', thread_id: 't', seq: 17, state: 'live' };
|
||||
const end = { schema_version: 1, event: 'stream.end', kind: 'stream.end', thread_id: 't', reason: 'runtime_shutdown', last_seq: 17, retryable: true };
|
||||
const raw = [record, progress, end].map(value => `data: ${JSON.stringify(value)}\n\n`).join('');
|
||||
const client = clientFor([raw], () => {}, { 'x-codewhale-event-progress': '1' });
|
||||
const events = await collect(client.threadEvents('t', { includeProgress: true }));
|
||||
assert.deepEqual(events.map(isThreadStreamEnd), [false, false, true]);
|
||||
assert.deepEqual(events.filter(isThreadStreamEnd), [end]);
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
// Run with: tsc --noEmit --strict --target ES2022 --module NodeNext test/thread-events.types.ts
|
||||
import { CodeWhaleRuntimeClient, isThreadStreamEnd } from '../index.js';
|
||||
import type { ThreadRuntimeEvent, ThreadStreamEnd } from '../index.js';
|
||||
|
||||
async function checkThreadEvents(client: CodeWhaleRuntimeClient, includeProgress: boolean) {
|
||||
for await (const item of client.threadEvents('thread')) {
|
||||
if (isThreadStreamEnd(item)) {
|
||||
const end: ThreadStreamEnd = item;
|
||||
const cursor: number = end.last_seq;
|
||||
const retryable: boolean = end.retryable;
|
||||
if (retryable) client.threadEvents(end.thread_id, { sinceSeq: cursor });
|
||||
} else {
|
||||
const journal: ThreadRuntimeEvent = item;
|
||||
journal.payload;
|
||||
}
|
||||
}
|
||||
for await (const item of client.threadEvents('thread', { includeProgress: true })) {
|
||||
if (isThreadStreamEnd(item)) {
|
||||
const end: ThreadStreamEnd = item;
|
||||
end.last_seq;
|
||||
end.retryable;
|
||||
} else {
|
||||
const cursor: number = item.seq;
|
||||
client.threadEvents(item.thread_id, { sinceSeq: cursor });
|
||||
}
|
||||
}
|
||||
for await (const item of client.threadEvents('thread', { includeProgress })) {
|
||||
if (isThreadStreamEnd(item)) {
|
||||
const end: ThreadStreamEnd = item;
|
||||
end.last_seq;
|
||||
end.retryable;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Future journal names must remain accepted without closing the protocol union.
|
||||
const future: ThreadRuntimeEvent = {
|
||||
event: 'future.journal.event', seq: 1, thread_id: 'thread', timestamp: '', payload: {},
|
||||
};
|
||||
isThreadStreamEnd(future);
|
||||
void checkThreadEvents;
|
||||
Reference in new issue
Block a user