From 45157888decf0264dfe590f2f4ea7e254c32e389 Mon Sep 17 00:00:00 2001 From: Ghost Scripter Date: Wed, 15 Jul 2026 19:58:45 +0530 Subject: [PATCH 1/3] fix(voice): ship the voice domain in the desktop build (#4901) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Tauri shell embeds the core in-process (#1061), so its dependency declaration is what actually ships. It has carried `default-features = false` since the in-process move, back when the core crate had no `[features] default` list at all — a harmless no-op. #4833 then made `voice` a default-ON compile-time feature so slim builds could drop it. That silently collided with the pre-existing `default-features = false`: the shipped app has been built WITHOUT the voice domain ever since, so the `#[cfg(feature = "voice")]` controllers in `src/core/all.rs` were never registered and every `openhuman.voice_*` RPC answered "unknown method". Evidence: `hound` (reachable only via `voice = ["dep:hound", ...]`) was absent from the shell's dependency graph while present in the root crate's. Sentry confirms in production — `voice_status` (46,735 events / 56 users) and `voice_server_status` (46,592 / 52) both unknown-method, first seen 2026-07-14, hours after #4833 landed, matching first bad release 0.58.19. - Cargo.toml: opt `voice` back in explicitly, and document that every future core default must be re-added here. - lib.rs: assert `VOICE_COMPILED_IN` at compile time. Cargo features are per-crate, so the shell cannot `#[cfg]` on the core's features; this const assert is the only way to make a voice-less desktop binary fail the build instead of failing silently at runtime. Verified red/green. - sttClient.ts: the "restart to pick up the latest core sidecar" copy was written for #1289 (a stale bundled sidecar, removed in #1061) and cannot ever resolve a compile-time gate. Replaced with an accurate reason and a real action, keeping the "unavailable in this build" substring that MicComposer matches to suppress the pointless retry loop. Tests asserting the misleading restart hint were pinning the bug; they now assert the accurate message and that restart advice is not reintroduced. Fixes #4901 --- app/src-tauri/Cargo.lock | 8 ++++ app/src-tauri/Cargo.toml | 12 +++++- app/src-tauri/src/lib.rs | 14 +++++++ app/src/features/human/MicComposer.test.tsx | 5 ++- app/src/features/human/MicComposer.tsx | 2 +- .../features/human/voice/sttClient.test.ts | 41 +++++++++++++++---- app/src/features/human/voice/sttClient.ts | 36 ++++++++++------ src/openhuman/voice/mod.rs | 11 +++++ 8 files changed, 105 insertions(+), 24 deletions(-) diff --git a/app/src-tauri/Cargo.lock b/app/src-tauri/Cargo.lock index c71413f1cb..fe07809888 100644 --- a/app/src-tauri/Cargo.lock +++ b/app/src-tauri/Cargo.lock @@ -3564,6 +3564,12 @@ dependencies = [ "windows-link 0.2.1", ] +[[package]] +name = "hound" +version = "3.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62adaabb884c94955b19907d60019f4e145d091c75345379e70d1ee696f7854f" + [[package]] name = "html5ever" version = "0.29.1" @@ -5608,9 +5614,11 @@ dependencies = [ "hkdf", "hmac 0.12.1", "hostname", + "hound", "iana-time-zone", "image", "keyring", + "lettre", "libc", "log", "mail-parser", diff --git a/app/src-tauri/Cargo.toml b/app/src-tauri/Cargo.toml index 177210c7e1..7023bed36e 100644 --- a/app/src-tauri/Cargo.toml +++ b/app/src-tauri/Cargo.toml @@ -132,7 +132,17 @@ cef = { version = "=146.4.1", default-features = false } # by tying the core's lifetime to the GUI process. The existing port-7788 # probe in `core_process::ensure_running` still attaches to a running # `openhuman-core run` harness when one is already listening. -openhuman_core = { path = "../..", package = "openhuman", default-features = false } +# +# `default-features = false` predates the core's default features (#1061-era, +# when `[features]` had no `default` list) — every default the core gains must +# therefore be opted back in HERE or it silently vanishes from the shipped app. +# `voice` is required: without it the `#[cfg(feature = "voice")]` controllers in +# `src/core/all.rs` are never registered, so the whole `openhuman.voice_*` +# namespace answers "unknown method" at runtime (#4901). The `VOICE_COMPILED_IN` +# const assert at the top of `src/lib.rs` fails the build if this is dropped. +openhuman_core = { path = "../..", package = "openhuman", default-features = false, features = [ + "voice", +] } tinyjuice = { version = "0.2.1", default-features = false } [target.'cfg(unix)'.dependencies] diff --git a/app/src-tauri/src/lib.rs b/app/src-tauri/src/lib.rs index 9cac2be8d8..11586cac31 100644 --- a/app/src-tauri/src/lib.rs +++ b/app/src-tauri/src/lib.rs @@ -3,6 +3,20 @@ #[cfg(not(any(target_os = "windows", target_os = "macos", target_os = "linux")))] compile_error!("src-tauri host supports desktop (Windows/macOS/Linux) only. Mobile lives in app/src-tauri-mobile."); +// The shipped desktop app must always embed the real voice domain. Cargo +// features are per-crate, so `#[cfg(feature = "voice")]` here would test THIS +// crate's features, not the core's — a voice-less core is only observable via +// the core's own always-compiled facade. Without this assert the failure is +// silent and runtime-only: every `openhuman.voice_*` RPC answers "unknown +// method" and the UI blames a stale sidecar (#4901). Keep `voice` in the +// `openhuman_core` feature list in Cargo.toml to satisfy this. +const _: () = assert!( + openhuman_core::openhuman::voice::VOICE_COMPILED_IN, + "openhuman_core must be built with the `voice` feature: the desktop app ships voice, \ + and without it every openhuman.voice_* controller is unregistered (#4901). \ + Add \"voice\" to the openhuman_core `features` list in app/src-tauri/Cargo.toml." +); + mod app_update; // Artifact export commands (#2779, #3162) — both cross-platform // (macOS/Windows/Linux): native Save-As dialog (rfd) + Downloads copy. diff --git a/app/src/features/human/MicComposer.test.tsx b/app/src/features/human/MicComposer.test.tsx index 42d418cf10..26e0062248 100644 --- a/app/src/features/human/MicComposer.test.tsx +++ b/app/src/features/human/MicComposer.test.tsx @@ -794,10 +794,11 @@ describe('MicComposer', () => { expect(onError).not.toHaveBeenCalled(); }); - it('does not retry permanent errors (stale sidecar)', async () => { + it('does not retry permanent errors (voice not compiled into the core)', async () => { transcribeWithFactoryMock.mockRejectedValueOnce( new Error( - 'Voice transcription is unavailable in this build. Restart the OpenHuman desktop app to pick up the latest core sidecar.' + 'Voice transcription is unavailable in this build — the voice module was not compiled into the app. ' + + 'Update OpenHuman to the latest version; restarting will not help.' ) ); const onError = vi.fn(); diff --git a/app/src/features/human/MicComposer.tsx b/app/src/features/human/MicComposer.tsx index 992a83f014..d306bbfd3a 100644 --- a/app/src/features/human/MicComposer.tsx +++ b/app/src/features/human/MicComposer.tsx @@ -33,7 +33,7 @@ const STT_RETRY_BASE_MS = 500; * Matched case-insensitively against the error message. */ const PERMANENT_ERROR_PATTERNS = [ - 'unknown method', // stale sidecar + 'unknown method', // core built without the `voice` feature (#4901) 'audio blob is empty', 'unavailable in this build', ]; diff --git a/app/src/features/human/voice/sttClient.test.ts b/app/src/features/human/voice/sttClient.test.ts index b4d93cc942..924bc98a8d 100644 --- a/app/src/features/human/voice/sttClient.test.ts +++ b/app/src/features/human/voice/sttClient.test.ts @@ -93,14 +93,31 @@ describe('transcribeCloud', () => { expect(await transcribeCloud(blob)).toBe(''); }); - // Issue #1289: stale sidecar binaries surface a generic - // "unknown method" error. Frontend rewrites it to an actionable - // message so users know to restart the desktop app. - it('rewrites "unknown method" errors to an actionable restart hint', async () => { + // #4901: a core built without the `voice` feature never registers the + // `openhuman.voice_*` controllers, so they answer "unknown method". That is a + // compile-time property of the binary, so the message must NOT tell users to + // restart (the pre-#4901 copy did, which could never work). + it('rewrites "unknown method" errors to a not-compiled-in message', async () => { const mock = callCoreRpc as ReturnType; mock.mockRejectedValueOnce(new Error('unknown method: openhuman.voice_cloud_transcribe')); const blob = new Blob([new Uint8Array([1])], { type: 'audio/webm' }); - await expect(transcribeCloud(blob)).rejects.toThrow(/Restart the OpenHuman desktop app/i); + await expect(transcribeCloud(blob)).rejects.toThrow(/not compiled into the app/i); + }); + + it('does not advise restarting for a compile-time voice gate', async () => { + const mock = callCoreRpc as ReturnType; + mock.mockRejectedValueOnce(new Error('unknown method: openhuman.voice_cloud_transcribe')); + const blob = new Blob([new Uint8Array([1])], { type: 'audio/webm' }); + await expect(transcribeCloud(blob)).rejects.not.toThrow(/restart the openhuman desktop app/i); + }); + + // `MicComposer`'s PERMANENT_ERROR_PATTERNS matches this substring to skip the + // retry/backoff loop — a compile-time gate can never succeed on retry. + it('keeps the "unavailable in this build" substring for retry suppression', async () => { + const mock = callCoreRpc as ReturnType; + mock.mockRejectedValueOnce(new Error('unknown method: openhuman.voice_cloud_transcribe')); + const blob = new Blob([new Uint8Array([1])], { type: 'audio/webm' }); + await expect(transcribeCloud(blob)).rejects.toThrow(/unavailable in this build/i); }); it('passes through non-unknown-method errors verbatim', async () => { @@ -148,11 +165,21 @@ describe('transcribeWithFactory', () => { expect(mock).not.toHaveBeenCalled(); }); - it('rewrites stale-sidecar "unknown method" errors', async () => { + // #4901 — same compile-time gate as the cloud path above. + it('rewrites "unknown method" errors to a not-compiled-in message', async () => { + const mock = callCoreRpc as ReturnType; + mock.mockRejectedValueOnce(new Error('unknown method: openhuman.voice_stt_dispatch')); + const blob = new Blob([new Uint8Array([1])], { type: 'audio/webm' }); + await expect(transcribeWithFactory(blob)).rejects.toThrow(/not compiled into the app/i); + }); + + it('does not advise restarting for a compile-time voice gate', async () => { const mock = callCoreRpc as ReturnType; mock.mockRejectedValueOnce(new Error('unknown method: openhuman.voice_stt_dispatch')); const blob = new Blob([new Uint8Array([1])], { type: 'audio/webm' }); - await expect(transcribeWithFactory(blob)).rejects.toThrow(/Restart the OpenHuman desktop app/i); + await expect(transcribeWithFactory(blob)).rejects.not.toThrow( + /restart the openhuman desktop app/i + ); }); it('passes through non-unknown-method errors verbatim', async () => { diff --git a/app/src/features/human/voice/sttClient.ts b/app/src/features/human/voice/sttClient.ts index 1700b067d0..3c4e785299 100644 --- a/app/src/features/human/voice/sttClient.ts +++ b/app/src/features/human/voice/sttClient.ts @@ -4,6 +4,19 @@ import { callCoreRpc } from '../../../services/coreRpcClient'; const sttLog = debug('human:stt'); +/** + * Surfaced when the core answers `unknown method` for a `voice_*` RPC — i.e. + * the core was compiled without the `voice` feature, so the domain is absent + * from the binary entirely (#4901). + * + * Keep the `unavailable in this build` substring: `MicComposer`'s + * `PERMANENT_ERROR_PATTERNS` matches on it to skip the retry/backoff loop, + * which can never succeed for a compile-time gate. + */ +const VOICE_NOT_COMPILED_MESSAGE = + 'Voice transcription is unavailable in this build — the voice module was not compiled into the app. ' + + 'Update OpenHuman to the latest version; restarting will not help.'; + export interface CloudTranscribeOptions { /** Override the backend STT model id. Default is whatever the backend * resolves `whisper-v1` to today. */ @@ -64,17 +77,16 @@ export async function transcribeCloud( params, }); } catch (err) { - // Issue #1289: an "unknown method" error means the bundled core - // sidecar is older than the frontend (e.g. a stale dev build, or a - // cached binary the desktop auto-update hasn't refreshed yet). - // The raw "unknown method: openhuman.voice_cloud_transcribe" string - // is opaque to end users — surface an actionable message instead. + // An "unknown method" error means the core serving this app was built + // without the `voice` Cargo feature, so the `openhuman.voice_*` + // controllers were never registered (#4901). This is a compile-time + // property of the binary — restarting cannot change it, which is why the + // old #1289-era "restart to pick up the latest core sidecar" copy was + // unactionable (and the sidecar itself is gone since #1061). const msg = err instanceof Error ? err.message : String(err); if (msg.includes('unknown method')) { - sttLog('transcribe rpc stale-sidecar path hit; rewriting unknown-method error: %s', msg); - throw new Error( - 'Voice transcription is unavailable in this build. Restart the OpenHuman desktop app to pick up the latest core sidecar.' - ); + sttLog('[voice-stt] transcribe rpc: voice domain absent from core build: %s', msg); + throw new Error(VOICE_NOT_COMPILED_MESSAGE); } sttLog('transcribe rpc failed (passthrough): %O', err); throw err; @@ -149,10 +161,8 @@ export async function transcribeWithFactory( } catch (err) { const msg = err instanceof Error ? err.message : String(err); if (msg.includes('unknown method')) { - sttLog('[voice-stt] dispatch stale-sidecar path: %s', msg); - throw new Error( - 'Voice transcription is unavailable in this build. Restart the OpenHuman desktop app to pick up the latest core sidecar.' - ); + sttLog('[voice-stt] dispatch: voice domain absent from core build: %s', msg); + throw new Error(VOICE_NOT_COMPILED_MESSAGE); } sttLog('[voice-stt] dispatch failed (passthrough): %O', err); throw err; diff --git a/src/openhuman/voice/mod.rs b/src/openhuman/voice/mod.rs index 993dca1479..8dd69d0efe 100644 --- a/src/openhuman/voice/mod.rs +++ b/src/openhuman/voice/mod.rs @@ -92,6 +92,17 @@ pub(crate) fn cloud_transcribe_default_model() -> &'static str { "whisper-v1" } +/// Whether the real voice domain was compiled into this binary. +/// +/// Part of the always-compiled facade so downstream crates can assert the gate +/// resolved the way they need. Cargo features are per-crate and invisible to +/// dependents' `#[cfg]`, so a consumer that *requires* voice (the desktop shell) +/// has no other way to detect that it silently got the stubbed build — which is +/// exactly how #4901 shipped: the shell's `default-features = false` dropped the +/// default-ON `voice` feature, unregistering every `openhuman.voice_*` +/// controller. +pub const VOICE_COMPILED_IN: bool = cfg!(feature = "voice"); + // --------------------------------------------------------------------------- // Disabled facade — compiled only when the `voice` feature is OFF. // --------------------------------------------------------------------------- From e21285d1783830255b1d0f6591148c6c8d36046e Mon Sep 17 00:00:00 2001 From: Ghost Scripter Date: Thu, 16 Jul 2026 00:11:40 +0530 Subject: [PATCH 2/3] refactor(voice): translate the not-compiled error, move gate const out of mod.rs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses review feedback on #4917. Two of three findings were valid. i18n (valid): `MicComposer` interpolated the raw English error into the translated `mic.transcriptionFailed` template ("Transcription failed: {message}"), so non-English locales rendered a half-translated sentence. CI could not catch this — `i18n:english:check` scans locale files, not service code. - sttClient now throws a typed `VoiceNotCompiledError` carrying a stable `VOICE_NOT_COMPILED_CODE`. The English message is kept deliberately: it is what reaches debug logs and Sentry. - `isVoiceNotCompiledError` duck-types on `code` rather than `instanceof` — the error crosses async/retry boundaries where prototypes can be stripped. - `MicComposer` branches on the code and renders `mic.voiceNotCompiled`. - Key added to all 14 locales with real translations (no em dashes, per AGENTS.md). `i18n:check` and `i18n:english:check` both pass. mod.rs (valid): AGENTS.md requires mod.rs be export-only. Moved `VOICE_COMPILED_IN` to a sibling `compile_status.rs`, deliberately ungated (it must exist in both gate states — reporting which state we are in is its purpose) and re-exported. Tests pin the const to `cfg!(feature = "voice")` rather than a literal, so they hold for both builds. transport provenance (skipped): not reachable. `setActiveCoreTransport` has exactly one occurrence in the tree — its own definition — so `_activeTransport` is always null and every `callCoreRpc` hits the local core. The `unknown method` rewrite condition is also pre-existing, unchanged by #4917. Threading provenance through the shared RPC client for a dead path is not minimal; it belongs with whatever work activates the iOS/tunnel transport. Also fixes a latent test defect: MicComposer.test.tsx hand-listed its sttClient mock exports, so any newly imported export silently resolved to undefined and threw a TypeError at the call site. Now spreads importOriginal(). Verified: compile_status tests pass with voice ON and OFF (2/2 each); shell cargo check green (const assert resolves via the re-export); 78 voice tests pass; i18n checks, tsc, ESLint, Prettier, cargo fmt all clean; locale diff is 27 insertions / 0 deletions. --- app/src/features/human/MicComposer.test.tsx | 34 +++++++++++++- app/src/features/human/MicComposer.tsx | 9 +++- app/src/features/human/voice/sttClient.ts | 49 +++++++++++++++++---- app/src/lib/i18n/ar.ts | 2 + app/src/lib/i18n/bn.ts | 2 + app/src/lib/i18n/de.ts | 2 + app/src/lib/i18n/en.ts | 2 + app/src/lib/i18n/es.ts | 2 + app/src/lib/i18n/fr.ts | 2 + app/src/lib/i18n/hi.ts | 2 + app/src/lib/i18n/id.ts | 2 + app/src/lib/i18n/it.ts | 2 + app/src/lib/i18n/ko.ts | 2 + app/src/lib/i18n/pl.ts | 2 + app/src/lib/i18n/pt.ts | 2 + app/src/lib/i18n/ru.ts | 2 + app/src/lib/i18n/zh-CN.ts | 1 + src/openhuman/voice/compile_status.rs | 49 +++++++++++++++++++++ src/openhuman/voice/mod.rs | 17 +++---- 19 files changed, 164 insertions(+), 21 deletions(-) create mode 100644 src/openhuman/voice/compile_status.rs diff --git a/app/src/features/human/MicComposer.test.tsx b/app/src/features/human/MicComposer.test.tsx index 26e0062248..da5411b31d 100644 --- a/app/src/features/human/MicComposer.test.tsx +++ b/app/src/features/human/MicComposer.test.tsx @@ -7,12 +7,19 @@ import { MicComposer, STT_MAX_RETRIES, } from './MicComposer'; +import { VoiceNotCompiledError } from './voice/sttClient'; // transcribeWithFactory + encodeBlobToWav are the network/heavy boundaries — // mock them here so we can drive the state machine without touching real APIs. const transcribeWithFactoryMock = vi.fn(); const encodeBlobToWavMock = vi.fn(); -vi.mock('./voice/sttClient', () => ({ +// Spread the real module so `VoiceNotCompiledError` / `isVoiceNotCompiledError` +// keep their real behaviour; only the network-touching call is stubbed. A +// factory listing exports by hand silently yields `undefined` for anything it +// forgets, which fails as a TypeError at the call site rather than a clear mock +// error. +vi.mock('./voice/sttClient', async importOriginal => ({ + ...(await importOriginal()), transcribeWithFactory: (...args: unknown[]) => transcribeWithFactoryMock(...args), })); vi.mock('./voice/wavEncoder', () => ({ @@ -817,6 +824,31 @@ describe('MicComposer', () => { expect(onSubmit).not.toHaveBeenCalled(); }); + // #4901: the raw error text is untranslated developer copy, so a + // VoiceNotCompiledError must surface the localized `mic.voiceNotCompiled` + // string instead of being spliced into the `{message}` slot. + it('renders translated copy for a voice-not-compiled error, not the raw message', async () => { + transcribeWithFactoryMock.mockRejectedValueOnce(new VoiceNotCompiledError()); + const onError = vi.fn(); + const onSubmit = vi.fn(); + render(); + + fireEvent.click(screen.getByRole('button', { name: /start recording/i })); + await waitFor(() => expect(getUserMediaMock).toHaveBeenCalled()); + await waitFor(() => + expect(screen.getByRole('button', { name: /stop recording and send/i })).toBeInTheDocument() + ); + fireEvent.click(screen.getByRole('button', { name: /stop recording and send/i })); + + await waitFor(() => expect(onError).toHaveBeenCalled()); + const shown = onError.mock.calls[0][0] as string; + // The English `mic.voiceNotCompiled` value, resolved through useT(). + expect(shown).toMatch(/not included in this version/i); + // The untranslated developer copy must not leak into the UI. + expect(shown).not.toMatch(/not compiled into the app/i); + expect(onSubmit).not.toHaveBeenCalled(); + }); + // ── Low-confidence transcript detection (#1206) ──────────────────────────── it('rejects a single-character transcript as low confidence', async () => { diff --git a/app/src/features/human/MicComposer.tsx b/app/src/features/human/MicComposer.tsx index d306bbfd3a..b843cac268 100644 --- a/app/src/features/human/MicComposer.tsx +++ b/app/src/features/human/MicComposer.tsx @@ -3,7 +3,7 @@ import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react import { createPortal } from 'react-dom'; import { useT } from '../../lib/i18n/I18nContext'; -import { transcribeWithFactory } from './voice/sttClient'; +import { isVoiceNotCompiledError, transcribeWithFactory } from './voice/sttClient'; import { encodeBlobToWav } from './voice/wavEncoder'; /** Minimal descriptor for an audio input device. */ @@ -576,6 +576,13 @@ export function MicComposer({ } const msg = err instanceof Error ? err.message : String(err); composerLog('transcribe failed: %s', msg); + // The core has no voice domain compiled in (#4901). `msg` is untranslated + // developer copy, so render the localized string rather than splicing it + // into the `{message}` slot. + if (isVoiceNotCompiledError(err)) { + onError?.(t('mic.voiceNotCompiled')); + return; + } onError?.(t('mic.transcriptionFailed').replace('{message}', msg)); } finally { if (!disposedRef.current) setState('idle'); diff --git a/app/src/features/human/voice/sttClient.ts b/app/src/features/human/voice/sttClient.ts index 3c4e785299..9876503d73 100644 --- a/app/src/features/human/voice/sttClient.ts +++ b/app/src/features/human/voice/sttClient.ts @@ -5,17 +5,50 @@ import { callCoreRpc } from '../../../services/coreRpcClient'; const sttLog = debug('human:stt'); /** - * Surfaced when the core answers `unknown method` for a `voice_*` RPC — i.e. - * the core was compiled without the `voice` feature, so the domain is absent - * from the binary entirely (#4901). + * Stable identifier for "the core has no voice domain compiled in" (#4901). + * + * Callers must branch on this code rather than on the message text: the message + * is untranslated developer/log copy, and the UI is responsible for rendering + * translated copy via `useT()` (see `MicComposer`'s `mic.voiceNotCompiled`). + */ +export const VOICE_NOT_COMPILED_CODE = 'voice_not_compiled'; + +/** + * Thrown when the core answers `unknown method` for a `voice_*` RPC — i.e. the + * core was compiled without the `voice` feature, so the domain is absent from + * the binary entirely (#4901). + * + * The `message` is English on purpose: it is what reaches debug logs and Sentry. + * User-facing copy is resolved from `code` at the UI boundary. * * Keep the `unavailable in this build` substring: `MicComposer`'s * `PERMANENT_ERROR_PATTERNS` matches on it to skip the retry/backoff loop, * which can never succeed for a compile-time gate. */ -const VOICE_NOT_COMPILED_MESSAGE = - 'Voice transcription is unavailable in this build — the voice module was not compiled into the app. ' + - 'Update OpenHuman to the latest version; restarting will not help.'; +export class VoiceNotCompiledError extends Error { + readonly code = VOICE_NOT_COMPILED_CODE; + + constructor() { + super( + 'Voice transcription is unavailable in this build — the voice module was not compiled into the app. ' + + 'Update OpenHuman to the latest version; restarting will not help.' + ); + this.name = 'VoiceNotCompiledError'; + } +} + +/** + * Duck-typed on `code` rather than `instanceof`: the error crosses async + + * retry boundaries, and structured-clone/serialization paths can strip the + * prototype while preserving own properties. + */ +export function isVoiceNotCompiledError(err: unknown): boolean { + return ( + typeof err === 'object' && + err !== null && + (err as { code?: unknown }).code === VOICE_NOT_COMPILED_CODE + ); +} export interface CloudTranscribeOptions { /** Override the backend STT model id. Default is whatever the backend @@ -86,7 +119,7 @@ export async function transcribeCloud( const msg = err instanceof Error ? err.message : String(err); if (msg.includes('unknown method')) { sttLog('[voice-stt] transcribe rpc: voice domain absent from core build: %s', msg); - throw new Error(VOICE_NOT_COMPILED_MESSAGE); + throw new VoiceNotCompiledError(); } sttLog('transcribe rpc failed (passthrough): %O', err); throw err; @@ -162,7 +195,7 @@ export async function transcribeWithFactory( const msg = err instanceof Error ? err.message : String(err); if (msg.includes('unknown method')) { sttLog('[voice-stt] dispatch: voice domain absent from core build: %s', msg); - throw new Error(VOICE_NOT_COMPILED_MESSAGE); + throw new VoiceNotCompiledError(); } sttLog('[voice-stt] dispatch failed (passthrough): %O', err); throw err; diff --git a/app/src/lib/i18n/ar.ts b/app/src/lib/i18n/ar.ts index a86721859b..9b9662d400 100644 --- a/app/src/lib/i18n/ar.ts +++ b/app/src/lib/i18n/ar.ts @@ -3163,6 +3163,8 @@ const messages: TranslationMap = { 'mic.lowConfidenceResult': 'تعذّر فهم الصوت بوضوح: يرجى المحاولة مرة أخرى', 'mic.failedToStopRecording': 'فشل إيقاف التسجيل: {message}', 'mic.transcriptionFailed': 'فشل النسخ: {message}', + 'mic.voiceNotCompiled': + 'خاصية تحويل الصوت إلى نص غير متوفرة في هذا الإصدار من التطبيق. حدّث OpenHuman لتفعيلها.', 'reflections.kind.retrospective': 'مراجعة', 'reflections.kind.derivedFact': 'حقيقة مستنتجة', 'reflections.kind.moodInsight': 'رؤية المزاج', diff --git a/app/src/lib/i18n/bn.ts b/app/src/lib/i18n/bn.ts index 340eb931f6..1e34ff6cc7 100644 --- a/app/src/lib/i18n/bn.ts +++ b/app/src/lib/i18n/bn.ts @@ -3238,6 +3238,8 @@ const messages: TranslationMap = { 'mic.lowConfidenceResult': 'অডিও স্পষ্টভাবে বোঝা যায়নি: আবার চেষ্টা করুন', 'mic.failedToStopRecording': 'রেকর্ডিং বন্ধ করতে ব্যর্থ: {message}', 'mic.transcriptionFailed': 'ট্রান্সক্রিপশন ব্যর্থ: {message}', + 'mic.voiceNotCompiled': + 'অ্যাপের এই সংস্করণে ভয়েস ট্রান্সক্রিপশন অন্তর্ভুক্ত নেই। এটি চালু করতে OpenHuman আপডেট করুন।', 'reflections.kind.retrospective': 'পূর্বদর্শন', 'reflections.kind.derivedFact': 'ডেরাইভড ফ্যাক্ট', 'reflections.kind.moodInsight': 'মুড ইনসাইট', diff --git a/app/src/lib/i18n/de.ts b/app/src/lib/i18n/de.ts index 77cc64948d..871f54a665 100644 --- a/app/src/lib/i18n/de.ts +++ b/app/src/lib/i18n/de.ts @@ -3328,6 +3328,8 @@ const messages: TranslationMap = { 'mic.lowConfidenceResult': 'Audio konnte nicht klar verstanden werden: bitte erneut versuchen', 'mic.failedToStopRecording': 'Aufzeichnung konnte nicht gestoppt werden: {message}', 'mic.transcriptionFailed': 'Transkription fehlgeschlagen: {message}', + 'mic.voiceNotCompiled': + 'Sprachtranskription ist in dieser App-Version nicht enthalten. Aktualisiere OpenHuman, um sie zu aktivieren.', 'reflections.kind.retrospective': 'Retrospektive', 'reflections.kind.derivedFact': 'Abgeleitete Tatsache', 'reflections.kind.moodInsight': 'Stimmungseinsicht', diff --git a/app/src/lib/i18n/en.ts b/app/src/lib/i18n/en.ts index a1dfc6742f..0291d524c1 100644 --- a/app/src/lib/i18n/en.ts +++ b/app/src/lib/i18n/en.ts @@ -3629,6 +3629,8 @@ const en: TranslationMap = { 'mic.lowConfidenceResult': 'Could not understand the audio clearly: please try again', 'mic.failedToStopRecording': 'Failed to stop recording: {message}', 'mic.transcriptionFailed': 'Transcription failed: {message}', + 'mic.voiceNotCompiled': + 'Voice transcription is not included in this version of the app. Update OpenHuman to enable it.', // Reflections: kind labels 'reflections.kind.retrospective': 'Retrospective', diff --git a/app/src/lib/i18n/es.ts b/app/src/lib/i18n/es.ts index d8d676616a..b41d056590 100644 --- a/app/src/lib/i18n/es.ts +++ b/app/src/lib/i18n/es.ts @@ -3299,6 +3299,8 @@ const messages: TranslationMap = { 'mic.lowConfidenceResult': 'No se pudo entender el audio con claridad: intenta de nuevo', 'mic.failedToStopRecording': 'No se pudo detener la grabación: {message}', 'mic.transcriptionFailed': 'Transcripción fallida: {message}', + 'mic.voiceNotCompiled': + 'La transcripción de voz no está incluida en esta versión de la aplicación. Actualiza OpenHuman para activarla.', 'reflections.kind.retrospective': 'Retrospectiva', 'reflections.kind.derivedFact': 'Hecho derivado', 'reflections.kind.moodInsight': 'Insight de ánimo', diff --git a/app/src/lib/i18n/fr.ts b/app/src/lib/i18n/fr.ts index c6a8907d4f..4d34ed6dfa 100644 --- a/app/src/lib/i18n/fr.ts +++ b/app/src/lib/i18n/fr.ts @@ -3323,6 +3323,8 @@ const messages: TranslationMap = { 'mic.lowConfidenceResult': "Impossible de comprendre l'audio clairement: réessaie", 'mic.failedToStopRecording': "Échec de l'arrêt de l'enregistrement : {message}", 'mic.transcriptionFailed': 'Échec de la transcription : {message}', + 'mic.voiceNotCompiled': + "La transcription vocale n'est pas incluse dans cette version de l'application. Mettez à jour OpenHuman pour l'activer.", 'reflections.kind.retrospective': 'Rétrospective', 'reflections.kind.derivedFact': 'Fait dérivé', 'reflections.kind.moodInsight': 'Insight émotionnel', diff --git a/app/src/lib/i18n/hi.ts b/app/src/lib/i18n/hi.ts index 32c565246a..ce2250f929 100644 --- a/app/src/lib/i18n/hi.ts +++ b/app/src/lib/i18n/hi.ts @@ -3236,6 +3236,8 @@ const messages: TranslationMap = { 'mic.lowConfidenceResult': 'ऑडियो स्पष्ट रूप से समझ नहीं आया: कृपया पुनः प्रयास करें', 'mic.failedToStopRecording': 'रिकॉर्डिंग रोकने में दिक्कत: {message}', 'mic.transcriptionFailed': 'ट्रांसक्रिप्शन विफल: {message}', + 'mic.voiceNotCompiled': + 'इस ऐप संस्करण में वॉइस ट्रांसक्रिप्शन शामिल नहीं है। इसे चालू करने के लिए OpenHuman को अपडेट करें।', 'reflections.kind.retrospective': 'रेट्रोस्पेक्टिव', 'reflections.kind.derivedFact': 'डिराइव्ड फैक्ट', 'reflections.kind.moodInsight': 'मूड इनसाइट', diff --git a/app/src/lib/i18n/id.ts b/app/src/lib/i18n/id.ts index 71dcb44573..2c9eddecaf 100644 --- a/app/src/lib/i18n/id.ts +++ b/app/src/lib/i18n/id.ts @@ -3248,6 +3248,8 @@ const messages: TranslationMap = { 'mic.lowConfidenceResult': 'Tidak dapat memahami audio dengan jelas: silakan coba lagi', 'mic.failedToStopRecording': 'Gagal menghentikan perekaman: {message}', 'mic.transcriptionFailed': 'Transkripsi gagal: {message}', + 'mic.voiceNotCompiled': + 'Transkripsi suara tidak tersedia di versi aplikasi ini. Perbarui OpenHuman untuk mengaktifkannya.', 'reflections.kind.retrospective': 'Retrospektif', 'reflections.kind.derivedFact': 'Fakta Turunan', 'reflections.kind.moodInsight': 'Wawasan Suasana Hati', diff --git a/app/src/lib/i18n/it.ts b/app/src/lib/i18n/it.ts index 92c48d9082..6ca7a6820e 100644 --- a/app/src/lib/i18n/it.ts +++ b/app/src/lib/i18n/it.ts @@ -3297,6 +3297,8 @@ const messages: TranslationMap = { 'mic.lowConfidenceResult': "Impossibile comprendere l'audio chiaramente: riprova", 'mic.failedToStopRecording': 'Impossibile fermare la registrazione: {message}', 'mic.transcriptionFailed': 'Trascrizione fallita: {message}', + 'mic.voiceNotCompiled': + "La trascrizione vocale non è inclusa in questa versione dell'app. Aggiorna OpenHuman per attivarla.", 'reflections.kind.retrospective': 'Retrospettiva', 'reflections.kind.derivedFact': 'Fatto derivato', 'reflections.kind.moodInsight': "Insight sull'umore", diff --git a/app/src/lib/i18n/ko.ts b/app/src/lib/i18n/ko.ts index 77a920ae01..0f484310e5 100644 --- a/app/src/lib/i18n/ko.ts +++ b/app/src/lib/i18n/ko.ts @@ -3201,6 +3201,8 @@ const messages: TranslationMap = { 'mic.lowConfidenceResult': '오디오를 명확하게 이해할 수 없습니다: 다시 시도해 주세요', 'mic.failedToStopRecording': '녹음을 중지하지 못했습니다: {message}', 'mic.transcriptionFailed': '전사에 실패했습니다: {message}', + 'mic.voiceNotCompiled': + '이 버전의 앱에는 음성 받아쓰기가 포함되어 있지 않습니다. OpenHuman을 업데이트하면 사용할 수 있습니다.', 'reflections.kind.retrospective': '회고', 'reflections.kind.derivedFact': '파생된 사실', 'reflections.kind.moodInsight': '기분 인사이트', diff --git a/app/src/lib/i18n/pl.ts b/app/src/lib/i18n/pl.ts index 26d6481937..71a6700c09 100644 --- a/app/src/lib/i18n/pl.ts +++ b/app/src/lib/i18n/pl.ts @@ -3274,6 +3274,8 @@ const messages: TranslationMap = { 'mic.lowConfidenceResult': 'Nie udało się wyraźnie zrozumieć dźwięku: spróbuj ponownie', 'mic.failedToStopRecording': 'Nie udało się zatrzymać nagrywania: {message}', 'mic.transcriptionFailed': 'Transkrypcja nie powiodła się: {message}', + 'mic.voiceNotCompiled': + 'Transkrypcja głosu nie jest dostępna w tej wersji aplikacji. Zaktualizuj OpenHuman, aby ją włączyć.', 'reflections.kind.retrospective': 'Retrospektywa', 'reflections.kind.derivedFact': 'Wywiedziony fakt', 'reflections.kind.moodInsight': 'Wnioski o nastroju', diff --git a/app/src/lib/i18n/pt.ts b/app/src/lib/i18n/pt.ts index 8ed1b3f750..f88efaeb90 100644 --- a/app/src/lib/i18n/pt.ts +++ b/app/src/lib/i18n/pt.ts @@ -3291,6 +3291,8 @@ const messages: TranslationMap = { 'mic.lowConfidenceResult': 'Não foi possível entender o áudio com clareza: tente novamente', 'mic.failedToStopRecording': 'Falha ao parar a gravação: {message}', 'mic.transcriptionFailed': 'Falha na transcrição: {message}', + 'mic.voiceNotCompiled': + 'A transcrição de voz não está incluída nesta versão do aplicativo. Atualize o OpenHuman para ativá-la.', 'reflections.kind.retrospective': 'Retrospectiva', 'reflections.kind.derivedFact': 'Fato Derivado', 'reflections.kind.moodInsight': 'Insight de Humor', diff --git a/app/src/lib/i18n/ru.ts b/app/src/lib/i18n/ru.ts index 897d272052..45ea259140 100644 --- a/app/src/lib/i18n/ru.ts +++ b/app/src/lib/i18n/ru.ts @@ -3264,6 +3264,8 @@ const messages: TranslationMap = { 'mic.lowConfidenceResult': 'Не удалось чётко распознать аудио: попробуйте ещё раз', 'mic.failedToStopRecording': 'Не удалось остановить запись: {message}', 'mic.transcriptionFailed': 'Ошибка транскрипции: {message}', + 'mic.voiceNotCompiled': + 'Расшифровка речи не включена в эту версию приложения. Обновите OpenHuman, чтобы включить её.', 'reflections.kind.retrospective': 'Ретроспектива', 'reflections.kind.derivedFact': 'Выведенный факт', 'reflections.kind.moodInsight': 'Инсайт о настроении', diff --git a/app/src/lib/i18n/zh-CN.ts b/app/src/lib/i18n/zh-CN.ts index 23d8f0446c..517b1b8fc0 100644 --- a/app/src/lib/i18n/zh-CN.ts +++ b/app/src/lib/i18n/zh-CN.ts @@ -3065,6 +3065,7 @@ const messages: TranslationMap = { 'mic.lowConfidenceResult': '无法清楚地理解音频:请重试', 'mic.failedToStopRecording': '停止录音失败: {message}', 'mic.transcriptionFailed': '转录失败: {message}', + 'mic.voiceNotCompiled': '此版本的应用未包含语音转文字功能。请更新 OpenHuman 后再使用。', 'reflections.kind.retrospective': '回顾', 'reflections.kind.derivedFact': '派生事实', 'reflections.kind.moodInsight': '情绪洞察', diff --git a/src/openhuman/voice/compile_status.rs b/src/openhuman/voice/compile_status.rs new file mode 100644 index 0000000000..c4973cd244 --- /dev/null +++ b/src/openhuman/voice/compile_status.rs @@ -0,0 +1,49 @@ +//! Compile-time visibility into the `voice` gate. +//! +//! Deliberately **ungated**: unlike the rest of the domain, this module is +//! compiled in both feature states, because its whole purpose is to report +//! which state the binary ended up in. It is part of the always-compiled facade +//! described in [`super`], alongside `stub`. + +/// Whether the real voice domain was compiled into this binary. +/// +/// Cargo features are per-crate and invisible to dependents' `#[cfg]`, so a +/// consumer that *requires* voice (the desktop shell) has no other way to detect +/// that it silently got the stubbed build — which is exactly how #4901 shipped: +/// `app/src-tauri/Cargo.toml` set `default-features = false`, dropping the +/// default-ON `voice` feature, so every `openhuman.voice_*` controller went +/// unregistered and answered "unknown method" at runtime. +/// +/// The shell asserts this at compile time (`const _: () = assert!(...)` in +/// `app/src-tauri/src/lib.rs`), turning that silent runtime failure into a build +/// failure. +pub const VOICE_COMPILED_IN: bool = cfg!(feature = "voice"); + +#[cfg(test)] +mod tests { + use super::VOICE_COMPILED_IN; + + /// Pins the constant to the gate rather than to a hardcoded value: the + /// assertion inverts with the feature, so it holds for both the default + /// build and the slim (`--no-default-features`) build. + #[test] + fn reports_the_compiled_gate_state() { + assert_eq!(VOICE_COMPILED_IN, cfg!(feature = "voice")); + } + + /// The default build ships voice; this is the state the desktop app + /// requires (#4901). Skipped when the slim build is under test. + #[test] + #[cfg(feature = "voice")] + fn is_true_when_the_voice_feature_is_on() { + assert!(VOICE_COMPILED_IN); + } + + /// The slim build must report honestly, otherwise the shell's const assert + /// would pass against a stubbed core and #4901 could ship again. + #[test] + #[cfg(not(feature = "voice"))] + fn is_false_when_the_voice_feature_is_off() { + assert!(!VOICE_COMPILED_IN); + } +} diff --git a/src/openhuman/voice/mod.rs b/src/openhuman/voice/mod.rs index 8dd69d0efe..50b3508d5d 100644 --- a/src/openhuman/voice/mod.rs +++ b/src/openhuman/voice/mod.rs @@ -23,6 +23,12 @@ //! (`cargo check --no-default-features --features ""`): any //! signature drift fails that build. +// Ungated — part of the always-compiled facade (see the module docs above): it +// reports which side of the gate this binary landed on, so it must exist in +// both states. +pub mod compile_status; +pub use compile_status::VOICE_COMPILED_IN; + #[cfg(feature = "voice")] pub mod always_on; #[cfg(feature = "voice")] @@ -92,17 +98,6 @@ pub(crate) fn cloud_transcribe_default_model() -> &'static str { "whisper-v1" } -/// Whether the real voice domain was compiled into this binary. -/// -/// Part of the always-compiled facade so downstream crates can assert the gate -/// resolved the way they need. Cargo features are per-crate and invisible to -/// dependents' `#[cfg]`, so a consumer that *requires* voice (the desktop shell) -/// has no other way to detect that it silently got the stubbed build — which is -/// exactly how #4901 shipped: the shell's `default-features = false` dropped the -/// default-ON `voice` feature, unregistering every `openhuman.voice_*` -/// controller. -pub const VOICE_COMPILED_IN: bool = cfg!(feature = "voice"); - // --------------------------------------------------------------------------- // Disabled facade — compiled only when the `voice` feature is OFF. // --------------------------------------------------------------------------- From 8a3d89260b03c9816a1def40b1d6f9e9ac2cc970 Mon Sep 17 00:00:00 2001 From: Ghost Scripter Date: Thu, 16 Jul 2026 02:43:24 +0530 Subject: [PATCH 3/3] fix(build): forward tokenjuice-treesitter + gate feature-forwarding drift in CI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #4918 and #4919 — the remaining un-forwarded gate, and the check that stops the next one from slipping through. #4918 — the desktop app has never shipped with tree-sitter. The shell declares `openhuman_core` with `default-features = false`, so `tokenjuice-treesitter` (default-ON in the core) was silently dropped and TokenJuice fell back to the brace-depth heuristic instead of the AST-aware path. Unlike #4901 this failed *soft*: no error, no Sentry signal, just quietly worse compression, which is why it went unnoticed since the gate was added in #4123. The exclusion was NOT deliberate. #4575 ("fix tinyjuice build wiring") set out to "forward the correct TinyJuice tree-sitter feature name" and named broken `tokenjuice-treesitter` resolution as its root cause — the intent was clearly for this to work. The shell's separate `tinyjuice = { default-features = false }` is version alignment for the 0.2.1 patch, not a veto. Cost: 5 crates (tree-sitter + Rust/TS/Python grammars, C build); the core already pays it. Verified with the probe that diagnosed #4901: `cargo tree -i tree-sitter` from app/src-tauri went from "did not match any packages" to resolving via tinyjuice -> openhuman -> OpenHuman. Shell `cargo check` green. #4919 — nothing enforced that the shell's forwarded list matches the core's `default` list. Two of three gates have now been dropped, by two different authors, one of whom knew about the trap and documented it in a comment. A comment is documentation, not enforcement. - scripts/lib/feature-forwarding.mjs — narrow TOML reader + drift diff. No TOML dep exists for Node, and only two well-known shapes are needed, so it is scanner-based like the sibling checklist-parser.mjs. - scripts/ci/check-feature-forwarding.mjs — CLI + INTENTIONALLY_NOT_FORWARDED allow-list (empty by design). An intentional exclusion must carry a reason, so "deliberate" and "forgotten" stop looking identical — that ambiguity is what let #4918 sit. - New always-on CI lane, wired into the PR CI Gate. Deliberately not filtered on changed paths: drift can come from either manifest, and a skipped job counts as a pass in the gate. - AGENTS.md documents the rule next to the gate docs. The checker refuses to pass vacuously: parsing zero core defaults exits 2 rather than reporting success. That caught a real bug in my own parser during development (`\s` matches newlines, so `^\s*\[features\]` anchored early and sliced the section to nothing). Verified red/green on the real manifests: green as committed; dropping voice and tokenjuice-treesitter fails with exit 1 naming both. 18 unit tests, including regression cases reproducing #4901 and #4918, and a case proving a brand-new default gate is covered with no per-gate wiring. Full `pnpm test:scripts` suite passes (103 tests). --- .github/workflows/ci-lite.yml | 26 +++ AGENTS.md | 4 + app/src-tauri/Cargo.lock | 60 ++++++ app/src-tauri/Cargo.toml | 10 +- scripts/__tests__/feature-forwarding.test.mjs | 187 +++++++++++++++++ scripts/ci/check-feature-forwarding.mjs | 80 ++++++++ scripts/lib/feature-forwarding.mjs | 189 ++++++++++++++++++ 7 files changed, 554 insertions(+), 2 deletions(-) create mode 100644 scripts/__tests__/feature-forwarding.test.mjs create mode 100644 scripts/ci/check-feature-forwarding.mjs create mode 100644 scripts/lib/feature-forwarding.mjs diff --git a/.github/workflows/ci-lite.yml b/.github/workflows/ci-lite.yml index f807ef536c..14164a9414 100644 --- a/.github/workflows/ci-lite.yml +++ b/.github/workflows/ci-lite.yml @@ -648,6 +648,30 @@ jobs: - name: Run orchestration IP gate run: bash scripts/ci/orch-ip-gate.sh + feature-forwarding-gate: + name: Feature Forwarding Gate (shell forwards core defaults) + runs-on: ubuntu-22.04 + timeout-minutes: 5 + steps: + - name: Checkout code + uses: actions/checkout@v7 + with: + fetch-depth: 1 + persist-credentials: false + + # The shell sets `default-features = false` on `openhuman_core`, so it does + # NOT inherit the core's default gates — each must be forwarded by hand. + # When that list drifts, the domain is compiled out of the shipped app with + # no build error and no failing test: that is how #4901 (voice — 56 users, + # ~93k Sentry events) and #4918 (tokenjuice-treesitter — silent) shipped. + # + # Deliberately NOT filtered on `changes`: drift can be introduced by + # editing either manifest, so a path filter watching one would miss it, and + # a skipped job counts as a pass in the gate below. The check is a few + # seconds of pure Node with no dependencies. + - name: Verify the desktop shell forwards every default-ON core gate + run: node scripts/ci/check-feature-forwarding.mjs + pester-install: name: PowerShell Install Test (Pester) needs: [changes] @@ -714,6 +738,7 @@ jobs: - pester-install - tinycortex-tests - orch-ip-gate + - feature-forwarding-gate if: always() runs-on: ubuntu-latest timeout-minutes: 15 @@ -732,6 +757,7 @@ jobs: ["PowerShell Install Test"]="${{ needs['pester-install'].result }}" ["TinyCortex Memory Tests"]="${{ needs['tinycortex-tests'].result }}" ["Orchestration IP Gate"]="${{ needs['orch-ip-gate'].result }}" + ["Feature Forwarding Gate"]="${{ needs['feature-forwarding-gate'].result }}" ) failed=0 diff --git a/AGENTS.md b/AGENTS.md index b279ed219f..20c186a54e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -222,6 +222,10 @@ Two independent runtime axes on `CoreBuilder` (`src/core/runtime/builder.rs`): Per-domain Cargo features drop whole domains **at compile time** (smaller binary, fewer deps), composing with the runtime `DomainSet` axis above. Each gate is **default-ON**, so the desktop build is byte-identical; slim builds opt out explicitly. +> **Adding a default-ON gate? You must forward it to the desktop shell.** +> `app/src-tauri/Cargo.toml` declares `openhuman_core` with `default-features = false` (set in #1061, before gates existed), so the shipped app does **not** inherit the core's `default` list. A gate you add to `default` but not to the shell's `features` list is **compiled out of the shipped desktop app** — with no build error and no failing test. This is not hypothetical: `voice` shipped missing from v0.58.19 to v0.61.x (56 users, ~93k Sentry events, #4901), and `tokenjuice-treesitter` was never forwarded once since #4123 and failed *soft*, silently degrading AST compression (#4918). +> `scripts/ci/check-feature-forwarding.mjs` (the **Feature Forwarding Gate** lane) now fails CI on drift and covers new gates automatically. If a gate genuinely must not ship, add it to `INTENTIONALLY_NOT_FORWARDED` in that script **with a reason** — an explicit exclusion is the only way "deliberate" stays distinguishable from "forgotten". + **Slim-profile convention** (no `full` meta-feature): build slim variants with `cargo build --no-default-features --features ""`. This mirrors the existing standalone-feature style (`sandbox-landlock`, `browser-native`, …). Example — everything except voice: ```bash diff --git a/app/src-tauri/Cargo.lock b/app/src-tauri/Cargo.lock index dce7e7528f..acbb06c45a 100644 --- a/app/src-tauri/Cargo.lock +++ b/app/src-tauri/Cargo.lock @@ -8327,6 +8327,12 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b2231b7c3057d5e4ad0156fb3dc807d900806020c5ffa3ee6ff2c8c76fb8520" + [[package]] name = "strict-num" version = "0.1.1" @@ -9276,6 +9282,10 @@ dependencies = [ "sha2 0.11.0", "thiserror 2.0.18", "tokio", + "tree-sitter", + "tree-sitter-python", + "tree-sitter-rust", + "tree-sitter-typescript", "unicode-segmentation", "unicode-width", ] @@ -9726,6 +9736,56 @@ dependencies = [ "windows-sys 0.60.2", ] +[[package]] +name = "tree-sitter" +version = "0.26.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af1c71c1c4cc0920b20d6b0f6572e7682cd07a6a2faec71067a31fa394c586df" +dependencies = [ + "cc", + "regex", + "regex-syntax", + "serde_json", + "streaming-iterator", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-language" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "009994f150cc0cd50ff54917d5bc8bffe8cad10ca10d81c34da2ec421ae61782" + +[[package]] +name = "tree-sitter-python" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6bf85fd39652e740bf60f46f4cda9492c3a9ad75880575bf14960f775cb74a1c" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-rust" +version = "0.24.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439e577dbe07423ec2582ac62c7531120dbfccfa6e5f92406f93dd271a120e45" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-typescript" +version = "0.23.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c5f76ed8d947a75cc446d5fccd8b602ebf0cde64ccf2ffa434d873d7a575eff" +dependencies = [ + "cc", + "tree-sitter-language", +] + [[package]] name = "try-lock" version = "0.2.5" diff --git a/app/src-tauri/Cargo.toml b/app/src-tauri/Cargo.toml index 04015c24a7..6b79da31b1 100644 --- a/app/src-tauri/Cargo.toml +++ b/app/src-tauri/Cargo.toml @@ -150,11 +150,17 @@ cef = { version = "=146.4.1", default-features = false } # - `media` — re-registers the `media_generate_*` agent tools that #4804 moved # behind `#[cfg(feature = "media")]`; it sheds no deps, so this only restores # the pre-gate desktop tool surface. +# - `tokenjuice-treesitter` — forwards `tinyjuice/tinyjuice-treesitter`, which +# pulls the tree-sitter Rust/TS/Python grammars. Without it TokenJuice falls +# back to the brace-depth heuristic, so the desktop app compresses code worse +# than intended. This failed *soft* (no error, no Sentry signal) and had never +# once been forwarded since the gate was added in #4123 (#4918). # -# `tokenjuice-treesitter` is the remaining un-forwarded default — tracked in -# #4918, deliberately not bundled here because dropping it may be intentional. +# `scripts/ci/check-feature-forwarding.mjs` fails CI when this list drifts from +# the core's `[features] default` (#4919) — do not hand-maintain it from memory. openhuman_core = { path = "../..", package = "openhuman", default-features = false, features = [ "media", + "tokenjuice-treesitter", "voice", ] } tinyjuice = { version = "0.2.1", default-features = false } diff --git a/scripts/__tests__/feature-forwarding.test.mjs b/scripts/__tests__/feature-forwarding.test.mjs new file mode 100644 index 0000000000..745c3a6c82 --- /dev/null +++ b/scripts/__tests__/feature-forwarding.test.mjs @@ -0,0 +1,187 @@ +import assert from 'node:assert/strict'; +import { execFileSync } from 'node:child_process'; +import { readFileSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; +import { test } from 'node:test'; +import { fileURLToPath } from 'node:url'; + +import { + diffForwarding, + parseCoreDefaultFeatures, + parseShellForwardedFeatures, + stripComments, +} from '../lib/feature-forwarding.mjs'; + +const REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '../..'); +const CHECKER = resolve(REPO_ROOT, 'scripts/ci/check-feature-forwarding.mjs'); + +// ── parsing ──────────────────────────────────────────────────────────────── + +test('parses the core default gate list', () => { + const toml = ` +[features] +default = ["tokenjuice-treesitter", "voice", "media"] +voice = ["dep:hound"] +`; + assert.deepEqual(parseCoreDefaultFeatures(toml), ['tokenjuice-treesitter', 'voice', 'media']); +}); + +test('parses a multi-line default gate list', () => { + const toml = ` +[features] +default = [ + "voice", + "media", +] +`; + assert.deepEqual(parseCoreDefaultFeatures(toml), ['voice', 'media']); +}); + +test('ignores a default key belonging to another table', () => { + const toml = ` +[some-other-table] +default = ["not-a-gate"] + +[features] +default = ["voice"] +`; + assert.deepEqual(parseCoreDefaultFeatures(toml), ['voice']); +}); + +test('parses the shell forwarded list across multiple lines', () => { + const toml = ` +openhuman_core = { path = "../..", package = "openhuman", default-features = false, features = [ + "media", + "voice", +] } +`; + assert.deepEqual(parseShellForwardedFeatures(toml), { + defaultFeatures: false, + features: ['media', 'voice'], + }); +}); + +test('detects when the shell inherits defaults instead of forwarding', () => { + const toml = 'openhuman_core = { path = "../..", package = "openhuman" }\n'; + assert.deepEqual(parseShellForwardedFeatures(toml), { defaultFeatures: true, features: [] }); +}); + +test('comment stripping does not truncate on a # inside a quoted value', () => { + const stripped = stripComments('a = "issue #4901" # trailing comment\n'); + assert.match(stripped, /issue #4901/); + assert.doesNotMatch(stripped, /trailing comment/); +}); + +test('a commented-out gate does not count as forwarded', () => { + const toml = ` +openhuman_core = { path = "../..", package = "openhuman", default-features = false, features = [ + # "voice", + "media", +] } +`; + assert.deepEqual(parseShellForwardedFeatures(toml).features, ['media']); +}); + +// ── drift detection ──────────────────────────────────────────────────────── + +test('passes when every default gate is forwarded', () => { + const result = diffForwarding({ + coreDefaults: ['voice', 'media'], + shell: { defaultFeatures: false, features: ['media', 'voice'] }, + }); + assert.equal(result.ok, true); + assert.deepEqual(result.missing, []); +}); + +test('reproduces #4901: a dropped voice gate is reported missing', () => { + const result = diffForwarding({ + coreDefaults: ['tokenjuice-treesitter', 'voice', 'media'], + shell: { defaultFeatures: false, features: ['media', 'tokenjuice-treesitter'] }, + }); + assert.equal(result.ok, false); + assert.deepEqual(result.missing, ['voice']); +}); + +test('reproduces #4918: a dropped tokenjuice-treesitter gate is reported missing', () => { + const result = diffForwarding({ + coreDefaults: ['tokenjuice-treesitter', 'voice', 'media'], + shell: { defaultFeatures: false, features: ['media', 'voice'] }, + }); + assert.equal(result.ok, false); + assert.deepEqual(result.missing, ['tokenjuice-treesitter']); +}); + +test('a brand new default gate is covered automatically, with no per-gate wiring', () => { + const result = diffForwarding({ + coreDefaults: ['voice', 'media', 'some-future-gate'], + shell: { defaultFeatures: false, features: ['voice', 'media'] }, + }); + assert.equal(result.ok, false); + assert.deepEqual(result.missing, ['some-future-gate']); +}); + +test('an allow-listed gate passes and is reported as intentional', () => { + const result = diffForwarding({ + coreDefaults: ['voice', 'heavy-gate'], + shell: { defaultFeatures: false, features: ['voice'] }, + allowlist: { 'heavy-gate': 'Adds 400MB of models to the bundle.' }, + }); + assert.equal(result.ok, true); + assert.deepEqual(result.allowed, ['heavy-gate']); + assert.deepEqual(result.missing, []); +}); + +test('an allow-list entry for a gate that IS forwarded is flagged as stale', () => { + const result = diffForwarding({ + coreDefaults: ['voice'], + shell: { defaultFeatures: false, features: ['voice'] }, + allowlist: { voice: 'stale entry' }, + }); + assert.equal(result.ok, false); + assert.deepEqual(result.stale, ['voice']); +}); + +test('inheriting defaults needs no forwarding', () => { + const result = diffForwarding({ + coreDefaults: ['voice'], + shell: { defaultFeatures: true, features: [] }, + }); + assert.equal(result.ok, true); +}); + +test('a missing dependency fails rather than passing vacuously', () => { + const result = diffForwarding({ coreDefaults: ['voice'], shell: null }); + assert.equal(result.ok, false); + assert.equal(result.reason, 'dependency-not-found'); +}); + +// ── the real manifests + CLI ─────────────────────────────────────────────── + +test('the checked-in manifests pass the guard', () => { + const out = execFileSync('node', [CHECKER], { encoding: 'utf8' }); + assert.match(out, /every default-ON core gate is forwarded/); +}); + +test('--help exits 0', () => { + const out = execFileSync('node', [CHECKER, '--help'], { encoding: 'utf8' }); + assert.match(out, /Usage:/); +}); + +test('the real shell manifest forwards every real core default', () => { + const coreDefaults = parseCoreDefaultFeatures( + readFileSync(resolve(REPO_ROOT, 'Cargo.toml'), 'utf8') + ); + const shell = parseShellForwardedFeatures( + readFileSync(resolve(REPO_ROOT, 'app/src-tauri/Cargo.toml'), 'utf8') + ); + // Guards the guard: if the parser silently returned nothing, the assertions + // below would pass against empty input and prove nothing. + assert.ok(coreDefaults.length > 0, 'expected to parse at least one core default gate'); + assert.equal(shell.defaultFeatures, false, 'shell is expected to set default-features = false'); + for (const gate of coreDefaults) { + assert.ok( + shell.features.includes(gate), + `core default gate not forwarded to the shell: ${gate}` + ); + } +}); diff --git a/scripts/ci/check-feature-forwarding.mjs b/scripts/ci/check-feature-forwarding.mjs new file mode 100644 index 0000000000..76844cc4fc --- /dev/null +++ b/scripts/ci/check-feature-forwarding.mjs @@ -0,0 +1,80 @@ +#!/usr/bin/env node +// Fails when the desktop shell does not forward a default-ON core Cargo gate. +// +// See scripts/lib/feature-forwarding.mjs for why this exists (#4919). Short +// version: the shell sets `default-features = false` on `openhuman_core`, so +// every default-ON gate must be forwarded by hand. When someone forgets, the +// domain vanishes from the shipped app with no build error — that is how #4901 +// (voice, 56 users) and #4918 (tokenjuice-treesitter) shipped. +// +// Usage: check-feature-forwarding.mjs [core-manifest] [shell-manifest] +import { readFileSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { + diffForwarding, + formatReport, + parseCoreDefaultFeatures, + parseShellForwardedFeatures, +} from '../lib/feature-forwarding.mjs'; + +const REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '../..'); + +/** + * Gates the desktop shell intentionally does NOT forward, mapped to why. + * + * Empty by design: every current default-ON gate belongs in the shipped app. + * Adding an entry is a deliberate product decision, not a way to silence this + * check — the reason string is what a future reader (and reviewer) relies on to + * tell "excluded on purpose" from "forgotten". That ambiguity is exactly what + * let #4918 sit unnoticed since #4123. + */ +const INTENTIONALLY_NOT_FORWARDED = { + // 'some-gate': 'Reason it must not ship in the desktop build.', +}; + +function usage() { + return 'Usage: check-feature-forwarding.mjs [core-manifest] [shell-manifest]'; +} + +const [coreArg, shellArg, extra] = process.argv.slice(2); +if (coreArg === '--help' || coreArg === '-h') { + console.log(usage()); + process.exit(0); +} +if (extra) { + console.error(usage()); + process.exit(2); +} + +const corePath = coreArg ? resolve(coreArg) : resolve(REPO_ROOT, 'Cargo.toml'); +const shellPath = shellArg ? resolve(shellArg) : resolve(REPO_ROOT, 'app/src-tauri/Cargo.toml'); + +let coreToml; +let shellToml; +try { + coreToml = readFileSync(corePath, 'utf8'); + shellToml = readFileSync(shellPath, 'utf8'); +} catch (err) { + console.error(`Could not read manifests: ${err.message}`); + process.exit(2); +} + +const coreDefaults = parseCoreDefaultFeatures(coreToml); +const shell = parseShellForwardedFeatures(shellToml); + +// A parser that silently finds nothing would turn this guard into a rubber +// stamp, which is worse than not having it. Treat "no defaults found" as a +// failure of the check itself rather than a pass. +if (coreDefaults.length === 0) { + console.error( + `FAIL: parsed zero default features from ${corePath}.\n` + + 'Either the manifest changed shape or the parser is broken — refusing to pass vacuously.' + ); + process.exit(2); +} + +const result = diffForwarding({ coreDefaults, shell, allowlist: INTENTIONALLY_NOT_FORWARDED }); +console.log(formatReport(result, { coreDefaults, shell, allowlist: INTENTIONALLY_NOT_FORWARDED })); +process.exit(result.ok ? 0 : 1); diff --git a/scripts/lib/feature-forwarding.mjs b/scripts/lib/feature-forwarding.mjs new file mode 100644 index 0000000000..a105e0646b --- /dev/null +++ b/scripts/lib/feature-forwarding.mjs @@ -0,0 +1,189 @@ +// Detects drift between the core crate's default-ON Cargo gates and the gates +// the Tauri shell forwards to its embedded copy of that crate. +// +// Why this exists (#4919): the shell declares `openhuman_core` with +// `default-features = false`, so it does NOT inherit the core's `default` list. +// Every default-ON gate must be forwarded by hand, and nothing enforced that. +// When the two drift, the domain is compiled out of the shipped desktop app and +// the failure is invisible — no build error, no failing test: +// +// - `voice` shipped missing from v0.58.19 to v0.61.x. Every `openhuman.voice_*` +// RPC answered "unknown method"; 56 users, ~93k Sentry events (#4901). +// - `tokenjuice-treesitter` was never forwarded once since #4123 and failed +// *soft* — AST compression silently degraded to a heuristic (#4918). +// +// Two of three gates were dropped, by two different authors, one of whom knew +// about the trap and documented it in a comment. A comment is documentation, +// not enforcement. +// +// This is a deliberately narrow TOML reader rather than a general parser: it +// only needs two well-known shapes, and the repo has no TOML dependency for +// Node. It is regex/scanner-based in the same spirit as `checklist-parser.mjs`. + +/** + * Strip TOML `#` comments while respecting quoted strings, so a `#` inside a + * value (or an issue number in a comment) can't truncate a real line. + */ +export function stripComments(text) { + const out = []; + for (const line of text.split(/\r?\n/)) { + let quote = null; + let cut = -1; + for (let i = 0; i < line.length; i++) { + const ch = line[i]; + if (quote) { + if (ch === quote && line[i - 1] !== '\\') quote = null; + } else if (ch === '"' || ch === "'") { + quote = ch; + } else if (ch === '#') { + cut = i; + break; + } + } + out.push(cut === -1 ? line : line.slice(0, cut)); + } + return out.join('\n'); +} + +/** + * Read a bracketed array starting at `open` (the index of `[`), returning its + * raw inner text. Scans for the balanced close so multi-line arrays work. + */ +function readArray(text, open) { + let depth = 0; + for (let i = open; i < text.length; i++) { + if (text[i] === '[') depth++; + else if (text[i] === ']') { + depth--; + if (depth === 0) return text.slice(open + 1, i); + } + } + return null; +} + +/** Pull the quoted string items out of a raw TOML array body. */ +function arrayItems(raw) { + if (raw === null) return []; + return [...raw.matchAll(/"([^"]+)"/g)].map(m => m[1]); +} + +/** + * The core crate's default-ON gates: `[features] default = [...]`. + * + * Scoped to the `[features]` table so an unrelated `default = [...]` in another + * table cannot be picked up by mistake. + */ +export function parseCoreDefaultFeatures(coreToml) { + const text = stripComments(coreToml); + // NOTE: `[ \t]` not `\s` — `\s` matches newlines, so `^\s*\[features\]` would + // happily anchor several lines early and slice the section to nothing. + const header = text.match(/^[ \t]*\[features\][ \t]*$/m); + if (!header) return []; + // Bound the search at the next table header so we stay inside [features]. + const rest = text.slice(header.index + header[0].length); + const nextTable = rest.search(/^[ \t]*\[[^[\]]+\][ \t]*$/m); + const section = nextTable === -1 ? rest : rest.slice(0, nextTable); + const defaultAt = section.search(/^[ \t]*default[ \t]*=[ \t]*\[/m); + if (defaultAt === -1) return []; + return arrayItems(readArray(section, section.indexOf('[', defaultAt))); +} + +/** + * What the shell forwards on its `openhuman_core` dependency. + * + * Returns `{ defaultFeatures, features }`. `defaultFeatures: true` means the + * shell inherits the core's defaults and forwarding is moot — there is nothing + * to drift. + */ +export function parseShellForwardedFeatures(shellToml, depName = 'openhuman_core') { + const text = stripComments(shellToml); + // `[ \t]` not `\s`, for the same newline-matching reason as above. + const declAt = text.search(new RegExp(`^[ \\t]*${depName}[ \\t]*=[ \\t]*\\{`, 'm')); + if (declAt === -1) return null; + const braceOpen = text.indexOf('{', declAt); + // Scan to the matching close brace; the inline table spans lines. + let depth = 0; + let braceClose = -1; + for (let i = braceOpen; i < text.length; i++) { + if (text[i] === '{') depth++; + else if (text[i] === '}') { + depth--; + if (depth === 0) { + braceClose = i; + break; + } + } + } + if (braceClose === -1) return null; + const decl = text.slice(braceOpen, braceClose + 1); + const defaultFeatures = !/default-features\s*=\s*false/.test(decl); + const featuresAt = decl.search(/features\s*=\s*\[/); + const features = + featuresAt === -1 ? [] : arrayItems(readArray(decl, decl.indexOf('[', featuresAt))); + return { defaultFeatures, features }; +} + +/** + * Compare the two lists. + * + * `allowlist` maps a gate name to the reason it is intentionally NOT forwarded. + * An intentional exclusion must be explicit and carry a reason, so that + * "deliberately excluded" and "forgotten" stop looking identical — which is the + * ambiguity that let #4918 sit unnoticed. + */ +export function diffForwarding({ coreDefaults, shell, allowlist = {} }) { + if (shell === null) { + return { ok: false, reason: 'dependency-not-found', missing: [], stale: [], allowed: [] }; + } + // Inheriting defaults means there is no forwarding list to drift. + if (shell.defaultFeatures) { + return { ok: true, reason: 'inherits-defaults', missing: [], stale: [], allowed: [] }; + } + const forwarded = new Set(shell.features); + const missing = []; + const allowed = []; + for (const gate of coreDefaults) { + if (forwarded.has(gate)) continue; + if (Object.prototype.hasOwnProperty.call(allowlist, gate)) allowed.push(gate); + else missing.push(gate); + } + // A gate that is allow-listed AND forwarded is a contradiction: the allow-list + // entry is stale and would mask a real drop if the gate were later removed. + const stale = Object.keys(allowlist).filter(gate => forwarded.has(gate)); + return { ok: missing.length === 0 && stale.length === 0, reason: null, missing, stale, allowed }; +} + +export function formatReport(result, { coreDefaults, shell, allowlist = {} }) { + if (result.reason === 'dependency-not-found') { + return 'FAIL: could not find the `openhuman_core` dependency in the shell manifest.\nThe guard cannot verify forwarding — fix the parser or the manifest.'; + } + if (result.reason === 'inherits-defaults') { + return 'OK: the shell inherits the core default features (no `default-features = false`), so no forwarding is required.'; + } + const lines = [ + `Core default gates (${coreDefaults.length}): ${coreDefaults.join(', ') || '(none)'}`, + `Shell forwards (${shell.features.length}): ${shell.features.join(', ') || '(none)'}`, + ]; + for (const gate of result.allowed) { + lines.push(` allowed: ${gate} — ${allowlist[gate]}`); + } + if (result.stale.length > 0) { + lines.push('', 'Stale allow-list entries (gate is forwarded, so the entry is wrong):'); + for (const gate of result.stale) lines.push(` - ${gate}`); + } + if (result.missing.length > 0) { + lines.push('', 'Default-ON core gates NOT forwarded by the desktop shell:'); + for (const gate of result.missing) lines.push(` - ${gate}`); + lines.push( + '', + 'Each of these is compiled OUT of the shipped desktop app, silently.', + 'Fix by adding the gate to the `openhuman_core` features list in', + 'app/src-tauri/Cargo.toml — or, if the exclusion is deliberate, add it to', + 'INTENTIONALLY_NOT_FORWARDED in scripts/ci/check-feature-forwarding.mjs', + 'with a reason. See #4901 (voice) and #4918 (tokenjuice-treesitter).' + ); + } + if (result.ok) + lines.push('', 'OK: every default-ON core gate is forwarded to the desktop shell.'); + return lines.join('\n'); +}