feat: transcription v1 — cloud STT (Groq / OpenAI / OpenAI-compatible) end-to-end - #184
Merged
Conversation
Transcription v1 introduces a new adapter crate at crates/transcribe; this slice's commits land under feat(transcribe): per the per-slice scope discipline.
User policy: CLAUDE.md and docs/ are LOCAL-ONLY artifacts. Both files have been repeatedly re-tracked by autonomous agents 'helpfully' re-introducing the !CLAUDE.md and !docs/superpowers/**/*.md exception lines. This commit removes the exceptions, untracks both via git rm --cached (files stay on disk for local agent state), and adds a prominent comment block at the top of .gitignore telling future agents NOT to re-enable the tracking. Files remain on disk for local session continuity. Cloud agents (if any future session uses them) will need to receive plan/spec content via the prompt or a non-tracked-file mechanism, not by reading the repo.
Adds the sync-trait STT port that all adapters (cloud HTTP, future local whisper.cpp, future plugin sidecars) implement. Mirrors the existing MetadataExtractor pattern: &self, Send + Sync, object-safe, returns Result<_, CoreError>. Types: BackendId, TranscriptSegment, TranscriptionResult, TranscribeRequest, TranscriptionProgress, TranscriptionError. CoreError::Transcription wraps the error enum with #[from] for ?-propagation from adapter sites. tokio-util added to perima-core deps for CancellationToken; the sync module is unconditionally available in tokio-util 0.7 (no feature gate). TranscribeRequest uses a manual Debug impl because Arc<dyn Fn(...)> is not Debug (opaque callback elided from debug output). Spec: docs/superpowers/specs/2026-05-02-transcription-v1-design.md
…yn Fn> Code-quality review for T1 surfaced 5 polish items, all in crates/core/src/transcription.rs: - transcription_error_auth_serializes_without_data: assert data key absent (negative case) so a future Auth-with-payload edit doesn't slip past silently. - transcript_segment_round_trip_uuid_v7: assert all 5 fields round-trip, not just id + text. - on_progress field doc: clarify Arc-shared-on-clone semantics. - on_progress field WHY comment: document Arc<dyn Fn> over channel/Box trade-off (alternatives-discipline). - TranscriptionError doc: trim clippy-lint editorialising out of rustdoc, move to a // WHY comment near the derive.
- Refinery V012: transcript + transcript_segment tables, transcript_search
FTS5 virtual table (unicode61 remove_diacritics 2 tokenizer for
multilingual diacritic-insensitive matching), and a non-FTS5 cascade
trigger transcript_segment_after_transcript_update_cascade that
propagates soft-delete + restore from a transcript header to its
segments. Both arms present (V007->V008 bug class avoided). PKs are
TEXT UUIDv7 matching V001..V011 practice. FK to file_uuid (immutable
surrogate per V011), NOT blake3_hash (mutable).
- FTS5 maintenance triggers via Batch F codegen: 3 new BodyKind variants
(TranscriptSegmentAfterInsert/Delete/Update), 3 new FTS_AGGREGATIONS
entries (15 -> 18 total), 3 new minijinja macros, 3 new dispatch arms
in body_kind_name. AFTER UPDATE macro covers soft-delete + restore +
text-changed arms via inlined SELECT...WHERE gates. Snapshot accepted
via cargo insta accept (fts_transcript_segment.snap). Existing snapshot
tests + the 4 sibling fts_* tests stay green.
- WriteCmd::Transcript(TranscriptWriteCmd::Insert {...}) sub-enum follows
the established sub-enum pattern (Volume(VolumeWriteCmd) etc.); cmd
carries transcript header + Vec<segments> + device + optional
CancellationToken + reply: ReplyTx<TranscriptId>. Handler signature
pub(super) fn handle(conn: &mut Connection, cmd, bus) mirrors
writer/metadata.rs::handle.
- Atomic transaction with BEGIN IMMEDIATE inside the inner helper; one
HLC value per command (Hlc::now().pack()) stamped on every row;
cancel-aware between BEGIN and each per-segment INSERT (closes the
cancel-after-adapter-success race). Empty AppEvent payload uses
IndexInvalidated::SearchIndexRebuilt today; T5 will introduce the
dedicated TranscriptionCompleted variant.
- SqliteTranscriptRepository (writer-sender + read-pool) cheap-clone
shape mirrors sibling adapters. Read methods deferred to T7 when the
Tauri commands need them.
- tokio-util bumped from dev-dep to prod-dep on perima-db (cmd.rs needs
CancellationToken in the variant).
- Integration tests verify atomicity (transcript header + 3 segments +
FTS5 query reachability) and pre-fired-cancel rollback (no rows leak).
- 64-case proptest mirrors search_proptests shape: 7-op universe
(insert/soft-delete/restore segment, soft-delete/restore transcript
with cascade, edit-text, no-op) over 2 transcripts; per-op assertion
that transcript_search match-reachable rows equal the live-state
ground truth. ~6s wall-clock per run.
- fts_install_idempotent extended to glob transcript_search_after_*
triggers in addition to sc_*/search_after_* (count assertion is
18 post-bump).
…t cap, doc trims) Code-quality review surfaced 2 important + 4 minor items, all in crates/db files touched by T2: - Cargo.toml: drop duplicate tokio-util.workspace dev-dep (prod dep is automatically visible to integration tests + benches); update the WHY block to mention only tokio. - transcript_proptests.rs: drop cases cap from 64 to 32 to match the soft-delete-churn sibling (per-case work is comparable shape, not the lighter tag-churn shape with the 64-cap). - writer/transcript.rs: compress 9-step transaction module-doc into one shape sentence; trim Drop-rollback comment that overstated 'sibling-handler convention'. - transcript_repo.rs: WHY comment on TranscriptSegmentRow.id reusing TranscriptId (not a separate TranscriptSegmentId). - writer_transcript.rs: docstring on the atomicity test so readers know what's covered (happy-path commit + FTS trigger fire) vs what the cancel-rollback test + proptest cover (atomicity-under-fault).
- New crates/transcribe with lib.rs + audio.rs + providers.rs +
registry.rs + audio_pipeline integration tests.
- AudioPipeline trait + FfmpegAudioPipeline<I: FfmpegInvoker> generic
over the invoker (DesktopFfmpegInvoker via app.path().resolve;
CliFfmpegInvoker via which::which). FfmpegChild Drop kills the child.
- KNOWN_PROVIDERS preset table: groq, openai, custom. AuthScheme enum:
Bearer, XApiKey { header }, None.
- TranscriberRegistry skeleton with register / set_active / active / get.
- Workspace deps added: async-openai 0.36 (audio feature; plan spec'd
0.32 — bumped to latest stable), which 8, keyring 3.6 (plan spec'd
3.6 — kept; 4.0 dropped compile-time features so held back), dialoguer
0.12, wiremock 0.6 (plan spec'd 0.7 — 0.7 does not exist on crates.io),
hound 3.5, toml 1 (plan spec'd 0.10 — bumped to latest stable),
tauri-plugin-shell 2, ffmpeg-sidecar 2.
- 3/3 mock-ffmpeg tests pass: canonical-flags, non-zero-exit, cancel-aborts.
…er preset notes, const fn) Spec + code-quality reviews surfaced 1 actionable + 2 important + 4 minor: - tests/audio_pipeline.rs: gate on #![cfg(unix)] — mocks spawn POSIX coreutils (true/false/sleep). Windows port deferred to ASR v2. - tests/audio_pipeline.rs: MockFfmpeg::new const fn (clippy --all-targets). - src/audio.rs: WHY block on Arc<I> (shared invoker across pipeline instances; per-job + future health-check probe). - src/providers.rs: WHY block on KNOWN_PROVIDERS["custom"] empty fields (template — T5 config loader overlays user values). - src/providers.rs: 'how to add a new provider' note on KNOWN_PROVIDERS doc — insert before custom; find_preset is O(N), fine for N=3-10.
Single Rust adapter covers all providers serving the OpenAI /v1/audio/transcriptions wire format (Groq, OpenAI, faster-whisper-server, vLLM, Whisperfile, OpenWebUI, vox-box, Azure-via-LiteLLM). Wraps async-openai 0.36 with configurable base_url + auth_scheme + model + file_size_limit_bytes from the ProviderPreset table. Sync Transcriber::transcribe bridges to the async API via tokio::task::block_in_place + Handle::block_on. The constructor verifies the supplied tokio runtime handle is MultiThread and panics loudly otherwise (block_in_place would deadlock on current_thread). Pre-remux via injected Arc<dyn AudioPipeline> when the input file exceeds the backend's size limit OR has a video MIME. The NamedTempFile returned by the pipeline is held in a binding for the duration of the upload so its Drop fires on both success and failure paths (avoids the plan draft's mem::forget + manual remove_file leak-on-panic hazard). map_async_openai_error covers every concrete OpenAIError variant in 0.36 (no wildcard arm) and maps the OpenAI-style ApiError.code values to the five spec'd TranscriptionError discriminants: Auth, RateLimited, QuotaExceeded, ModelNotFound, BackendUnavailable. Two helper unit tests in the production module pin the size-heuristic + the unknown-code arm. Tests: - crates/transcribe/tests/error_mapping.rs: 7 unit tests over map_async_openai_error covering each discriminant + sibling codes. - crates/transcribe/tests/openai_compat.rs: 3 wiremock-backed integration tests (happy path with synthesized 100ms WAV via hound, 401 -> Auth end-to-end, 200 + non-JSON body -> Internal). End-to-end 5xx is intentionally NOT tested because async-openai's default ExponentialBackoff retries server errors for ~15 min wall-clock; the unit test in error_mapping.rs covers that mapping path against the typed ApiError shape. API drift adaptations from plan (async-openai 0.32 -> 0.36): - audio().transcribe_verbose_json(req) became audio().transcription().create_verbose_json(req). - AudioInput auto-converts From<P: AsRef<Path>>; builder .file(path) works via setter(into). - TranscriptionSegment fields are f32 (not f64); cast lints suppressed with WHY-block. - OpenAIError::StreamError is Box<StreamError>, not String. - AuthScheme::XApiKey custom-header support is deferred (no first-class knob on OpenAIConfig in 0.36); falls back to Bearer for v1.
…eserve Cancelled signal) Spec reviewer surfaced 1 substantive + 1 noteworthy: - AuthScheme::XApiKey was deferred-to-Bearer with a WHY-block claiming async-openai 0.36 had no custom-header API. That claim was wrong: OpenAIConfig::with_header(K, V) -> Result<Self, OpenAIError> exists in 0.36.1 (config.rs:168). Now properly emits the custom header for Azure / LiteLLM / similar providers; with_header errors map to TranscriptionError::Internal with provider context. - AudioError::Cancelled was silently folded into TranscriptionError:: AudioDecode by the remux call site's blanket .map_err(|e| AudioDecode(e)), losing the cancel signal end-to-end. Now pre-matches Cancelled before the catch-all so the UI sees a real Cancelled when the user cancels during remux.
… marker, doc trim, dedupe tests) Code-quality reviewer surfaced 1 important + 5 minor: - do_transcribe -> transcribe_inner: more discoverable. T5 wiring will grep for transcribe to find the use-case integration point. - Uuid::nil() segment placeholder gains TODO(T5): marker so the use-case implementer finds it on grep. - map_async_openai_error doc Snapshot-tested -> Covered by ... (variant + field assertions, not insta snapshots) - the codebase uses insta elsewhere; the overloaded word was misleading. - Delete the in-module #[cfg(test)] mod tests block. The unique invalid_argument_with_size_maps_to_file_too_large test moved to tests/error_mapping.rs alongside the other 7 mapping cases. The duplicated unknown_code test was already present at line 83. Net: 9 mapping tests in tests/error_mapping.rs (was 7 + 2 in-module duplicates). Single source of truth, no in-module/integration drift.
- 5 new AppEvent variants (Started, Progress, Completed, Cancelled, Failed).
Failed carries the full TranscriptionError to preserve discriminant
payloads (e.g. RateLimited.retry_after_secs).
- TranscriptionUseCase owns a flume::bounded(32) queue + single tokio
worker. execute() returns Started { request_uuid, queue_position } or
Cancelled. Queue full -> TranscriptionError::QueueFull (typed).
- WriteCmd::Transcript::Insert gains a request_uuid field; writer handler
emits AppEvent::TranscriptionCompleted with the use-case's request UUID.
- crates/app/src/config.rs flat file -> config/ directory module with
resolve_data_dir + new transcription.rs (TOML parser + saver). toml dep
added to crates/app (NOT crates/core; core stays framework-free).
- AppContainer wires the registry from TranscriptionConfig::load + keyring
entries, instantiates one OpenAICompatibleTranscriber per provider.
BackendId pinned to format!("{name}:{model}").
Reviewer flagged that AppEvent::TranscriptionStarted.queue_size was hardcoded to 1 in process_one despite the field docstring promising "current queue size including this job". Worker now snapshots rx.len() at dequeue and passes it as a parameter to process_one, so the emitted queue_size matches the live observation (jobs still queued behind this one + this job).
Adds two top-level CLI subcommands, completing the user-visible surface
for the v0.6.x transcription slice (T6):
- `perima transcribe <PATH>` — runs a transcription via the active
provider, prints transcript text to stdout (or `--output FILE`).
Streams per-segment progress to stderr. Exit codes: 0 success,
2 auth/quota, 3 queue full, 130 cancelled, 1 otherwise.
- `perima auth {set,delete,has,list} [PROVIDER]` — manages keyring
entries under service `perima.transcription`. `set` accepts hidden
TTY input (dialoguer) or piped stdin; `delete` is idempotent;
`list` reads `[transcription.providers.*]` from `config.toml` and
marks providers with stored keys.
Wiring + plumbing changes the subcommands required:
- AppContainer::new_with_bus(deps, handlers, bus) — overload that
accepts a pre-built `Bus`. The CLI shell now constructs the Bus
before SqliteWriter::start so writer-side `AppEvent::Transcription
Completed` (post-COMMIT) reaches handlers registered on the same
bus the use-cases publish to. The single-construction-site
invariant survives — the bus is built in exactly one place per
process, just moves into the shell.
- Transcription wiring (build_transcription_use_case): overlays the
`entry.base_url` and `entry.<provider-name>` onto the static
ProviderPreset via Box::leak, fixing `preset = "custom"` (which
ships an empty base_url) and resolving the BackendId-mismatch
bug when the user's provider name diverges from the preset name.
- PERIMA_TEST_API_KEY_<provider> env-var seam — short-circuits the
keyring lookup in tests where `keyring::mock` cannot cross a
subprocess boundary. Production users never set it.
- PERIMA_KEYRING_MOCK env switch in main() — flips the keyring
builder to `keyring::mock::default_credential_builder()` so the
auth subcommand tests don't pollute the developer's real OS
keyring during `cargo nextest run`.
Tests:
- crates/cli/tests/transcribe_test.rs — wiremock + hound-synthesised
WAV drives `transcribe` end-to-end (happy path: text on stdout;
401 → exit 2; clap rejects bad BCP-47), plus auth subcommand
smoke tests (`has` exits 1 missing, `delete` idempotent, `set`
reads piped stdin, `list` empty-config marker).
- Unit tests in cmd::transcribe::tests for BCP-47 validator + exit
code mapping.
New runtime deps: dialoguer, keyring, regex, async-broadcast, flume,
rusqlite (read-only) on perima CLI; hound + wiremock on dev-deps.
New workspace dep: regex 1.
WHY no T7 read-API call: the SqliteTranscriptRepository read-side is
empty until T7 lands. The CLI opens a short-lived rusqlite read-only
connection to `SELECT text FROM transcript_segment` after the writer
emits Completed. Marked as a stop-gap in the WHY comment.
… dedup - Module docstring claimed `\r`-overwriting status updates but the handler uses writeln! (newline-terminated). Trimmed claim to match shipped behaviour + noted v1 cloud adapters emit Started/Finished only, so per-segment overwrite would be wasted complexity. - WHY block on u32::try_from(i64).unwrap_or(u32::MAX) said "map to a typed Internal error" but code saturates silently. Rewrote to describe the actual saturation behaviour + UX rationale (49-day ms-fits-in-u32 envelope). - Promoted perima_app::container::KEYRING_SERVICE to pub + re-export from perima_app crate root. CLI auth subcommand now imports it instead of duplicating the literal. Drift impossible at compile time. - Replaced trivial keyring_entry_constructs_with_canonical_service unit test with keyring_service_name_matches_app_const (asserts the shared invariant rather than re-exercising the keyring crate). - Renamed auth_set_then_has_then_delete_is_idempotent test to auth_set_succeeds_and_delete_is_idempotent — the `has` step it claimed was deliberately skipped (mock keyring is process-local). - Added one-line WHY above the `let _ = self.tx.send(...)` cluster in TerminalEventHandler::handle.
…iring Adds eight new `#[tauri::command]` handlers to the desktop shell — wiring the transcription use-case + per-provider keyring management through the typed CoreError IPC contract: transcribe, cancel_transcription, set_provider_key, delete_provider_key, has_provider_key, list_providers, update_transcription_config, get_transcription_config Adopts `tauri-plugin-shell::init()` (BEFORE `.setup`) to expose the `app.path().resolve(.., BaseDirectory::Resource)` API used by a layered ffmpeg sidecar discovery: bundled-sidecar → system PATH → deferred-error `MissingFfmpegPipeline`. T12 will land the bundled binaries; until then desktop transcription falls back to system ffmpeg gracefully. Switches `build_container` from `AppContainer::new` → `new_with_bus` so the writer-emitted `AppEvent::TranscriptionCompleted` (post-COMMIT) reaches the same `TauriEventHandler` that use-case-emitted events flow through — no event reaches the frontend without it. Wire-types added to `crates/desktop/src/payloads.rs` (specta-derived): `TranscribeStartedPayload`, `ListProvidersPayload`, `ProviderListEntry`. `TranscriptionConfig` + `ProviderEntry` get specta::Type behind the `specta` feature in `crates/app/src/config/transcription.rs` for direct IPC use without a shell-side mirror. `apps/desktop/src/bindings.ts` regenerated (hand-extended; the export runs inside `lib.rs::run()` which `cargo build` doesn't trigger). The bindings_compile integration test now covers the new commands and asserts all five new types appear in the generated TS. `update_transcription_config` v1 = save + warn-log "restart required" (hot reload deferred — would require swapping the Arc<TranscriptionUseCase> field on the AppContainer mid-flight).
- BLOCKING: ffmpeg sidecar resolution used std::env::consts::ARCH
("x86_64") where Tauri's externalBin rewrites to a full target
triple ("x86_64-unknown-linux-gnu"). The bundled-sidecar branch
would never have matched in a release build; the system-PATH
fallback masked the bug locally but defeated the entire T12
bundling story. Forward cargo's TARGET env var via build.rs +
read with env!("TARGET") so the resource path matches what
Tauri actually emits.
- IMPORTANT: list_providers swallowed provider_keyring_entry
construction errors as has_key=false. Added tracing::warn! on
the error branch so a system-keyring outage is diagnosable
without piecing together other commands' error paths.
- NIT: documented the sync-I/O-on-Tauri-worker-thread tradeoff
in update_transcription_config (sub-ms TOML write makes
spawn_blocking dispatch overhead unjustified).
…domain events Wires the React desktop frontend to the 5 AppEvent::Transcription* variants and the 8 transcription Tauri commands shipped in T7. WHY: components landing in T9–T11 (TranscribeButton, TranscribeSettingsModal, TranscriptionPill) need a single source of truth for in-flight job state and typed wrappers around the IPC surface. Centralising the slice + invalidations here keeps the per-component code thin. Changes: - bindings.ts: hand-extend AppEvent with 5 Transcription* arms (channel-only, bypass tauri-specta) and CoreError::Transcription wrapping the new TranscriptionError discriminated union (11 variants, mirrors crates/core/src/transcription.rs::TranscriptionError). - stores/ui.ts: add TranscriptionSlice with jobs: Record<request_uuid, Job>, startJob / updateJob / removeJob actions; updateJob + removeJob no-op on missing keys to defuse the auto-remove-vs-user-dismiss race. - queries/transcripts.ts: NEW. transcriptsKeys + queryOptions factory + useTranscriptsByFileUuid hook with select: callback returning the latest by completed_at DESC. queryFn rejects with CoreError::Unsupported until the list_transcripts_by_file_uuid Tauri command lands (T9 follow-up); cache key + invalidation surface are wired today. - api.ts: add 8 wrappers (transcribe / cancelTranscription / setProviderKey / deleteProviderKey / hasProviderKey / listProviders / updateTranscriptionConfig / getTranscriptionConfig); extend KNOWN_KINDS with "Transcription". - lib/coreError.ts: extend coreErrorMessage with the inner-TranscriptionError switch; exhaustive-never default catches future variant adds. - components/StatusBar.tsx::errorKindLabel: add "Transcription" arm so the exhaustive-never check still compiles after the bindings extension. - hooks/useDomainEvents.ts: 5 new switch arms. Started → startJob (running) + invalidate per-file transcripts. Progress → updateJob (running). Completed → updateJob (completed) + invalidate per-file + search; auto-remove after 5s grace via tracked-timers Set cleared on unmount. Cancelled → updateJob + 3s auto-remove. Failed → updateJob + notifyError (typed); no auto-remove (user dismisses). Tests: - __tests__/transcription-slice.test.ts: NEW. 9 tests covering startJob / updateJob / removeJob; multiple-job coexistence; missing-key no-ops; discriminated-union round-trip. - __tests__/hooks/useDomainEvents.test.tsx: extended with 6 new tests for the 5 transcription arms + an unmount-clears-timers regression guard. - __tests__/test-utils.tsx::resetUiStore: zero the transcription jobs map while keeping the slice's action closures intact across tests. Verification: - bun test → 19 files / 135 passed (was 12 files / 112 passed pre-T8). - bun run build → tsc -b clean, vite build clean. - bun run lint → eslint clean (--max-warnings 0). - cargo doc --workspace --no-deps → clean. - cargo nextest run -p perima-desktop --test bindings_compile → passed (specta-emitted bindings still consistent with hand-extended channel types).
Adds per-file transcription action with 3 visual states (idle / running+queued / terminal). Slots the button + Settings gear stub into FileSidebar adjacent to the hash section. 7 component tests cover all status branches, the cancel path, and the notifyError error path.
…eWithMetadataPayload The desktop transcribe button passed `file.relative_path` to the backend, which forwarded it untouched to ffmpeg. ffmpeg resolves relative paths against the desktop process cwd, so end-to-end transcription from the UI silently ENOENT'd in any normal install where the volume root != cwd. Fix: extend the read-side query that powers `list_files_with_metadata` + `list_files_with_tags` to also surface `mount_path` from a deduplicated subquery on `volume_mounts`, plumb it through the `FileWithMetadataRow` tuple alias + `FileWithTags` struct, and have the desktop `FileWithMetadataPayload::From` impl materialise an `absolute_path` string by joining `mount_path` with `relative_path` via `PathBuf::push` (Windows-correct separators). The frontend TranscribeButton now takes `source: string | null` and disables itself with a "Volume not mounted" tooltip when `absolute_path === null`. Decision: extended the `FileWithMetadataRow` type alias to a 4-tuple `(loc, meta, quick_hash, mount_path)` rather than adding `mount_path` to `FileLocationRecord`. WHY: mount paths are device-local and have no home in the core domain (volumes are abstract identifiers there); the tuple-element approach contains the change to the read path without forcing every consumer of `FileLocationRecord` to reason about a device-local field that's almost always None for them.
New modal: provider name + preset (Groq/OpenAI/Custom) + model + base_url/auth_scheme (custom only) + write-only API key + set-as-active checkbox. Save flow: setProviderKey (if key non-empty) then updateTranscriptionConfig, mid-save locks Cancel/ESC/backdrop. On success: invalidate providers query + onClose. FileSidebar gear icon onClick wired from TODO stub to useState(settingsOpen) → <TranscribeSettingsModal />. 8 component tests (TDD): open/closed render, happy-path save, empty-key skip, mid-save disable, Auth error banner, ESC-during-save no-op, custom preset field visibility.
CLAUDE.md bans emojis in code/UI unless explicitly requested. Replace the providers-list "key set" indicator (🔑) with " (key set)" text. No tests assert on the glyph, so this is a one-line user-facing text change.
Two IMPORTANT data-integrity issues from code-quality review:
1. Save could wipe existing providers when config query had not yet
resolved. The mutationFn used `configQuery.data ?? { providers: {} }`,
so submitting before the query settled would spread an empty map and
drop every other configured provider. Now the mutationFn throws a
typed CoreError::Internal banner if data === undefined, AND the Save
button is gated on `configQuery.isSuccess`. Belt + braces.
2. Empty (or whitespace-only) provider name would have written
`providers[""] = entry`, producing a corrupt config row that the
loader can't address. Save button now disabled when
`form.name.trim() === ""` with a tooltip explaining why.
Test additions:
- Centralised `waitForSaveEnabled()` helper so the existing 5 Save-clicking
tests wait for the configQuery + name gates to clear before clicking.
- New test #9 asserts: empty name → Save disabled; whitespace-only name
→ Save disabled (trim guard); real name → Save enabled.
…eslint disable Two fix-up items rolled into one commit (e9a2204 → cumulative T10 polish): 1. Missing `CoreError` type import in bindings (TS2304 build break). 2. `@typescript-eslint/only-throw-error` rule still flags `throw err` even when `err: CoreError` is assigned first (rule does static analysis on the throw expression, not the assigned-type chain). Restored the eslint-disable-next-line directive matching the two pre-existing throws at lines 154 + 191. bun run build + bun run lint now both clean.
Adds a read-only StatusBar pill that surfaces in-flight transcription jobs from the Zustand slice. Pill hidden when empty; click opens an absolute- positioned popover listing each job with status badge, Cancel button for queued/running, and a title tooltip for failed jobs. Closes on ESC or outside click. 10 vitest tests covering all status variants, sort order, ESC close, and empty-slice auto-hide.
Wire up Tauri's externalBin sidecar slot for ffmpeg so a `bun run tauri build` produces a Linux desktop bundle that includes the binary at the path Tauri's externalBin rewriter emits (`crates/desktop/binaries/ffmpeg-x86_64-unknown-linux-gnu`). WHY a fetch script + just recipe + CI step (not committed binary): the static ffmpeg build is ~80 MB and platform-specific. Committing it bloats the repo and forces every clone to download macOS+Windows variants nobody on Linux dev needs. The script (Linux: johnvansickle static build via curl; macOS+Windows: zero-byte stub) is idempotent + Swatinem-cache-friendly, runs once per CI runner before any cargo invocation that touches perima-desktop. WHY before `just ci` (not after): tauri-build validates externalBin paths during EVERY cargo compile of perima-desktop (issue tauri-apps/tauri#14602 — not just `tauri build`). Without the file on disk, `cargo {build,clippy,nextest} -p perima-desktop` aborts with "resource path doesn't exist". Same fix needed in mutants.yml's baseline-build pass (exclude_globs filters mutation generation, not baseline compile). WHY a placeholder stub on macOS+Windows (not skip externalBin): the externalBin declaration is single-source-of-truth; per-platform conditionals would split tauri.conf.json. The stubs let cross-platform CI green; runtime invocation surfaces a clear BinaryNotFound from the audio pipeline. macOS + Windows real fetchers tracked as T12 follow-ups (filed at plan-merge). WHY shell:allow-execute scoped to `name: "binaries/ffmpeg"` (not "*"): least-privilege per Tauri 2 capability spec. The runtime cannot spawn arbitrary shell commands — only the bundled ffmpeg sidecar. Verification: - cargo build -p perima-desktop -j 2: PASS (1m 30s with sidecar fetched; aborts with "resource path doesn't exist" without). - just bindings (cargo build --features specta-export + git diff apps/desktop/src/bindings.ts): PASS (no drift). - cargo clippy -p perima-desktop -j 2 -- -D warnings: PASS. - cargo doc --workspace --no-deps: PASS. - JSON syntax (jq): tauri.conf.json + capabilities/default.json OK. - YAML parse (python yaml): ci.yml + mutants.yml OK. - resolve_audio_pipeline (T7) unchanged: bundled-first path matches the new `binaries/ffmpeg-{target-triple}` naming convention. Files: - .github/workflows/ci.yml: `just sidecar` step before `just ci` + bindings-drift job. - .github/workflows/mutants.yml: same fetch step (baseline compile needs the binary even though desktop is excluded from mutation). - .gitignore: ignore `crates/desktop/binaries/*` except `.gitkeep`. - crates/desktop/binaries/.gitkeep: placeholder so the dir exists. - crates/desktop/capabilities/default.json: shell:allow-execute permission scoped to the sidecar. - crates/desktop/gen/schemas/capabilities.json: regenerated by tauri-build to reflect the new permission. - crates/desktop/tauri.conf.json: bundle.externalBin entry. - justfile: `just sidecar` recipe. - scripts/fetch-ffmpeg-sidecar.sh: per-OS fetch logic. Follow-ups (filed at plan-merge): - macOS ffmpeg sidecar fetch (evermeet.cx static build). - Windows ffmpeg sidecar fetch (BtbN GitHub releases).
…ignore 2 unmaintained-crate advisories Pre-push hook ran cargo-deny + caught: 1. error[wildcard] on perima-transcribe — every other intra-workspace crate (perima-app, perima-db, perima-desktop) declares `publish = false` to opt out of the wildcard-path-dep check (wildcard paths only fail for crates that COULD be published to crates.io). Added the same line to perima-transcribe. 2. error[unmaintained] for `backoff` (RUSTSEC-2025-0012) and `instant` (RUSTSEC-2024-0384). Both transitive via async-openai 0.36's default ExponentialBackoff retry policy. Upstream replacements exist (`backon` / `web-time`) but async-openai hasn't migrated. Added to deny.toml's ignore list with cross-reference to GH #178 (which already tracks disabling async-openai's retry logic for transcription). Local cargo deny check now: advisories ok, bans ok, licenses ok, sources ok.
The advisory DB moved on since this branch's last commit; pre-push cargo-deny flagged three new vulnerabilities. All three resolve with semver-compatible lockfile bumps — no source changes, no new ignores. - RUSTSEC-2026-0204 (crossbeam-epoch 0.9.18 -> 0.9.20): invalid pointer dereference in the `fmt::Pointer` impl for `Atomic`/`Shared` when the underlying pointer is null. Reached via rayon-core (blake3, image, criterion). - RUSTSEC-2026-0194 + RUSTSEC-2026-0195 (quick-xml 0.38.4 -> 0.41.0): quadratic duplicate-attribute check and unbounded namespace-declaration allocation, both memory-exhaustion DoS vectors. quick-xml was pinned by plist 1.8.0, so plist 1.8.0 -> 1.10.0 comes along to unpin it. WHY low-impact here regardless: quick-xml is target-gated to macOS (Tauri's build-time Info.plist handling) and never compiles on Linux — `cargo tree -i quick-xml --workspace` returns nothing. It also only ever parses our own Info.plist, never untrusted input. Bumped anyway because the fix is free. `cargo deny check` now: advisories ok, bans ok, licenses ok, sources ok.
rust-toolchain.toml pins `channel = "stable"`, so the toolchain floated
forward while this branch was open and clippy tightened. Pre-push caught
five failures, all in code that was clean when T5/T6 landed:
- `field_reassign_with_default` (crates/app/src/config/transcription.rs):
build `TranscriptionConfig` with struct-update syntax instead of
`default()`-then-assign.
- `match_wildcard_for_single_variants` x3 (crates/app/tests/
transcription_test.rs): `TranscribeOutput` has exactly two variants, so
the `other =>` catch-all covered precisely one. Bound the concrete
variant with `other @ TranscribeOutput::Cancelled { .. }` — keeps the
`{other:?}` panic message and makes a future third variant a compile
error rather than a silently-swallowed test pass.
- `doc_markdown` x2: backticked here; the lint itself is disabled in a
follow-up commit.
Verified: `cargo clippy --workspace --all-targets -- -D warnings` clean;
`cargo nextest run -p perima-app -p perima` 137/137 pass.
`doc_markdown` (from the pedantic group) demands backticks around any mixed-case token it mistakes for a code identifier — "OpenAI", "JavaScript", "GitHub", vendor and product names. It fires on ordinary English prose in doc comments, every fire is a pure-churn edit with zero correctness value, and clippy's default `doc-valid-idents` allowlist does not cover the proper nouns this codebase actually uses (the transcribe crate alone references OpenAI constantly). Follows the existing precedent of `module_name_repetitions = "allow"` — opt out of one pedantic lint rather than dropping the whole group. Doc quality stays enforced by the checks that catch reader-visible breakage: `rustdoc::broken_intra_doc_links = "deny"`, `private_intra_doc_links = "deny"`, and `missing_docs = "deny"`. Verified: `cargo clippy --workspace --all-targets -- -D warnings` clean.
CI failed on ubuntu-latest AND macos-latest at the "Fetch ffmpeg sidecar" step. `just sidecar` invokes `./scripts/fetch-ffmpeg-sidecar.sh` directly, and the file was committed as mode 100644 — permission denied on both runners before a single byte was downloaded. WHY this survived local verification: this working tree lives on a mount that reports every file as `-rwxrwxrwx`, and the repo sets `core.fileMode = false`, so git ignores on-disk mode bits entirely. The script runs fine locally and the pre-push hook (which never executes the CI workflow) has nothing to catch. The mode is only observable via `git ls-files -s` — `scripts/no-cargo-test.sh` was already 100755, which is why the lefthook `./scripts/no-cargo-test.sh` invocation always worked and this one never did. Set via `git update-index --chmod=+x` (an on-disk chmod is a no-op under `core.fileMode = false`). Also fixes `scripts/test-precommit-hook.sh`, same class — not currently invoked by CI or lefthook, but it is a `./`-executable dev helper and would fail identically the first time someone ran it. Verified: `git ls-files -s scripts/` now reports 100755 for all three.
The bindings-drift job failed while all three matrix jobs passed, at the
same "Fetch ffmpeg sidecar" step:
Downloading ffmpeg static build from https://johnvansickle.com/...
Extracting ...
xz: (stdin): File format not recognized
tar: Child returned status 1
Timestamps show the 41 MB "download" completing in 0.3 seconds, so the
body was never the tarball.
WHY curl could not catch it: johnvansickle.com throttles bursts from a
single GitHub Actions egress range by serving a short HTML notice with
HTTP status 200 — not a 4xx/5xx. Both `--fail` and `--retry` key off the
status code, so curl reports success and hands tar a truncated file. The
resulting error names xz, which reads like archive corruption and points
the reader at entirely the wrong layer. CI fetches this tarball 4× per
run (3-platform matrix + bindings-drift), so later jobs get throttled
while earlier ones succeed — exactly the observed pattern.
Fix: validate the payload ourselves and treat a too-small body as a
retryable condition, in a loop around download-plus-validation with
linear backoff (10/20/30/40s, 5 attempts). A `curl --retry` alone cannot
help here because curl never sees an error.
On give-up the message says rate-limiting and dumps the first 200 bytes
of the response, so the next person reads the real cause instead of an
xz complaint.
Cross-ref #183, which tracks the durable fix (sha256 pin + mirror /
composite-action consolidation).
Verified: full live run against a throwaway repo root fetches 41,888,096
bytes and installs a 79,826,272-byte binary, byte-size-identical to the
existing local sidecar. `bash -n` clean.
Retrying was not enough. Within one hour on 2026-08-01,
johnvansickle.com escalated from throttling to refusing this runner
outright:
1. First: a rate-limit notice served with HTTP 200 (caught by the size
gate added in the previous commit).
2. Then: connection timeouts —
`curl: (28) Failed to connect to johnvansickle.com port 443 after
30002 ms` — 0 bytes, five attempts, all five dead.
No retry policy fixes a host that has stopped accepting the connection.
The defect is depending on one small third-party host for a file CI
fetches 4x per run (3-platform matrix + bindings-drift).
Adds BtbN/FFmpeg-Builds on GitHub Releases as a fallback mirror. It is
served from GitHub's own CDN — the one host an Actions runner can always
reach — and it is a GPL static build exactly like johnvansickle's, so
nothing about the licensing of the bundled binary changes. johnvansickle
stays primary because it is 41 MB against BtbN's 127 MB.
Two details the mirror forced:
- `find -maxdepth 4` (was 2). The layouts differ: johnvansickle unpacks
to `<dir>/ffmpeg`, BtbN to `<dir>/bin/ffmpeg`. A depth-2 search finds
nothing on BtbN and fails with the unhelpful "could not find ffmpeg
binary in extracted tarball".
- `if (( fetched )); then break; fi` rather than `(( fetched )) && break`.
Under `set -e`, an AND-list whose first command fails takes the script
down — with `fetched=0` the terse form would abort before ever trying
the second mirror, which is precisely the case this commit exists to
handle.
Verified end-to-end with the primary URL forced to a bogus host: 3
attempts exhausted, fell through to BtbN, downloaded 127,602,232 bytes,
extracted the nested binary, installed it, and
`ffmpeg -version` runs (N-125875, --enable-gpl --enable-version3).
Cross-ref #183 (sha256 pin + composite-action consolidation).
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Ships cloud-only video transcription (Groq + OpenAI + any OpenAI-compatible endpoint) end-to-end through both the desktop app and the CLI, with per-segment FTS5 storage.
Spec:
docs/superpowers/specs/2026-05-02-transcription-v1-design.mdPlan:
docs/superpowers/plans/2026-05-02-transcription-v1.md(T0–T13)31 commits, 89 code files, +11,004 / −125.
What landed
coreTranscribertrait mirroringMetadataExtractor;TranscriptionResult/TranscriptSegment/TranscriptionProgresstypes;CoreError::Transcriptiondbtranscript+transcript_segmentschema, FTS5transcript_searchviacrates/db/src/schema/codegen,WriteCmd::Transcriptwriter handler, propteststranscribetranscribeOpenAICompatibleTranscriber— one adapter covers all/v1/audio/transcriptionsproviders viabase_url+auth_scheme; wiremock-tested error mappingappTranscriptionUseCase+ single-worker FIFO queue (depth 32),AppContainerwiring,config/flat→dir refactor, 5 newAppEventvariantscliperima transcribe+perima auth {login,logout,list}; blind key entry viadialoguer; SRT / text / JSON outputdesktoptauri-plugin-shellwiring, regeneratedbindings.tsdesktop/webtranscriptionslice,transcriptsQueryOptionsfactory, 8 api wrappers,useDomainEventsarmsdesktop/web<TranscribeButton />(sidebar),<TranscribeSettingsModal />,<TranscriptionPill />(StatusBar popover)ciscripts/fetch-ffmpeg-sidecar.sh+bundle.externalBin(Linux v1)Architecture notes
/v1/audio/transcriptionsshape is the de facto STT wire spec;base_url+auth_schemecollapses Groq / OpenAI / faster-whisper-server / vLLM / speaches into ~250 LoC.unicode61 remove_diacritics 2(explicitly notporter, which produces wrong stems for non-English).keyring3.x OS-native keychain. Stronghold rejected (Tauri v3 deprecation signalled, CLI parity broken);tauri-plugin-storerejected (plain JSON — audit failure for API keys).WriteCmd, soft-delete + restore arms on every FTS trigger, typedCoreErroracross IPC, no manualuseMemo/useCallbackunder React Compiler.Test plan
Automated (green locally via the pre-push hook — clippy
-D warnings,cargo nextest run --workspace --all-targets, workspace doctests,cargo doc --no-deps,cargo deny check,bun run build,bun test):crates/core— trait + type unit testscrates/db— writer-handler integration tests against real SQLite + transcript proptestscrates/transcribe— audio-pipeline tests, wiremock-backed provider tests, exhaustive error-mapping testscrates/app— use-case + queue testscrates/cli— wiremock + synthetic WAV (hound) integration testapps/desktop— vitest/RTL for the slice, all three components, anduseDomainEventsMerging on automated coverage; the checklist below is unverified and is recorded here as the post-merge follow-up. No item has been run against a live provider.
••••••••; key survives restart (keyring read-back viahas_provider_key).SELECT count(*) FROM transcript;unchanged.CoreError::Transcription(Auth).perima transcribe video.mp4 --output srt→ valid SRT matching stored segments.perima transcribe podcast.mp3 --output text→ works on audio-only input.SELECT count(*) FROM transcript;andFROM transcript_segment;both > 0.Known limitations (v1, by design)
SearchBarstill hitssearch_indexonly;transcript_searchis queryable via SQL/CLI.MediaMetadata.mime_type, which audio files lack until Audio file support: no AudioExtractor registered; audio files scan but yield no metadata #175 lands. The CLI dispatches by path and works today.Branch-rot commits
This branch sat open for ~3 months, and the ecosystem moved underneath it. Four commits are pure catch-up, unrelated to the slice itself:
cargo denyunblock —publish = falseonperima-transcribe(matches every other intra-workspace crate) + ignores forbackoff/instant, both unmaintained transitives of async-openai's default retry policy (cross-ref async-openai default ExponentialBackoff retries 5xx for ~15min — disable or shorten for transcription #178).crossbeam-epoch→ 0.9.20) and RUSTSEC-2026-0194/0195 (quick-xml→ 0.41.0, pulled viaplist→ 1.10.0). No source changes, no new ignores.rust-toolchain.tomlpinschannel = "stable", so clippy 1.95 added five blockers to code that was clean when T5/T6 landed.field_reassign_with_defaultandmatch_wildcard_for_single_variants×3 fixed properly (the wildcard fix turns a future thirdTranscribeOutputvariant into a compile error instead of a silently-passing test).clippy::doc_markdownset toallowworkspace-wide, following the existingmodule_name_repetitions = "allow"precedent. It demands backticks around mixed-case proper nouns in English prose — pure churn, no correctness value. Doc enforcement that catches reader-visible breakage is untouched:broken_intra_doc_links,private_intra_doc_links, andmissing_docsall remaindeny.Worth noting for future long-lived branches: a floating
stablechannel means an idle branch accumulates lint blockers it never had at review time.Follow-up issues from the spec's "Out of scope / next-slice queue" are filed after merge — the local
whisper-rsadapter ispriority/highand is the intended next slice.🤖 Generated with Claude Code