14 Commits

Author SHA1 Message Date
2ca01e7c9d feat(export): prefer native save dialog over browser blob fallback
Some checks failed
check / cargo check (macos-latest) (push) Has been cancelled
check / cargo check (ubuntu-22.04) (push) Has been cancelled
check / cargo check (windows-latest) (push) Has been cancelled
check / svelte build + lint (push) Has been cancelled
DictationPage + FilesPage handleExport() now use Tauri save() +
write_text_file_cmd when in the desktop runtime; browser blob path
remains as fallback when hasTauriRuntime() is false. saveMarkdown.ts
surfaces dialog errors via toasts. Adds txt to the extension map.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 08:26:19 +01:00
1d71e8e361 refactor(llm): remove GBNF grammar, switch to JSON-envelope extractor
extract_content_tags now generates with grammar=None and parses the
response via a manual brace-counting JSON envelope extractor that
handles Qwen <think>...</think> prefixes and trailing stop tokens.
Five new unit tests. Bumps llama-cpp-2 to 0.1.146. Explicit
features=[] on tauri dependency (no-op).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 08:25:55 +01:00
b0d4ac8de1 docs(phase10a): re-validation pass entry for 2026/05/12 stack
Some checks failed
check / cargo check (macos-latest) (push) Has been cancelled
check / cargo check (ubuntu-22.04) (push) Has been cancelled
check / cargo check (windows-latest) (push) Has been cancelled
check / svelte build + lint (push) Has been cancelled
Smoke checks (native window, Settings, Record, no error toasts) PASS on
the post-residuals-B/tracing/Area-A stack. Hermes session crashed
before the longer-form dogfood pass (transcribe + LLM cleanup + tagging)
could run; the LLM tagging crash from 2026/05/10 remains the
outstanding release-blocker. Tracing pipeline confirmed working under
the new default filter.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 23:45:32 +01:00
c55b0aaae0 docs: update residuals status after tracing and storage passes
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 23:27:15 +01:00
a36ae7e068 agent: remove legacy string storage error variant
Commit 52565ea migrated storage to magnotia_storage::Error and flattened
typed storage failures into MagnotiaError::Storage { kind, operation,
detail }. No production code constructs the old
MagnotiaError::StorageError String variant anymore.

Remove the legacy variant so new storage failures cannot regress back to
stringly-typed errors.

Also updates living architecture-map docs that referenced the old
variant (core-error.md variant table, storage-overview.md
SQLITE_BUSY note, storage-crud-profiles.md duplicate-name + default-
profile-rename notes, storage-crud-transcripts.md pre-flight FK check
note) and one stale code comment in crates/storage/src/database.rs's
duplicate-name test. Survey doc + old residuals plan + phase8 historical
plan deliberately left alone — they're audit trail of how the migration
was decided, not living docs.

Pre-existing doc rot flagged but not fixed (Other(String) and
Io(std::io::Error) rows in core-error.md are about variants that
already don't match the actual enum shape — separate doc cleanup pass).

Verification:
- cargo fmt --all -- --check
- cargo check -p magnotia-core
- cargo check -p magnotia-storage
- cargo check --workspace --all-targets
- cargo test -p magnotia-storage — 60 passed, 0 failed
- cargo test --workspace --lib — all green
- rg 'StorageError\(' crates/ src-tauri/src/ — zero hits
- rg 'StorageError' crates/ src-tauri/src/ docs/architecture-map/ — zero
- rg 'Other\(String\)' crates/ src-tauri/src/ — zero

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 23:08:56 +01:00
52565ea8b8 agent: area A commit 1 — typed magnotia_storage::Error + boundary
Commit 1 of 2 for engine-slop residuals Area A.

Adds a typed `magnotia_storage::Error` enum and rewires every error
construction site in the storage crate to use it. The crate boundary
into `magnotia_core::error::MagnotiaError` is handled by a
`From<storage::Error> for MagnotiaError` impl that lives inside the
storage crate (not in core) to avoid a `core -> storage` dependency
cycle. The old `MagnotiaError::StorageError(String)` variant is kept
in this commit as an unused placeholder; commit 2 deletes it.

New types in crates/storage/src/error.rs:
- pub enum Error { DatabaseOpen, Migration, Query, NotFound,
  InvalidReference, Filesystem } with #[derive(thiserror::Error)]
- pub enum OpenOp { Connect, ReadOnlyConnect, ForeignKeysPragma }
- pub enum MigrationStep { SchemaVersionTableCreate, SchemaVersionQuery,
  TxBegin, Apply, RecordVersion, Commit }
- pub enum Entity { Transcript, Task, Profile, ImplementationRule,
  Feedback } — seeded only with entities that have a real
  NotFound/InvalidReference case in the codebase
- pub type Result<T> = std::result::Result<T, Error>
- impl Error { pub fn kind() -> StorageKind, pub fn
  operation_label() -> Cow<'static, str> }
- impl From<Error> for MagnotiaError

New types in crates/core/src/error.rs:
- MagnotiaError::Storage { kind: StorageKind, operation: String,
  detail: String } with #[error("{detail}")] so the boundary doesn't
  double-prefix Display output
- pub enum StorageKind { DatabaseOpen, Migration, Query, NotFound,
  InvalidReference, Filesystem } #[derive(Serialize)]
  #[serde(rename_all = "snake_case")] for future Area E

Storage crate dependency added: thiserror = "1"

Migration scope:
- migrations.rs: 6 sites → Error::Migration with structured step + version
- database.rs: 72 sites broken down as:
  * 3  → Error::DatabaseOpen (init / readonly / pragma)
  * 1  → Error::Filesystem (init's create_dir_all with path context)
  * ~58 → Error::Query with operation labels matching the prior message
        stem in snake_case; transaction sub-steps use dotted labels
        ("complete_subtask_and_check_parent.commit_transaction" etc.)
        so transaction internals are not collapsed
  * 5  → Error::NotFound (transcript / task×2 / implementation_rule×2)
  * 5  → Error::InvalidReference (insert_transcript unknown profile;
        Default profile rename/delete invariants ×3; feedback rating
        value validation)

Survey ambiguities resolved per the locked answers:
- Q1 (schema_version_query): Migration step, not Query
- Q2 (transaction sub-steps): preserved granularity with dotted operation
  labels; no collapsing
- Q3 (Entity cardinality): seeded with the 3 from the survey + 2 more
  discovered during migration (ImplementationRule, Feedback), per
  "add when an actual case needs it"
- Q4 (operation label type): Cow<'static, str>
- Q5 (Serialize): storage::Error is not serializable; flattens only at
  the MagnotiaError boundary
- Q6 (re-exports): pub use error::{Entity, Error, MigrationStep, OpenOp,
  Result} in storage::lib.rs; StorageKind belongs to magnotia_core

Two scope-discovered additions beyond the original 5 variants:
- Error::Filesystem { path, source } variant + matching StorageKind —
  required because init() now returns storage::Result<SqlitePool> and
  the create_dir_all call needs a typed path; doing this via
  From<io::Error> for storage::Error would have lost the path so it's
  explicit
- Entity::ImplementationRule and Entity::Feedback — two NotFound /
  InvalidReference sites the original survey missed in the rule-CRUD
  and feedback-validation areas

Verification:
- cargo fmt --all
- cargo check --workspace --all-targets — clean
- cargo test -p magnotia-storage — 60 passed, 0 failed
- cargo test --workspace — all green, ~330 tests
- rg 'StorageError\(' crates/ src-tauri/src/ — only hit is the variant
  declaration in core that commit 2 will delete
- rg 'Other\(String' crates/storage/src/ — zero
- rg 'format!\("[A-Z].* failed' crates/storage/src/ — zero

Commit 2 will delete MagnotiaError::StorageError(String) once this is
in. Pre-existing working-tree churn in crates/llm/, src/lib/pages/,
src/lib/utils/saveMarkdown.ts and the untracked phase10a dogfood notes
deliberately left unstaged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 22:57:17 +01:00
fdab77776c docs(area-a): storage error inventory + proposed typed taxonomy
Pre-migration survey for engine-slop residuals Area A. Catalogues every
MagnotiaError::StorageError construction site in the storage crate
(78 total across database.rs and migrations.rs), groups by failure
mode, proposes a typed magnotia_storage::Error enum with a serde-
friendly MagnotiaError::Storage { kind, operation, detail } variant on
top, and flags 6 ambiguities + 3 migration risk classes.

Key findings the residuals plan did not capture:
- StorageError is a String variant on MagnotiaError, not a separate
  enum — there is nothing called StorageError in the storage crate.
- Actual site count is 78, not the ~25 the residuals plan estimated.
- Tauri commands stringify every error before crossing into the
  frontend (Result<T, String>), so MagnotiaError's Serialize impl is
  presently unused at the FE/BE boundary. Area E owns that fix.
- Zero Other(String) sites in storage — already cleaned in db654de.

No code changes. Migration awaits decisions on the 6 ambiguous cases
in §"Ambiguous cases / decisions needed".

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 22:39:27 +01:00
48c483894b agent: finish tracing migration for hotkey logs
Follow-up to 184214b. The eprintln→tracing sweep surfaced 6 existing
log::* calls in the hotkey crate that were silent at runtime — the
workspace builds tracing-subscriber without the tracing-log feature, so
log:: events never reached the tracing pipeline.

Migrating direct rather than adding a LogTracer bridge: the repo is
already standardising on tracing, the bridge adds a second logger init
path with "already initialised" edge cases, and the bridge would
indiscriminately route all log records (including future third-party
chatter), not just these known sites.

Migrated (6 sites, 2 files):
- crates/hotkey/src/linux.rs (5) — read /dev/input error, device open
  debug, device-attached info, device-listener-ended warn, event-channel-
  closed warn
- crates/hotkey/src/stub.rs (1) — non-Linux no-op info

Also removed the now-unused log = "0.4" dependency from
crates/hotkey/Cargo.toml. magnotia_hotkey=info filter target was
already added to init_tracing in 184214b, so these events emit at the
default level immediately.

Verification:
- cargo fmt --all -- --check
- cargo check -p magnotia-hotkey — clean
- cargo check -p magnotia — clean
- rg 'log::|eprintln!' crates/hotkey/src/ src-tauri/src/commands/ crates/ai-formatting/src/ — zero hits

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 22:34:26 +01:00
184214b60a agent: engine slop residuals B — eprintln → tracing sweep
Replaces 22 production eprintln! sites with structured tracing events
across 8 files. Closes Area B of the post-prognosis residuals plan
(docs/superpowers/plans/2026-05-12-engine-slop-residuals.md).

Files touched (22 sites):
- crates/hotkey/src/linux.rs (2) — hotplug watcher degraded-mode warnings
- crates/ai-formatting/src/pipeline.rs (1) — LLM cleanup fallback warning
- src-tauri/src/commands/transcription.rs (1) — chunking dispatch info
- src-tauri/src/commands/diagnostics.rs (1) — crashes-dir setup warning
- src-tauri/src/commands/tasks.rs (1) — malformed feedback row warning
- src-tauri/src/commands/power.rs (3) — App Nap acquire/release/fail
- src-tauri/src/commands/models.rs (5) — Whisper warmup lifecycle
- src-tauri/src/commands/live.rs (8) — session start, chunk dispatch,
  per-chunk delivery, inference errors, worker disconnects, listener
  loss, status-channel cascade

Levels: error for unrecoverable failures (inference disconnect, panic,
status cascade), warn for recoverable degradation (LLM fallback,
malformed rows, App Nap fail, hotplug watcher fail), info for lifecycle
(session start, chunk processed, App Nap acquire/release, warmup
complete, chunking dispatch), debug for per-chunk noise (speech-gate
skip, chunk dispatch).

Two new dependencies and two new filter targets:
- tracing = "0.1" added to crates/hotkey and crates/ai-formatting
- Default EnvFilter in src-tauri/src/lib.rs::init_tracing extended with
  magnotia_hotkey=info,magnotia_ai_formatting=info so the new targets
  emit at the default level

Out of scope (intentional, left as-is):
- crates/mcp/src/main.rs — CLI binary, stderr is the log contract
  (module docstring) so the JSON-RPC stdout stream stays clean
- crates/*/tests/*.rs and crates/core/examples/tuning_log_demo.rs —
  test/example diagnostic output relies on --nocapture stdio semantics

Discovery during sweep (not fixed — separate follow-up): hotkey crate
has 6 existing log:: calls (log::error/warn/info/debug) but the
workspace builds tracing-subscriber without the tracing-log feature, so
those events are currently silent. Worth a follow-up to either add the
tracing-log bridge or migrate hotkey's existing log:: calls to
tracing::.

Verification:
- cargo fmt --all
- cargo check --workspace --all-targets — clean
- cargo test --workspace — 330+ tests, zero failures
- rg eprintln! src-tauri/src/commands/ crates/hotkey/src/ crates/ai-formatting/src/ — zero hits

Pre-existing working-tree churn in crates/llm/, src/lib/pages/,
src/lib/utils/saveMarkdown.ts and the untracked phase10a dogfood notes
deliberately left unstaged per Jake's instruction.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 22:27:06 +01:00
792fb5ea08 agent: dev launcher — own Linux env-var contract, add dev:tauri, doc sweep
The 2026-05-12 engine-slop pass removed runtime std::env::set_var mutation from src-tauri/src/lib.rs and replaced it with warn_if_x11_env_unset_on_wayland. With no launcher owning the contract, Linux Wayland dogfood was about to regress.

run.sh:
- now owns Linux launcher env defaults via case "$(uname -s)"
  LIBCLANG_PATH=/usr/lib64/llvm21/lib64 (user-set wins)
  WEBKIT_DISABLE_DMABUF_RENDERER=1 (always on Linux; user-set wins)
  GDK_BACKEND=x11, WINIT_UNIX_BACKEND=x11 (Wayland only; user-set wins)
- 60s Vite readiness timeout
- detects early Vite exit (kill -0 + wait) instead of hanging forever
- trap installed before wait so Ctrl-C cleans up
- args forwarded: ./run.sh --release etc.
- non-exec final Tauri launch preserved so cleanup trap fires

package.json:
- "dev:tauri": "./run.sh" — canonical discoverable dev command

Docs:
- README, dev-setup, architecture-map runtime + launcher pages updated with the new contract; canonical command is npm run dev:tauri (./run.sh as direct equivalent)
- dev-launcher-and-scripts.md replaces the incorrect "kills process group" claim with honest "kills the spawned npm process"
- dev-setup.md path /CORBEL-Projects/magnotia → /CORBEL-Projects/transcription-app
- gpu-tuning/plan.md gets a superseded note rather than rewriting the original rationale
- engine-slop-residuals.md gains Area F (packaged-binary launcher contract) with honest wrapper-vs-.desktop trade-off documented

Verification: bash -n run.sh; shellcheck clean; cargo check -p magnotia green; npm pkg get 'scripts.dev:tauri' returns "./run.sh"; stale-ref sweep clean across living docs.
2026-05-12 22:05:33 +01:00
db654deecc agent: engine slop pass — DSP, typed errors, regex parsing, tracing, audit fixes
External code review on 2026-05-12 rated the codebase 4/10 across audio DSP, error typing, JS injection, env-var safety, ALSA parsing, and async logging. This commit lands the prognosis-level fixes plus three audit follow-ups.

Audio/DSP:
- StreamingResampler/rubato confirmed in the live capture path
- regression test at 12 kHz (rms < 0.01, ~40 dB) catches naive decimation
- near-Nyquist test at 9 kHz (rms < 0.05, ~26 dB) exercises transition band

Core errors:
- Other(String) removed; ProviderNotRegistered introduced
- Io variant restructured as struct with kind/message/raw_os_error
- FileNotFound display quotes paths
- Configuration variant removed (unused)

Core types:
- ModelId, EngineName backed by Cow<'static, str>; const borrowed ctor
- Megabytes::from_gb takes u64 (was f64)
- AudioSamples::sample_rate is NonZeroU32; zero-rate defensive branch removed

Capture:
- /proc/asound/cards parsing rewritten as anchored regex (OnceLock)
- regression test covers product names with embedded colons
- monitor_pattern_detection test restored alongside the regex test
- DEAD_SILENCE_FLOOR promoted to module-level with rationale
- DEVICE_VALIDATION_MS, SILENCE_RMS_FLOOR documented with field-observation rationale
- RMS validation loop made idiomatic
- eprintln! migrated to tracing with structured fields and targets

Tauri startup:
- unsafe std::env::set_var removed; ensure_x11_on_wayland renamed to warn_if_x11_env_unset_on_wayland (launcher/wrapper owns env-var contract)
- DB init + log prune + preferences load collapsed to one block_on
- build_preferences_script rewrites JS injection from JSON.parse string to direct object literal plus malformed-JSON guard and unit tests
- WebKitGTK microphone auto-grant logs warning at startup
- tracing subscriber initialised at top of run() (warn,magnotia=info,... on stderr; honors RUST_LOG); previously eprintln→tracing migration was silent because no subscriber existed

Filename counter:
- RECORDING_COUNTER uses SeqCst

Tests: cargo test --workspace --lib green (322 passed, 0 failed across 10 crates).

Three independent audits (original cleanup → Wren → fresh Codex subagent) concur on no critical findings.

Deferred to docs/superpowers/plans/2026-05-12-engine-slop-residuals.md: storage-layer typed errors, remaining eprintln→tracing sweep, capture actor-model refactor, property-based DSP testing, frontend/backend error boundary cleanup.
2026-05-12 22:03:58 +01:00
b463c32f17 chore: stabilize current head before Phase 10a QC 2026-05-10 23:00:25 +01:00
c95a5da077 docs(roadmap): bookmark SIE + adjacent embeddings/rerank/extract servers for PKM phase
Wyrdnote scales from voice-first dictation today to local-first
semantic PKM workbench tomorrow per the engine architecture spec.
The PKM phase needs a serving stack for embeddings + reranking +
entity extraction; this note bookmarks SIE (Superlinked Inference
Engine, Apache-2.0) as the trigger candidate plus six adjacents
to bake off against when the PKM phase lands.

Deferred because (1) PKM sits post-public-beta and infrastructure
decisions today would generalise poorly to a Q4 2026 selection;
(2) the candidates evolve fast; (3) Wyrdnote's local-first +
single-consumer-GPU constraint rules out a today-style bake-off
without real PKM-shape workload to test against.

Decision dimensions captured for the future evaluator: self-host
footprint, cold-start latency, throughput, quality on a Wyrdnote
eval set, reranker availability, extract/NER capability,
OEM-licensability against AGPL-3.0+dual-licence stack, telemetry
posture. Re-evaluation triggers documented.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 21:27:32 +01:00
6bc8acccce feat(transcription): Phase A — engine protocol + registry + orchestrator (clean-room)
Introduces the architectural spine for the Wyrdnote engine layer per
the spec at outputs/wyrdnote/2026-05-10-engine-architecture-spec.md
in the CORBEL-Main workspace, Codex-reviewed 2026/05/10.

Clean-room derivative of VoiceInk's TranscriptionService /
TranscriptionServiceRegistry / TranscriptionPipeline shape. Built from
the architectural pattern only; no GPL-3.0 code, comments, or
identifiers lifted. Wyrdnote names, control flow, and trait signatures
are original.

What this lands:

crates/cloud-providers/src/provider.rs (new, 184 lines)
  TranscriptionProvider async trait. Object-safe via async_trait so
  Arc<dyn TranscriptionProvider> is the canonical shape. Lives in
  cloud-providers (not transcription) per D7 so an OEM licensee
  implementing the trait depends only on this crate plus magnotia-core
  (no transcription internals leaked through the trait surface).
  Surrounding types: ProviderId (lower-kebab-case), ProviderKind,
  NetworkRequirement, CostClass, ProviderCapabilities, EngineProfile,
  ProviderTranscript. Compile-time object-safety witness in tests.

crates/cloud-providers/Cargo.toml (modified)
  Adds async-trait + serde dependencies.

crates/cloud-providers/src/lib.rs (modified)
  Re-exports the provider surface.

crates/transcription/src/registry.rs (new, 183 lines)
  EngineRegistry: catalogue of providers keyed by ProviderId. Push-style
  registration, default-provider id captured at construction. Read-only
  after boot. Full unit-test coverage: empty default, register/get
  round-trip, default-resolves-after-registration, re-register replaces,
  ids enumeration.

crates/transcription/src/orchestrator.rs (new, 286 lines)
  LocalProviderAdapter wraps Arc<LocalEngine>, presents async
  TranscriptionProvider upward via tokio::task::spawn_blocking. Per D7
  the adapter lives in the orchestrator, NOT as impl TranscriptionProvider
  for LocalEngine — this keeps the dependency edge one-directional and
  avoids leaking async-runtime requirements onto the synchronous
  Transcriber trait.

  Orchestrator: single transcribe() entry point; resolves a provider
  from the registry, derives TranscriptionOptions from the EngineProfile,
  dispatches. Returns a clear error when an unregistered provider is
  named. Unit tests use a mock CannedProvider to validate dispatch,
  error handling, and option routing without booting a real model.

crates/transcription/Cargo.toml (modified)
  Depends on magnotia-cloud-providers + async-trait. Dev-dep tokio
  gains macros + rt-multi-thread features for #[tokio::test].

crates/transcription/src/lib.rs (modified)
  Exports Orchestrator, EngineRegistry, LocalProviderAdapter, and
  re-exports the provider trait surface so downstream crates depend
  only on magnotia-transcription for both local engines and the trait.

KNOWN-ISSUES.md (modified)
  Adds KI-06: existing dictation, live, and meeting commands still call
  LocalEngine::transcribe_sync directly via pick_engine. The orchestrator
  path is dormant until a Phase A.1 follow-up commit migrates the call
  sites. Cloud providers (Phase G) cannot be exercised end-to-end until
  the rewire lands. Phasing rationale: this commit lands the abstractions
  cleanly with full test coverage; the rewire is a separate diff so the
  chunking + post-processing logic in transcribe_file moves without
  inflating this commit beyond review fidelity.

Test results: 9 new tests pass (orchestrator: dispatches, errors-on-
unregistered, routes-initial-prompt, object-safe; registry: empty-default,
round-trip, default-resolves, re-register, ids-list). Pre-existing
59 transcription tests + 5 cloud-providers tests still green.
cargo check --workspace clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 07:49:24 +01:00
70 changed files with 3074 additions and 593 deletions

20
Cargo.lock generated
View File

@@ -2705,9 +2705,9 @@ checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0"
[[package]] [[package]]
name = "llama-cpp-2" name = "llama-cpp-2"
version = "0.1.145" version = "0.1.146"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2e82b8c7a1c1a0ad97e1cc5cc28e01e9e14be73d4068e0fe9ac9d6c465001323" checksum = "f3b0f368c76cc0fe475e8257aeeec269e0d6569bd48b1f503efd0963fc3ee397"
dependencies = [ dependencies = [
"encoding_rs", "encoding_rs",
"enumflags2", "enumflags2",
@@ -2719,9 +2719,9 @@ dependencies = [
[[package]] [[package]]
name = "llama-cpp-sys-2" name = "llama-cpp-sys-2"
version = "0.1.145" version = "0.1.146"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9e1e5495433ca7487b9f8c7046f64e69937861d438b20f12ee3c524f35d55ad3" checksum = "9b291e4bc2d10c43cd8dec16d49b6104cb3cb125f596ec380a753a5db1d965dd"
dependencies = [ dependencies = [
"bindgen", "bindgen",
"cc", "cc",
@@ -2816,6 +2816,8 @@ dependencies = [
"tauri-plugin-window-state", "tauri-plugin-window-state",
"tempfile", "tempfile",
"tokio", "tokio",
"tracing",
"tracing-subscriber",
"uuid", "uuid",
"webkit2gtk", "webkit2gtk",
] ]
@@ -2827,6 +2829,7 @@ dependencies = [
"magnotia-core", "magnotia-core",
"magnotia-llm", "magnotia-llm",
"regex-lite", "regex-lite",
"tracing",
] ]
[[package]] [[package]]
@@ -2836,17 +2839,21 @@ dependencies = [
"cpal", "cpal",
"hound", "hound",
"magnotia-core", "magnotia-core",
"regex",
"rubato", "rubato",
"serde", "serde",
"symphonia", "symphonia",
"tokio", "tokio",
"tracing",
] ]
[[package]] [[package]]
name = "magnotia-cloud-providers" name = "magnotia-cloud-providers"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"async-trait",
"magnotia-core", "magnotia-core",
"serde",
] ]
[[package]] [[package]]
@@ -2870,12 +2877,12 @@ name = "magnotia-hotkey"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"evdev", "evdev",
"log",
"magnotia-core", "magnotia-core",
"nix 0.29.0", "nix 0.29.0",
"notify", "notify",
"serde", "serde",
"tokio", "tokio",
"tracing",
] ]
[[package]] [[package]]
@@ -2917,6 +2924,7 @@ dependencies = [
"magnotia-core", "magnotia-core",
"serde", "serde",
"sqlx", "sqlx",
"thiserror 1.0.69",
"tokio", "tokio",
"uuid", "uuid",
] ]
@@ -2925,7 +2933,9 @@ dependencies = [
name = "magnotia-transcription" name = "magnotia-transcription"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"async-trait",
"futures-util", "futures-util",
"magnotia-cloud-providers",
"magnotia-core", "magnotia-core",
"num_cpus", "num_cpus",
"reqwest 0.12.28", "reqwest 0.12.28",

View File

@@ -64,6 +64,20 @@ Tracked limitations and partial implementations in the current codebase. Each en
**Workaround:** N/A. **Workaround:** N/A.
## Engine architecture (Phase A)
### KI-06 — `transcribe_file` and live-transcription commands not yet rewired through `Orchestrator`
**Status:** Phase A introduced the `TranscriptionProvider` async trait ([`crates/cloud-providers/src/provider.rs`](crates/cloud-providers/src/provider.rs)), `EngineRegistry` ([`crates/transcription/src/registry.rs`](crates/transcription/src/registry.rs)), and `Orchestrator` plus `LocalProviderAdapter` ([`crates/transcription/src/orchestrator.rs`](crates/transcription/src/orchestrator.rs)). The abstractions compile, are object-safe, and pass unit tests against a mock provider. The existing dictation path ([`src-tauri/src/commands/transcription.rs`](src-tauri/src/commands/transcription.rs)) still calls `LocalEngine::transcribe_sync` directly via `pick_engine`, not through the orchestrator. Live and meeting commands likewise route around the orchestrator.
**Source:** [`src-tauri/src/commands/transcription.rs:29`](src-tauri/src/commands/transcription.rs#L29) (`pick_engine`), [`src-tauri/src/commands/live.rs`](src-tauri/src/commands/live.rs), [`src-tauri/src/commands/meeting.rs`](src-tauri/src/commands/meeting.rs).
**Impact:** None at runtime. The orchestrator path is dormant until a follow-up commit migrates the call sites. Cloud providers (Phase G) cannot be exercised end-to-end until this rewire lands.
**Resolution (deferred):** Phase A.1 follow-up commit. Build an `EngineRegistry` at app boot (one `LocalProviderAdapter` per `LocalEngine`), inject `Arc<Orchestrator>` into `AppState`, replace `pick_engine` plus `engine.transcribe_sync` chunking-loop calls with `orchestrator.transcribe(audio, &profile)` per chunk. Keep the chunking strategy in the command layer (it is a strategy concern, not a dispatch concern). Tests should cover both paths against a mock provider before touching the real engines.
**Workaround:** N/A. Existing path works as before.
## How to add an entry ## How to add an entry
When shipping a partial implementation or known limitation, add a `KI-NN` entry here with the four standard fields: **Status**, **Source** (file:line), **Impact**, **Workaround**. Link from the affected module's doc comment back to this file by ID. When shipping a partial implementation or known limitation, add a `KI-NN` entry here with the four standard fields: **Status**, **Source** (file:line), **Impact**, **Workaround**. Link from the affected module's doc comment back to this file by ID.

View File

@@ -271,20 +271,22 @@ See [`docs/dev-setup.md`](docs/dev-setup.md) for the authoritative per-platform
### Dev launch ### Dev launch
The fast path — starts Vite, waits for port 1420, then launches Tauri: Canonical full-stack dev launch — starts Vite, waits for port 1420, then launches Tauri:
```bash
npm run dev:tauri
```
Direct shell equivalent:
```bash ```bash
./run.sh ./run.sh
``` ```
Or manually: For pure frontend iteration without Tauri:
```bash ```bash
# Terminal 1
npm run dev:frontend npm run dev:frontend
# Terminal 2
npm run tauri dev
``` ```
### Build ### Build

View File

@@ -8,3 +8,4 @@ description = "Text post-processing pipeline: filler removal, British English co
magnotia-core = { path = "../core" } magnotia-core = { path = "../core" }
magnotia-llm = { path = "../llm" } magnotia-llm = { path = "../llm" }
regex-lite = "0.1" regex-lite = "0.1"
tracing = "0.1"

View File

@@ -91,8 +91,9 @@ pub fn post_process_segments(
replace_segments_with_cleaned(segments, cleaned.trim()); replace_segments_with_cleaned(segments, cleaned.trim());
} }
Ok(_) => {} Ok(_) => {}
Err(err) => eprintln!( Err(err) => tracing::warn!(
"[ai-formatting] LLM cleanup failed, keeping rule-based output: {err}" error = %err,
"LLM cleanup failed, keeping rule-based output"
), ),
} }
} }

View File

@@ -28,3 +28,5 @@ tokio = { version = "1", features = ["rt", "sync"] }
# Serde for DeviceInfo (returned across the Tauri boundary) # Serde for DeviceInfo (returned across the Tauri boundary)
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
tracing = "0.1"
regex = "1"

View File

@@ -4,22 +4,30 @@ use std::sync::Arc;
use cpal::traits::{DeviceTrait, HostTrait, StreamTrait}; use cpal::traits::{DeviceTrait, HostTrait, StreamTrait};
use cpal::{FromSample, Sample, SampleFormat, SizedSample}; use cpal::{FromSample, Sample, SampleFormat, SizedSample};
use regex::Regex;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::sync::OnceLock;
use magnotia_core::error::{MagnotiaError, Result}; use magnotia_core::error::{MagnotiaError, Result};
const AUDIO_CHANNEL_CAPACITY: usize = 32; const AUDIO_CHANNEL_CAPACITY: usize = 32;
/// Validation window. We listen for this long and compute RMS to decide /// Validation window. 350ms is long enough to collect several cpal callback
/// whether the chosen device is delivering real audio (vs a silent monitor). /// buffers at common 44.1/48kHz rates while keeping Settings/UI device
/// switching perceptibly sub-second.
const DEVICE_VALIDATION_MS: u64 = 350; const DEVICE_VALIDATION_MS: u64 = 350;
/// Below this RMS amplitude (peak ±1.0 scale) the input is treated as /// Below this RMS amplitude (peak ±1.0 scale) the input is treated as
/// silence. PulseAudio/PipeWire monitor sources for an idle speaker /// silence. Field dogfooding on PipeWire/PulseAudio showed idle monitor
/// typically deliver dead-zero samples; real microphones yield ~0.0005+ /// sources at exact or near-zero RMS, while connected microphones in quiet
/// even in a quiet room. Conservative floor: 1e-5. /// rooms stayed around 5e-4+; 1e-5 keeps a 50x safety margin below that.
const SILENCE_RMS_FLOOR: f32 = 1e-5; const SILENCE_RMS_FLOOR: f32 = 1e-5;
/// Absolute floor used even for monitor fallback. Values below this are
/// effectively digital zero on normalized f32 PCM, so accepting them only
/// records silence and hides device-routing failures.
const DEAD_SILENCE_FLOOR: f32 = 1e-7;
/// A chunk of captured audio from the microphone. /// A chunk of captured audio from the microphone.
pub struct AudioChunk { pub struct AudioChunk {
pub samples: Vec<f32>, pub samples: Vec<f32>,
@@ -53,7 +61,6 @@ pub struct DeviceInfo {
/// `start()` has already returned. The live session subscribes to these via /// `start()` has already returned. The live session subscribes to these via
/// `error_rx()` so the frontend can show a toast when the mic vanishes /// `error_rx()` so the frontend can show a toast when the mic vanishes
/// mid-recording. /// mid-recording.
/// (Codex review 2026/04/17 M2)
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct CaptureRuntimeError { pub struct CaptureRuntimeError {
pub device_name: String, pub device_name: String,
@@ -84,7 +91,6 @@ impl MicrophoneCapture {
/// Take the runtime-error receiver. Can be called once per capture; the /// Take the runtime-error receiver. Can be called once per capture; the
/// caller (live session manager) drains it on its own cadence and surfaces /// caller (live session manager) drains it on its own cadence and surfaces
/// errors to the frontend. Returns None on the second call. /// errors to the frontend. Returns None on the second call.
/// (Codex review 2026/04/17 M2)
pub fn take_error_rx(&mut self) -> Option<mpsc::Receiver<CaptureRuntimeError>> { pub fn take_error_rx(&mut self) -> Option<mpsc::Receiver<CaptureRuntimeError>> {
self.error_rx.take() self.error_rx.take()
} }
@@ -143,7 +149,7 @@ impl MicrophoneCapture {
for device in devices { for device in devices {
let name = device_display_name(&device).unwrap_or_default(); let name = device_display_name(&device).unwrap_or_default();
if name == device_name { if name == device_name {
eprintln!("[magnotia-audio] start_with_device: opening explicit device '{name}'"); tracing::info!(target: "magnotia_audio", "start_with_device: opening explicit device '{name}'");
return open_and_validate(device, &name, /* require_audio = */ true); return open_and_validate(device, &name, /* require_audio = */ true);
} }
} }
@@ -189,10 +195,11 @@ impl MicrophoneCapture {
} }
}); });
eprintln!( tracing::info!(
"[magnotia-audio] start: enumerated {} input device(s) (default='{}')", target: "magnotia_audio",
all_devices.len(), device_count = all_devices.len(),
default_name default = %default_name,
"enumerated input devices"
); );
// First pass: require real audio energy. // First pass: require real audio energy.
@@ -204,23 +211,25 @@ impl MicrophoneCapture {
match open_and_validate(device.clone(), &name, true) { match open_and_validate(device.clone(), &name, true) {
Ok(result) => return Ok(result), Ok(result) => return Ok(result),
Err(e) => { Err(e) => {
eprintln!("[magnotia-audio] '{name}' rejected: {e}"); tracing::warn!(target: "magnotia_audio", device = %name, error = %e, "candidate device rejected");
} }
} }
} }
// Second pass: accept anything that delivers bytes (monitor sources // Second pass: accept anything that delivers bytes (monitor sources
// included). Better to capture from a monitor than fail entirely. // included). Better to capture from a monitor than fail entirely.
eprintln!( tracing::warn!(
"[magnotia-audio] no non-monitor mic produced audio; falling back to monitor/loopback sources" target: "magnotia_audio",
"no non-monitor mic produced audio; falling back to monitor/loopback sources"
); );
for device in &all_devices { for device in &all_devices {
let name = device_display_name(device).unwrap_or_default(); let name = device_display_name(device).unwrap_or_default();
match open_and_validate(device.clone(), &name, false) { match open_and_validate(device.clone(), &name, false) {
Ok(result) => { Ok(result) => {
eprintln!( tracing::warn!(
"[magnotia-audio] FALLBACK: capturing from '{name}' (likely monitor source). \ target: "magnotia_audio",
Recordings may be silent or contain system audio." device = %name,
"capturing from likely monitor source; recordings may be silent or contain system audio"
); );
return Ok(result); return Ok(result);
} }
@@ -295,52 +304,49 @@ fn extract_card_id(name: &str) -> Option<&str> {
/// after the colon on that same line is the description we want. The /// after the colon on that same line is the description we want. The
/// next indented line is a longer location string we ignore. /// next indented line is a longer location string we ignore.
fn load_alsa_card_descriptions() -> std::collections::HashMap<String, String> { fn load_alsa_card_descriptions() -> std::collections::HashMap<String, String> {
use std::collections::HashMap;
let mut map = HashMap::new();
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
{ {
let Ok(contents) = std::fs::read_to_string("/proc/asound/cards") else { let Ok(contents) = std::fs::read_to_string("/proc/asound/cards") else {
return map; return std::collections::HashMap::new();
}; };
for line in contents.lines() { parse_alsa_card_descriptions(&contents)
// Header lines start with an optional leading space plus a }
// digit (the card ID, right-aligned to 2 chars for readable
// formatting). Continuation lines are indented beyond that. #[cfg(not(target_os = "linux"))]
let trimmed = line.trim_start(); {
if !trimmed std::collections::HashMap::new()
.chars() }
.next() }
.map(|c| c.is_ascii_digit())
.unwrap_or(false) fn parse_alsa_card_descriptions(contents: &str) -> std::collections::HashMap<String, String> {
{ use std::collections::HashMap;
continue;
} static CARD_LINE: OnceLock<Regex> = OnceLock::new();
let Some(open) = trimmed.find('[') else { let card_line = CARD_LINE.get_or_init(|| {
continue; Regex::new(r"^\s*\d+\s+\[([^\]]+)\]\s*:\s*(.+?)\s*$").expect("valid ALSA card-line regex")
}; });
let Some(close) = trimmed[open..].find(']') else {
continue; let mut map = HashMap::new();
}; for line in contents.lines() {
let short_name = trimmed[open + 1..open + close].trim().to_string(); let Some(captures) = card_line.captures(line) else {
if short_name.is_empty() { continue;
continue; };
} let Some(short_name) = captures.get(1).map(|m| m.as_str().trim()) else {
let after_bracket = &trimmed[open + close + 1..]; continue;
let Some(colon) = after_bracket.find(':') else { };
continue; if short_name.is_empty() {
}; continue;
// Format: "USB-Audio - Blue Microphones" }
// We keep everything after the " - " if present, otherwise let raw = captures
// the whole post-colon fragment. .get(2)
let raw = after_bracket[colon + 1..].trim(); .map(|m| m.as_str().trim())
let description = raw .unwrap_or_default();
.split(" - ") let description = raw
.nth(1) .split_once(" - ")
.map(|s| s.trim().to_string()) .map(|(_, product)| product.trim())
.unwrap_or_else(|| raw.to_string()); .unwrap_or(raw);
if !description.is_empty() { if !description.is_empty() {
map.insert(short_name, description); map.insert(short_name.to_string(), description.to_string());
}
} }
} }
map map
@@ -360,11 +366,13 @@ fn open_and_validate(
let channels = config.channels(); let channels = config.channels();
let format = config.sample_format(); let format = config.sample_format();
eprintln!( tracing::info!(
"[magnotia-audio] trying '{name}' ({sr}Hz, {ch}ch, {fmt:?})", target: "magnotia_audio",
sr = sample_rate, device = %name,
ch = channels, sample_rate,
fmt = format channels,
format = ?format,
"trying audio input device"
); );
let (tx, rx) = mpsc::sync_channel::<AudioChunk>(AUDIO_CHANNEL_CAPACITY); let (tx, rx) = mpsc::sync_channel::<AudioChunk>(AUDIO_CHANNEL_CAPACITY);
@@ -375,8 +383,7 @@ fn open_and_validate(
// and counted in `dropped_errors` so the symptom is visible in the // and counted in `dropped_errors` so the symptom is visible in the
// diagnostic bundle even when the listener has gone away. Errors // diagnostic bundle even when the listener has gone away. Errors
// beyond the cap are by definition redundant noise in a stream that // beyond the cap are by definition redundant noise in a stream that
// is already failing. (Codex review 2026/04/17 M2; capacity bump and // is already failing.
// drop logging added 2026/04/25 audit pass.)
let (err_tx, err_rx) = mpsc::sync_channel::<CaptureRuntimeError>(32); let (err_tx, err_rx) = mpsc::sync_channel::<CaptureRuntimeError>(32);
let dropped_errors = Arc::new(AtomicU64::new(0)); let dropped_errors = Arc::new(AtomicU64::new(0));
@@ -440,9 +447,11 @@ fn open_and_validate(
} }
match rx.recv_timeout(remaining) { match rx.recv_timeout(remaining) {
Ok(chunk) => { Ok(chunk) => {
for &s in &chunk.samples { sum_sq += chunk
sum_sq += (s as f64) * (s as f64); .samples
} .iter()
.map(|&s| (s as f64).powi(2))
.sum::<f64>();
total_samples += chunk.samples.len(); total_samples += chunk.samples.len();
collected.push(chunk); collected.push(chunk);
} }
@@ -457,9 +466,12 @@ fn open_and_validate(
} }
let rms = (sum_sq / total_samples as f64).sqrt() as f32; let rms = (sum_sq / total_samples as f64).sqrt() as f32;
eprintln!( tracing::info!(
"[magnotia-audio] '{name}' validation: {samples} samples, rms={rms:.6}", target: "magnotia_audio",
samples = total_samples device = %name,
samples = total_samples,
rms,
"audio input validation complete"
); );
if require_audio && rms < SILENCE_RMS_FLOOR { if require_audio && rms < SILENCE_RMS_FLOOR {
@@ -471,8 +483,7 @@ fn open_and_validate(
// Even in the fallback pass (require_audio=false), reject completely // Even in the fallback pass (require_audio=false), reject completely
// dead-zero audio. PulseAudio/PipeWire will sometimes happily emit a // dead-zero audio. PulseAudio/PipeWire will sometimes happily emit a
// long stream of f32 zeros from a borked device — that is worse than // long stream of f32 zeros from a borked device — that is worse than
// failing fast. (Codex review 2026/04/17 D3) // failing fast.
const DEAD_SILENCE_FLOOR: f32 = 1e-7;
if rms < DEAD_SILENCE_FLOOR { if rms < DEAD_SILENCE_FLOOR {
return Err(MagnotiaError::AudioCaptureFailed(format!( return Err(MagnotiaError::AudioCaptureFailed(format!(
"device produced dead silence (rms={rms:.6e} below absolute floor {DEAD_SILENCE_FLOOR:.6e})" "device produced dead silence (rms={rms:.6e} below absolute floor {DEAD_SILENCE_FLOOR:.6e})"
@@ -482,14 +493,13 @@ fn open_and_validate(
// Re-queue the collected chunks so downstream gets them. Count any // Re-queue the collected chunks so downstream gets them. Count any
// drops here against the same `dropped_chunks` counter so the live // drops here against the same `dropped_chunks` counter so the live
// session sees them and can warn the user. // session sees them and can warn the user.
// (Codex review 2026/04/17 M1)
for chunk in collected { for chunk in collected {
if requeue_tx.try_send(chunk).is_err() { if requeue_tx.try_send(chunk).is_err() {
dropped_chunks.fetch_add(1, Ordering::Relaxed); dropped_chunks.fetch_add(1, Ordering::Relaxed);
} }
} }
eprintln!("[magnotia-audio] selected microphone: '{name}'"); tracing::info!(target: "magnotia_audio", device = %name, "selected microphone");
Ok(( Ok((
MicrophoneCapture { MicrophoneCapture {
stream: Some(stream), stream: Some(stream),
@@ -528,18 +538,16 @@ where
sample_rate, sample_rate,
channels, channels,
}; };
// try_send fails if the channel is full. Track that explicitly // try_send fails if the channel is full. Track that explicitly;
// rather than swallowing it — Codex review 2026/04/17 caught // otherwise backpressure looks like clean transcription silence.
// this as a silent-failure risk under sustained load.
if tx.try_send(chunk).is_err() { if tx.try_send(chunk).is_err() {
dropped_chunks.fetch_add(1, Ordering::Relaxed); dropped_chunks.fetch_add(1, Ordering::Relaxed);
} }
}, },
move |err| { move |err| {
// Surface stream errors to the live session via err_tx so the // Surface stream errors to the live session via err_tx so the
// frontend can show a toast. Also keep the eprintln for ops // frontend can show a toast.
// logs. (Codex review 2026/04/17 M2) tracing::error!(target: "magnotia_audio", error = %err, "capture stream error");
eprintln!("[magnotia-audio] capture error: {err}");
if err_tx if err_tx
.try_send(CaptureRuntimeError { .try_send(CaptureRuntimeError {
device_name: err_device_name.clone(), device_name: err_device_name.clone(),
@@ -547,15 +555,15 @@ where
}) })
.is_err() .is_err()
{ {
// Channel full — listener has stalled or detached. Note // Channel full — listener has stalled or detached. Keep a
// it in stderr and the dropped-errors counter so the // counter so the diagnostic bundle still shows the symptom
// diagnostic bundle still shows the symptom even if the // even if the frontend never received the typed event.
// frontend never received the typed event.
let prior = dropped_errors.fetch_add(1, Ordering::Relaxed); let prior = dropped_errors.fetch_add(1, Ordering::Relaxed);
eprintln!( tracing::warn!(
"[magnotia-audio] capture error channel full; dropped error #{} for device '{}'", target: "magnotia_audio",
prior + 1, device = %err_device_name,
err_device_name, dropped_error = prior + 1,
"capture error channel full; dropping runtime error"
); );
} }
}, },
@@ -569,15 +577,43 @@ mod tests {
#[test] #[test]
fn monitor_pattern_detection() { fn monitor_pattern_detection() {
assert!(is_monitor_name( for name in [
"alsa_output.pci-0000_00_1f.3.analog-stereo.monitor" "alsa_output.pci-0000_00_1f.3.analog-stereo.monitor",
)); "Monitor of Built-in Audio Analog Stereo",
assert!(is_monitor_name("Monitor of Built-in Audio Analog Stereo")); "PipeWire Loopback Source",
assert!(is_monitor_name("Some Loopback Device")); "Built-in Audio Monitor of Analog Stereo",
assert!(!is_monitor_name("Blue Yeti USB")); ] {
assert!(!is_monitor_name( assert!(is_monitor_name(name), "expected monitor source: {name}");
"alsa_input.pci-0000_00_1f.3.analog-stereo" }
));
assert!(!is_monitor_name("")); for name in [
"Built-in Audio Analog Stereo",
"Blue Microphones",
"HD Pro Webcam C920",
"sysdefault:CARD=Microphones",
] {
assert!(!is_monitor_name(name), "expected physical input: {name}");
}
}
#[test]
fn parses_alsa_cards_with_regex() {
let contents = r#"
2 [Microphones ]: USB-Audio - Blue Microphones
Blue Microphones at usb-0000:04:00.3-2.1, full speed
3 [C920 ]: USB-Audio - HD Pro Webcam C920: With Colon
HD Pro Webcam C920 at usb-0000:04:00.3-2.2, high speed
"#;
let parsed = parse_alsa_card_descriptions(contents);
assert_eq!(
parsed.get("Microphones").map(String::as_str),
Some("Blue Microphones")
);
assert_eq!(
parsed.get("C920").map(String::as_str),
Some("HD Pro Webcam C920: With Colon")
);
} }
} }

View File

@@ -15,5 +15,7 @@ pub async fn decode_and_resample(path: &Path) -> Result<AudioSamples> {
resample_to_16khz(&audio) resample_to_16khz(&audio)
}) })
.await .await
.map_err(|e| magnotia_core::error::MagnotiaError::AudioDecodeFailed(format!("Task join error: {e}")))? .map_err(|e| {
magnotia_core::error::MagnotiaError::AudioDecodeFailed(format!("Task join error: {e}"))
})?
} }

View File

@@ -99,7 +99,9 @@ fn decode_media_stream(
.ok_or_else(|| MagnotiaError::AudioDecodeFailed("Unknown sample rate".into()))?; .ok_or_else(|| MagnotiaError::AudioDecodeFailed("Unknown sample rate".into()))?;
if sample_rate == 0 { if sample_rate == 0 {
return Err(MagnotiaError::AudioDecodeFailed("Invalid sample rate: 0".into())); return Err(MagnotiaError::AudioDecodeFailed(
"Invalid sample rate: 0".into(),
));
} }
let track_id = track.id; let track_id = track.id;
@@ -166,7 +168,9 @@ fn decode_media_stream(
} }
if samples.is_empty() { if samples.is_empty() {
return Err(MagnotiaError::AudioDecodeFailed("No audio data decoded".into())); return Err(MagnotiaError::AudioDecodeFailed(
"No audio data decoded".into(),
));
} }
Ok(AudioSamples::new(samples, sample_rate, 1)) Ok(AudioSamples::new(samples, sample_rate, 1))

View File

@@ -77,7 +77,9 @@ impl StreamingResampler {
INPUT_CHUNK, INPUT_CHUNK,
1, // mono 1, // mono
) )
.map_err(|e| MagnotiaError::AudioDecodeFailed(format!("StreamingResampler init failed: {e}")))?; .map_err(|e| {
MagnotiaError::AudioDecodeFailed(format!("StreamingResampler init failed: {e}"))
})?;
Ok(Self::Sinc { Ok(Self::Sinc {
resampler, resampler,
@@ -142,7 +144,9 @@ impl StreamingResampler {
let input = vec![chunk]; let input = vec![chunk];
let result = resampler.process(&input, None).map_err(|e| { let result = resampler.process(&input, None).map_err(|e| {
MagnotiaError::AudioDecodeFailed(format!("StreamingResampler flush failed: {e}")) MagnotiaError::AudioDecodeFailed(format!(
"StreamingResampler flush failed: {e}"
))
})?; })?;
let Some(mut out) = result.into_iter().next() else { let Some(mut out) = result.into_iter().next() else {
@@ -166,6 +170,25 @@ impl StreamingResampler {
mod tests { mod tests {
use super::*; use super::*;
fn resampled_sine_rms(from_rate: u32, input_frequency: f32) -> f64 {
let sample_count = from_rate as usize;
let samples: Vec<f32> = (0..sample_count)
.map(|i| {
let t = i as f32 / from_rate as f32;
(std::f32::consts::TAU * input_frequency * t).sin()
})
.collect();
let mut resampler = StreamingResampler::new(from_rate).unwrap();
let mut produced = Vec::new();
for chunk in samples.chunks(997) {
produced.extend(resampler.push_samples(chunk).unwrap());
}
produced.extend(resampler.flush().unwrap());
(produced.iter().map(|&s| (s as f64).powi(2)).sum::<f64>() / produced.len() as f64).sqrt()
}
#[test] #[test]
fn passthrough_at_16khz() { fn passthrough_at_16khz() {
let mut r = StreamingResampler::new(16_000).unwrap(); let mut r = StreamingResampler::new(16_000).unwrap();
@@ -179,6 +202,24 @@ mod tests {
assert!(StreamingResampler::new(0).is_err()); assert!(StreamingResampler::new(0).is_err());
} }
#[test]
fn high_frequency_content_is_filtered_before_downsampling() {
let rms = resampled_sine_rms(48_000, 12_000.0);
assert!(
rms < 0.01,
"12kHz content must be low-pass filtered before 16kHz output with at least ~40dB attenuation; rms={rms}"
);
}
#[test]
fn near_nyquist_content_is_attenuated_before_downsampling() {
let rms = resampled_sine_rms(48_000, 9_000.0);
assert!(
rms < 0.05,
"9kHz content just above 16kHz Nyquist should be materially attenuated; rms={rms}"
);
}
#[test] #[test]
fn streaming_48k_to_16k_preserves_duration() { fn streaming_48k_to_16k_preserves_duration() {
let from_rate = 48_000u32; let from_rate = 48_000u32;

View File

@@ -40,10 +40,11 @@ impl WavWriter {
bits_per_sample: 16, bits_per_sample: 16,
sample_format: hound::SampleFormat::Int, sample_format: hound::SampleFormat::Int,
}; };
let file = std::fs::File::create(path).map_err(MagnotiaError::Io)?; let file = std::fs::File::create(path).map_err(MagnotiaError::from)?;
let buffered = BufWriter::new(file); let buffered = BufWriter::new(file);
let inner = hound::WavWriter::new(buffered, spec) let inner = hound::WavWriter::new(buffered, spec).map_err(|e| {
.map_err(|e| MagnotiaError::Io(std::io::Error::other(format!("WAV create failed: {e}"))))?; MagnotiaError::from(std::io::Error::other(format!("WAV create failed: {e}")))
})?;
Ok(Self { Ok(Self {
inner, inner,
samples_since_flush: 0, samples_since_flush: 0,
@@ -60,7 +61,7 @@ impl WavWriter {
let clamped = sample.clamp(-1.0, 1.0); let clamped = sample.clamp(-1.0, 1.0);
let int_sample = (clamped * i16::MAX as f32) as i16; let int_sample = (clamped * i16::MAX as f32) as i16;
self.inner.write_sample(int_sample).map_err(|e| { self.inner.write_sample(int_sample).map_err(|e| {
MagnotiaError::Io(std::io::Error::other(format!("WAV write failed: {e}"))) MagnotiaError::from(std::io::Error::other(format!("WAV write failed: {e}")))
})?; })?;
} }
self.samples_since_flush += samples.len(); self.samples_since_flush += samples.len();
@@ -76,9 +77,9 @@ impl WavWriter {
/// `Self::DEFAULT_FLUSH_EVERY_SAMPLES` — but may do so at natural /// `Self::DEFAULT_FLUSH_EVERY_SAMPLES` — but may do so at natural
/// boundaries (end-of-utterance, UI events) for tighter recovery. /// boundaries (end-of-utterance, UI events) for tighter recovery.
pub fn flush(&mut self) -> Result<()> { pub fn flush(&mut self) -> Result<()> {
self.inner self.inner.flush().map_err(|e| {
.flush() MagnotiaError::from(std::io::Error::other(format!("WAV flush failed: {e}")))
.map_err(|e| MagnotiaError::Io(std::io::Error::other(format!("WAV flush failed: {e}"))))?; })?;
self.samples_since_flush = 0; self.samples_since_flush = 0;
Ok(()) Ok(())
} }
@@ -89,7 +90,7 @@ impl WavWriter {
/// that care about the unflushed tail should always finalise. /// that care about the unflushed tail should always finalise.
pub fn finalize(self) -> Result<()> { pub fn finalize(self) -> Result<()> {
self.inner.finalize().map_err(|e| { self.inner.finalize().map_err(|e| {
MagnotiaError::Io(std::io::Error::other(format!("WAV finalize failed: {e}"))) MagnotiaError::from(std::io::Error::other(format!("WAV finalize failed: {e}")))
})?; })?;
Ok(()) Ok(())
} }
@@ -104,20 +105,21 @@ pub fn write_wav(path: &Path, audio: &AudioSamples) -> Result<()> {
sample_format: hound::SampleFormat::Int, sample_format: hound::SampleFormat::Int,
}; };
let mut writer = hound::WavWriter::create(path, spec) let mut writer = hound::WavWriter::create(path, spec).map_err(|e| {
.map_err(|e| MagnotiaError::Io(std::io::Error::other(format!("WAV create failed: {e}"))))?; MagnotiaError::from(std::io::Error::other(format!("WAV create failed: {e}")))
})?;
for &sample in audio.samples() { for &sample in audio.samples() {
let clamped = sample.clamp(-1.0, 1.0); let clamped = sample.clamp(-1.0, 1.0);
let int_sample = (clamped * i16::MAX as f32) as i16; let int_sample = (clamped * i16::MAX as f32) as i16;
writer writer.write_sample(int_sample).map_err(|e| {
.write_sample(int_sample) MagnotiaError::from(std::io::Error::other(format!("WAV write failed: {e}")))
.map_err(|e| MagnotiaError::Io(std::io::Error::other(format!("WAV write failed: {e}"))))?; })?;
} }
writer writer.finalize().map_err(|e| {
.finalize() MagnotiaError::from(std::io::Error::other(format!("WAV finalize failed: {e}")))
.map_err(|e| MagnotiaError::Io(std::io::Error::other(format!("WAV finalize failed: {e}"))))?; })?;
Ok(()) Ok(())
} }

View File

@@ -2,7 +2,15 @@
name = "magnotia-cloud-providers" name = "magnotia-cloud-providers"
version = "0.1.0" version = "0.1.0"
edition = "2021" edition = "2021"
description = "BYOK cloud STT provider stubs and API key storage for Magnotia" description = "Provider trait and BYOK cloud STT scaffolding for Magnotia (Wyrdnote pending rebrand)"
[dependencies] [dependencies]
magnotia-core = { path = "../core" } magnotia-core = { path = "../core" }
# Async-native trait. async_trait converts async fn into Pin<Box<dyn
# Future>> at the trait surface so TranscriptionProvider stays
# object-safe (Arc<dyn TranscriptionProvider>).
async-trait = "0.1"
# Trait types serialise across the Tauri boundary.
serde = { version = "1", features = ["derive"] }

View File

@@ -1,3 +1,8 @@
pub mod keystore; pub mod keystore;
pub mod provider;
pub use keystore::{retrieve_api_key, store_api_key}; pub use keystore::{retrieve_api_key, store_api_key};
pub use provider::{
CostClass, EngineProfile, NetworkRequirement, ProviderCapabilities, ProviderId, ProviderKind,
ProviderTranscript, TranscriptionProvider,
};

View File

@@ -0,0 +1,184 @@
//! `TranscriptionProvider` is the async-native trait every transcription
//! backend implements, regardless of whether the work happens locally
//! (Whisper, Parakeet, Moonshine via the `LocalProviderAdapter` in
//! `magnotia-transcription`) or remotely (OpenAI Whisper, Groq,
//! Deepgram, etc.).
//!
//! Living in `magnotia-cloud-providers` is deliberate: the AGPL OEM
//! exception (≥£2k/yr) requires a clean trait surface that an OEM
//! licensee can implement without depending on Wyrdnote's transcription
//! internals. The trait crate stays small; provider implementations
//! sit alongside.
//!
//! Object-safety discipline: no generic methods, no `Self`-returning
//! methods. The compile-time witness in `tests` enforces this.
use std::fmt;
use async_trait::async_trait;
use magnotia_core::error::Result;
use magnotia_core::types::{AudioSamples, ModelId, Transcript, TranscriptionOptions};
use serde::{Deserialize, Serialize};
/// Stable, lower-kebab-case identifier for a provider. Used in user
/// profiles, settings storage, and logs. Examples: `local-whisper`,
/// `local-parakeet`, `openai-whisper`, `groq-whisper-v3`.
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct ProviderId(String);
impl ProviderId {
pub fn new(id: impl Into<String>) -> Self {
Self(id.into())
}
pub fn as_str(&self) -> &str {
&self.0
}
}
impl fmt::Display for ProviderId {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.0)
}
}
/// Whether a provider runs locally or over the network. The orchestrator
/// inspects this to decide whether to honour an offline-only profile.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum ProviderKind {
Local,
Cloud(NetworkRequirement),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum NetworkRequirement {
/// Provider requires a live network connection on every call.
Online,
/// Provider can fall back to a cached or queued path when offline.
OnlineWithFallback,
}
/// Indicative cost class for UI surfacing. Not a billing source of truth.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum CostClass {
/// No marginal cost beyond the user's local hardware.
Free,
/// Per-call cost; user supplies their own API key (BYOK).
PaidByok,
/// Per-call cost; provider billed by CORBEL on the user's behalf.
PaidManaged,
}
/// Capabilities a provider advertises to the orchestrator and the UI.
/// Superset of `magnotia_transcription::TranscriberCapabilities` for
/// local providers, with extra fields cloud providers populate.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct ProviderCapabilities {
pub sample_rate: u32,
pub channels: u16,
pub initial_prompt_supported: bool,
pub language_hint_supported: bool,
pub streaming_supported: bool,
pub cost_class: CostClass,
}
/// User-selectable engine configuration. The orchestrator resolves
/// `engine_id` against the `EngineRegistry`, then derives
/// `TranscriptionOptions` from the remaining fields.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EngineProfile {
pub engine_id: ProviderId,
pub model_id: Option<ModelId>,
pub language: Option<String>,
pub initial_prompt: Option<String>,
}
impl EngineProfile {
pub fn new(engine_id: ProviderId) -> Self {
Self {
engine_id,
model_id: None,
language: None,
initial_prompt: None,
}
}
pub fn to_options(&self) -> TranscriptionOptions {
TranscriptionOptions {
language: self.language.clone(),
initial_prompt: self.initial_prompt.clone(),
}
}
}
/// Result returned by `TranscriptionProvider::transcribe`. Carries the
/// transcript plus inference timing so the orchestrator can surface
/// latency without losing it across the trait boundary.
#[derive(Debug, Clone)]
pub struct ProviderTranscript {
pub transcript: Transcript,
pub inference_ms: u64,
}
/// Async-native unified interface for transcription providers.
///
/// `Send + Sync` supertraits: providers live in an `Arc<dyn
/// TranscriptionProvider>` shared across tokio tasks. `async_trait`
/// macro converts the async methods into boxed futures so the trait
/// stays object-safe.
#[async_trait]
pub trait TranscriptionProvider: Send + Sync {
fn provider_id(&self) -> ProviderId;
fn kind(&self) -> ProviderKind;
fn capabilities(&self) -> ProviderCapabilities;
/// Idempotent pre-flight. Local providers verify the model is
/// loaded into memory; cloud providers validate credentials and
/// reach the endpoint. Called before the first `transcribe` of a
/// session, and again after a profile or settings change that
/// invalidates the previous prepare.
async fn prepare(&self, profile: &EngineProfile) -> Result<()>;
/// Transcribe a chunk of audio. The orchestrator passes raw audio
/// already resampled to the provider's `capabilities().sample_rate`.
async fn transcribe(
&self,
audio: AudioSamples,
options: TranscriptionOptions,
) -> Result<ProviderTranscript>;
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn provider_trait_is_object_safe() {
// Compile-time witness: if the trait stops being object-safe
// (generic method, Self-returning method, missing async_trait
// attribute) this declaration fails to build. No runtime work.
let _: Option<std::sync::Arc<dyn TranscriptionProvider>> = None;
}
#[test]
fn provider_id_round_trips_display_and_str() {
let id = ProviderId::new("local-whisper");
assert_eq!(id.as_str(), "local-whisper");
assert_eq!(id.to_string(), "local-whisper");
}
#[test]
fn engine_profile_derives_options() {
let profile = EngineProfile {
engine_id: ProviderId::new("local-whisper"),
model_id: None,
language: Some("en".to_string()),
initial_prompt: Some("Wyrdnote".to_string()),
};
let opts = profile.to_options();
assert_eq!(opts.language, Some("en".to_string()));
assert_eq!(opts.initial_prompt, Some("Wyrdnote".to_string()));
}
}

View File

@@ -31,30 +31,53 @@ pub enum MagnotiaError {
#[error("model download failed: {0}")] #[error("model download failed: {0}")]
DownloadFailed(String), DownloadFailed(String),
#[error("file not found: {}", .0.display())] #[error("file not found: '{}'", .0.display())]
FileNotFound(PathBuf), FileNotFound(PathBuf),
#[error("storage error: {0}")] /// Structured storage failure flowed up from `magnotia_storage::Error` via
StorageError(String), /// its `From` impl. Display reads through to `detail` so the operation +
/// source context produced by the storage crate isn't double-prefixed.
#[error("{detail}")]
Storage {
kind: StorageKind,
operation: String,
detail: String,
},
#[error("io error: {0}")] #[error("provider not registered: {0}")]
Io( ProviderNotRegistered(String),
#[from]
#[serde(serialize_with = "serialize_io_error")]
std::io::Error,
),
#[error("{0}")] #[error("io error ({kind}): {message}")]
Other(String), Io {
kind: String,
message: String,
raw_os_error: Option<i32>,
},
} }
/// Serialises `std::io::Error` as its display string, since it does /// Coarse discriminator for `MagnotiaError::Storage`. The storage crate maps
/// not implement `Serialize` natively. /// its full typed error onto one of these kinds at the boundary. Backend code
fn serialize_io_error<S: serde::Serializer>( /// that wants finer-grained branching pattern-matches on
err: &std::io::Error, /// `magnotia_storage::Error` directly.
s: S, #[derive(Debug, Clone, Copy, Serialize)]
) -> std::result::Result<S::Ok, S::Error> { #[serde(rename_all = "snake_case")]
s.serialize_str(&err.to_string()) pub enum StorageKind {
DatabaseOpen,
Migration,
Query,
NotFound,
InvalidReference,
Filesystem,
}
impl From<std::io::Error> for MagnotiaError {
fn from(err: std::io::Error) -> Self {
Self::Io {
kind: format!("{:?}", err.kind()),
message: err.to_string(),
raw_os_error: err.raw_os_error(),
}
}
} }
pub type Result<T> = std::result::Result<T, MagnotiaError>; pub type Result<T> = std::result::Result<T, MagnotiaError>;

View File

@@ -91,7 +91,10 @@ fn resolve_app_data_dir() -> PathBuf {
return PathBuf::from(xdg).join("magnotia"); return PathBuf::from(xdg).join("magnotia");
} }
} }
PathBuf::from(home).join(".local").join("share").join("magnotia") PathBuf::from(home)
.join(".local")
.join("share")
.join("magnotia")
} }
#[cfg(not(any(target_os = "windows", target_os = "macos", target_os = "linux")))] #[cfg(not(any(target_os = "windows", target_os = "macos", target_os = "linux")))]
@@ -112,7 +115,10 @@ mod tests {
let paths = AppPaths { let paths = AppPaths {
app_data_dir: PathBuf::from("/tmp/magnotia-test"), app_data_dir: PathBuf::from("/tmp/magnotia-test"),
}; };
assert_eq!(paths.database_path(), PathBuf::from("/tmp/magnotia-test/magnotia.db")); assert_eq!(
paths.database_path(),
PathBuf::from("/tmp/magnotia-test/magnotia.db")
);
assert_eq!( assert_eq!(
paths.speech_model_dir(&ModelId::new("whisper-base-en")), paths.speech_model_dir(&ModelId::new("whisper-base-en")),
PathBuf::from("/tmp/magnotia-test/models/whisper-base-en") PathBuf::from("/tmp/magnotia-test/models/whisper-base-en")

View File

@@ -162,7 +162,9 @@ static TEST_OVERRIDE: Mutex<Option<PowerState>> = Mutex::new(None);
#[cfg(test)] #[cfg(test)]
fn test_get_override() -> Option<PowerState> { fn test_get_override() -> Option<PowerState> {
*TEST_OVERRIDE.lock().expect("power test override mutex poisoned") *TEST_OVERRIDE
.lock()
.expect("power test override mutex poisoned")
} }
/// Drop-guard that resets `TEST_OVERRIDE` to `None` on drop (including on unwind). /// Drop-guard that resets `TEST_OVERRIDE` to `None` on drop (including on unwind).
@@ -174,7 +176,9 @@ struct OverrideGuard;
#[cfg(test)] #[cfg(test)]
impl Drop for OverrideGuard { impl Drop for OverrideGuard {
fn drop(&mut self) { fn drop(&mut self) {
*TEST_OVERRIDE.lock().expect("power test override mutex poisoned") = None; *TEST_OVERRIDE
.lock()
.expect("power test override mutex poisoned") = None;
} }
} }
@@ -188,7 +192,9 @@ impl Drop for OverrideGuard {
pub(crate) fn with_override<R>(state: Option<PowerState>, body: impl FnOnce() -> R) -> R { pub(crate) fn with_override<R>(state: Option<PowerState>, body: impl FnOnce() -> R) -> R {
static TEST_LOCK: Mutex<()> = Mutex::new(()); static TEST_LOCK: Mutex<()> = Mutex::new(());
let _lock = TEST_LOCK.lock().expect("power TEST_LOCK poisoned"); let _lock = TEST_LOCK.lock().expect("power TEST_LOCK poisoned");
*TEST_OVERRIDE.lock().expect("power test override mutex poisoned") = state; *TEST_OVERRIDE
.lock()
.expect("power test override mutex poisoned") = state;
let _guard = OverrideGuard; let _guard = OverrideGuard;
body() body()
} }
@@ -226,7 +232,10 @@ mod tests {
let dir = tempdir().unwrap(); let dir = tempdir().unwrap();
write_supply(dir.path(), "AC", "Mains", "0"); write_supply(dir.path(), "AC", "Mains", "0");
write_supply(dir.path(), "BAT0", "Battery", "0"); write_supply(dir.path(), "BAT0", "Battery", "0");
assert_eq!(parse_power_state_from_dir(dir.path()), PowerState::OnBattery); assert_eq!(
parse_power_state_from_dir(dir.path()),
PowerState::OnBattery
);
} }
#[test] #[test]

View File

@@ -39,8 +39,7 @@ impl ProcessLister {
/// Refresh the process table in place and return the current /// Refresh the process table in place and return the current
/// lowercased executable names. /// lowercased executable names.
pub fn snapshot(&mut self) -> Vec<String> { pub fn snapshot(&mut self) -> Vec<String> {
self.system self.system.refresh_processes(ProcessesToUpdate::All, true);
.refresh_processes(ProcessesToUpdate::All, true);
self.system self.system
.processes() .processes()
.values() .values()

View File

@@ -49,7 +49,7 @@ pub fn score_model(model: &'static ModelEntry, profile: &SystemProfile) -> Optio
} }
let headroom = Megabytes(profile.ram.0.saturating_sub(model.ram_required.0)); let headroom = Megabytes(profile.ram.0.saturating_sub(model.ram_required.0));
if headroom > Megabytes::from_gb(4.0) { if headroom > Megabytes::from_gb(4) {
score += 10.0; score += 10.0;
} }

View File

@@ -113,7 +113,9 @@ mod tests {
fn with_thread_env_lock<R>(body: impl FnOnce() -> R) -> R { fn with_thread_env_lock<R>(body: impl FnOnce() -> R) -> R {
use std::sync::Mutex; use std::sync::Mutex;
static THREAD_ENV_LOCK: Mutex<()> = Mutex::new(()); static THREAD_ENV_LOCK: Mutex<()> = Mutex::new(());
let _lock = THREAD_ENV_LOCK.lock().expect("tuning thread-env lock poisoned"); let _lock = THREAD_ENV_LOCK
.lock()
.expect("tuning thread-env lock poisoned");
body() body()
} }
@@ -126,8 +128,10 @@ mod tests {
with_thread_env_lock(|| { with_thread_env_lock(|| {
std::env::remove_var("MAGNOTIA_INFERENCE_THREADS"); std::env::remove_var("MAGNOTIA_INFERENCE_THREADS");
let n = inference_thread_count(Workload::Llm, false); let n = inference_thread_count(Workload::Llm, false);
assert!(n >= MIN_INFERENCE_THREADS && n <= MAX_INFERENCE_THREADS, assert!(
"got {n}, expected within [{MIN_INFERENCE_THREADS}, {MAX_INFERENCE_THREADS}]"); (MIN_INFERENCE_THREADS..=MAX_INFERENCE_THREADS).contains(&n),
"got {n}, expected within [{MIN_INFERENCE_THREADS}, {MAX_INFERENCE_THREADS}]"
);
}); });
} }
@@ -175,8 +179,10 @@ mod tests {
std::env::remove_var("MAGNOTIA_INFERENCE_THREADS"); std::env::remove_var("MAGNOTIA_INFERENCE_THREADS");
crate::power::with_override(Some(PowerState::OnAc), || { crate::power::with_override(Some(PowerState::OnAc), || {
let n = inference_thread_count(Workload::Llm, true); let n = inference_thread_count(Workload::Llm, true);
assert!(n <= GPU_FLOOR_LLM.max(MIN_INFERENCE_THREADS), assert!(
"Llm with GPU offload should clamp to {GPU_FLOOR_LLM}, got {n}"); n <= GPU_FLOOR_LLM.max(MIN_INFERENCE_THREADS),
"Llm with GPU offload should clamp to {GPU_FLOOR_LLM}, got {n}"
);
assert!(n >= MIN_INFERENCE_THREADS); assert!(n >= MIN_INFERENCE_THREADS);
}); });
}); });
@@ -196,11 +202,12 @@ mod tests {
}); });
} }
const _: () = assert!(GPU_FLOOR_WHISPER >= GPU_FLOOR_LLM);
#[test] #[test]
fn whisper_gpu_floor_is_at_least_llm_gpu_floor() { fn whisper_gpu_floor_is_at_least_llm_gpu_floor() {
// Architectural invariant: whisper retains CPU work even when // Architectural invariant: whisper retains CPU work even when
// GPU-offloaded; its floor must not be lower than the LLM's. // GPU-offloaded; its floor must not be lower than the LLM's.
assert!(GPU_FLOOR_WHISPER >= GPU_FLOOR_LLM);
} }
#[test] #[test]

View File

@@ -1,11 +1,18 @@
use serde::{Deserialize, Serialize}; use std::borrow::Cow;
use std::num::NonZeroU32;
use serde::{Deserialize, Deserializer, Serialize};
/// Prevents passing raw strings where model IDs are expected. /// Prevents passing raw strings where model IDs are expected.
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize)]
pub struct ModelId(String); pub struct ModelId(Cow<'static, str>);
impl ModelId { impl ModelId {
pub fn new(id: impl Into<String>) -> Self { pub const fn borrowed(id: &'static str) -> Self {
Self(Cow::Borrowed(id))
}
pub fn new(id: impl Into<Cow<'static, str>>) -> Self {
Self(id.into()) Self(id.into())
} }
@@ -14,6 +21,15 @@ impl ModelId {
} }
} }
impl<'de> Deserialize<'de> for ModelId {
fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
where
D: Deserializer<'de>,
{
String::deserialize(deserializer).map(|s| Self(Cow::Owned(s)))
}
}
impl std::fmt::Display for ModelId { impl std::fmt::Display for ModelId {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.0) f.write_str(&self.0)
@@ -21,11 +37,15 @@ impl std::fmt::Display for ModelId {
} }
/// Prevents passing raw strings where engine names are expected. /// Prevents passing raw strings where engine names are expected.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct EngineName(String); pub struct EngineName(Cow<'static, str>);
impl EngineName { impl EngineName {
pub fn new(name: impl Into<String>) -> Self { pub const fn borrowed(name: &'static str) -> Self {
Self(Cow::Borrowed(name))
}
pub fn new(name: impl Into<Cow<'static, str>>) -> Self {
Self(name.into()) Self(name.into())
} }
@@ -34,6 +54,15 @@ impl EngineName {
} }
} }
impl<'de> Deserialize<'de> for EngineName {
fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
where
D: Deserializer<'de>,
{
String::deserialize(deserializer).map(|s| Self(Cow::Owned(s)))
}
}
impl std::fmt::Display for EngineName { impl std::fmt::Display for EngineName {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.0) f.write_str(&self.0)
@@ -45,8 +74,12 @@ impl std::fmt::Display for EngineName {
pub struct Megabytes(pub u64); pub struct Megabytes(pub u64);
impl Megabytes { impl Megabytes {
pub fn from_gb(gb: f64) -> Self { pub const fn from_gb(gb: u64) -> Self {
Self((gb * 1024.0) as u64) Self(gb.saturating_mul(1024))
}
pub const fn from_mb(mb: u64) -> Self {
Self(mb)
} }
pub fn as_gb(&self) -> f64 { pub fn as_gb(&self) -> f64 {
@@ -68,23 +101,36 @@ impl std::fmt::Display for Megabytes {
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct AudioSamples { pub struct AudioSamples {
samples: Vec<f32>, samples: Vec<f32>,
sample_rate: u32, sample_rate: NonZeroU32,
channels: u16, channels: u16,
} }
impl AudioSamples { impl AudioSamples {
pub fn new(samples: Vec<f32>, sample_rate: u32, channels: u16) -> Self { pub fn new(samples: Vec<f32>, sample_rate: u32, channels: u16) -> Self {
Self { Self::try_new(samples, sample_rate, channels)
.expect("AudioSamples sample_rate must be non-zero")
}
pub fn try_new(
samples: Vec<f32>,
sample_rate: u32,
channels: u16,
) -> std::result::Result<Self, &'static str> {
let Some(sample_rate) = NonZeroU32::new(sample_rate) else {
return Err("sample_rate must be non-zero");
};
Ok(Self {
samples, samples,
sample_rate, sample_rate,
channels, channels,
} })
} }
pub fn mono_16khz(samples: Vec<f32>) -> Self { pub fn mono_16khz(samples: Vec<f32>) -> Self {
Self { Self {
samples, samples,
sample_rate: crate::constants::WHISPER_SAMPLE_RATE, sample_rate: NonZeroU32::new(crate::constants::WHISPER_SAMPLE_RATE)
.expect("WHISPER_SAMPLE_RATE must be non-zero"),
channels: crate::constants::WHISPER_CHANNELS, channels: crate::constants::WHISPER_CHANNELS,
} }
} }
@@ -98,7 +144,7 @@ impl AudioSamples {
} }
pub fn sample_rate(&self) -> u32 { pub fn sample_rate(&self) -> u32 {
self.sample_rate self.sample_rate.get()
} }
pub fn channels(&self) -> u16 { pub fn channels(&self) -> u16 {
@@ -106,10 +152,7 @@ impl AudioSamples {
} }
pub fn duration_secs(&self) -> f64 { pub fn duration_secs(&self) -> f64 {
if self.sample_rate == 0 { self.samples.len() as f64 / self.sample_rate.get() as f64
return 0.0;
}
self.samples.len() as f64 / self.sample_rate as f64
} }
} }

View File

@@ -8,7 +8,7 @@ description = "Wayland-compatible global hotkey listener for Magnotia — evdev
magnotia-core = { path = "../core" } magnotia-core = { path = "../core" }
tokio = { version = "1", features = ["rt", "sync", "macros", "time"] } tokio = { version = "1", features = ["rt", "sync", "macros", "time"] }
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
log = "0.4" tracing = "0.1"
[target.'cfg(target_os = "linux")'.dependencies] [target.'cfg(target_os = "linux")'.dependencies]
evdev = { version = "0.12", features = ["tokio"] } evdev = { version = "0.12", features = ["tokio"] }

View File

@@ -88,19 +88,19 @@ impl EvdevHotkeyListener {
match w.watch(Path::new("/dev/input"), RecursiveMode::NonRecursive) { match w.watch(Path::new("/dev/input"), RecursiveMode::NonRecursive) {
Ok(()) => Some(w), Ok(()) => Some(w),
Err(e) => { Err(e) => {
eprintln!( tracing::warn!(
"[magnotia-hotkey] cannot watch /dev/input ({e}); \ error = %e,
hotplug detection disabled, devices present \ "cannot watch /dev/input; hotplug detection disabled, \
at startup still work", devices present at startup still work"
); );
None None
} }
} }
} }
Err(e) => { Err(e) => {
eprintln!( tracing::warn!(
"[magnotia-hotkey] cannot create inotify watcher ({e}); \ error = %e,
hotplug detection disabled", "cannot create inotify watcher; hotplug detection disabled"
); );
None None
} }
@@ -199,7 +199,7 @@ async fn scan_and_attach(
let entries = match std::fs::read_dir(input_dir) { let entries = match std::fs::read_dir(input_dir) {
Ok(e) => e, Ok(e) => e,
Err(e) => { Err(e) => {
log::error!("Cannot read /dev/input: {e}"); tracing::error!(error = %e, "cannot read /dev/input");
return; return;
} }
}; };
@@ -233,7 +233,7 @@ async fn try_attach_device(
let device = match Device::open(path) { let device = match Device::open(path) {
Ok(d) => d, Ok(d) => d,
Err(e) => { Err(e) => {
log::debug!("Cannot open {}: {e}", path.display()); tracing::debug!(path = %path.display(), error = %e, "cannot open device");
return false; return false;
} }
}; };
@@ -243,10 +243,10 @@ async fn try_attach_device(
} }
let device_name = device.name().unwrap_or("unknown").to_string(); let device_name = device.name().unwrap_or("unknown").to_string();
log::info!( tracing::info!(
"Attached hotkey listener to: {} ({})", device = %device_name,
device_name, path = %path.display(),
path.display() "attached hotkey listener"
); );
tracked_set.insert(path.to_path_buf()); tracked_set.insert(path.to_path_buf());
@@ -260,7 +260,11 @@ async fn try_attach_device(
tokio::spawn(async move { tokio::spawn(async move {
if let Err(e) = device_listener(device, hotkey_rx, event_tx).await { if let Err(e) = device_listener(device, hotkey_rx, event_tx).await {
log::warn!("Device listener for {} ended: {e}", path_owned.display()); tracing::warn!(
path = %path_owned.display(),
error = %e,
"device listener ended"
);
} }
// Remove from tracked set so hotplug can re-attach if reconnected // Remove from tracked set so hotplug can re-attach if reconnected
tracked.lock().await.remove(&path_owned); tracked.lock().await.remove(&path_owned);
@@ -331,8 +335,8 @@ async fn device_listener(
// shutdown. Log once and exit so // shutdown. Log once and exit so
// the listener doesn't spin // the listener doesn't spin
// sending into a closed channel. // sending into a closed channel.
log::warn!( tracing::warn!(
"Hotkey event channel closed; \ "hotkey event channel closed; \
listener for device exiting" listener for device exiting"
); );
return Ok(()); return Ok(());

View File

@@ -19,7 +19,7 @@ pub struct EvdevHotkeyListener;
impl EvdevHotkeyListener { impl EvdevHotkeyListener {
pub fn start(_combo: HotkeyCombo, _event_tx: mpsc::Sender<HotkeyEvent>) -> Self { pub fn start(_combo: HotkeyCombo, _event_tx: mpsc::Sender<HotkeyEvent>) -> Self {
log::info!("evdev hotkey listener is a no-op on this platform"); tracing::info!("evdev hotkey listener is a no-op on this platform");
Self Self
} }

View File

@@ -19,7 +19,7 @@ openmp = ["llama-cpp-2/openmp"]
magnotia-core = { path = "../core" } magnotia-core = { path = "../core" }
encoding_rs = "0.8" encoding_rs = "0.8"
futures-util = "0.3" futures-util = "0.3"
llama-cpp-2 = { version = "0.1.144", default-features = false } llama-cpp-2 = { version = "0.1.146", default-features = false }
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "stream"] } reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "stream"] }
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
serde_json = "1" serde_json = "1"

View File

@@ -172,8 +172,8 @@ impl LlmEngine {
// follow-up in docs/superpowers/specs/2026-05-09-battery-gpu-aware- // follow-up in docs/superpowers/specs/2026-05-09-battery-gpu-aware-
// thread-tuning-design.md (§ Out of scope). // thread-tuning-design.md (§ Out of scope).
let gpu_offloaded = use_gpu && gpu_layers >= model.n_layer(); let gpu_offloaded = use_gpu && gpu_layers >= model.n_layer();
let thread_count = i32::try_from(inference_thread_count(Workload::Llm, gpu_offloaded)) let thread_count =
.unwrap_or(4); i32::try_from(inference_thread_count(Workload::Llm, gpu_offloaded)).unwrap_or(4);
let ctx_params = LlamaContextParams::default() let ctx_params = LlamaContextParams::default()
.with_n_ctx(Some( .with_n_ctx(Some(
NonZeroU32::new(n_ctx).expect("n_ctx must be non-zero"), NonZeroU32::new(n_ctx).expect("n_ctx must be non-zero"),
@@ -212,6 +212,10 @@ impl LlmEngine {
generated.push_str(&piece); generated.push_str(&piece);
sampler.accept(next); sampler.accept(next);
if config.grammar.is_some() && json_envelope_complete(&generated) {
break;
}
if let Some(stop_index) = first_stop_index(&generated, &config.stop_sequences) { if let Some(stop_index) = first_stop_index(&generated, &config.stop_sequences) {
generated.truncate(stop_index); generated.truncate(stop_index);
break; break;
@@ -296,13 +300,12 @@ impl LlmEngine {
} }
/// Phase 9 content-tag extraction. Emits a single (topic, intent) /// Phase 9 content-tag extraction. Emits a single (topic, intent)
/// pair under the `CONTENT_TAGS_GRAMMAR` GBNF. Truncates to the /// pair as JSON. Truncates to the trailing 2000 chars of the
/// trailing 2000 chars of the transcript so the prompt budget /// transcript so the prompt budget stays well under any model's
/// stays well under any model's context window. Determinism is /// context window. Determinism is enforced by temperature 0.0;
/// enforced by temperature 0.0 and the closed-set intent grammar /// the parsed intent is re-validated with `is_valid_intent` and
/// rule; on the rare case the model emits a parse-able-but-out-of- /// invalid JSON bubbles as `InvalidJson` so the frontend toasts a
/// set intent, we re-validate with `is_valid_intent` and bubble /// clear error.
/// `InvalidJson` so the frontend toasts a clear error.
pub fn extract_content_tags( pub fn extract_content_tags(
&self, &self,
transcript: &str, transcript: &str,
@@ -338,12 +341,11 @@ impl LlmEngine {
max_tokens: 96, max_tokens: 96,
temperature: 0.0, temperature: 0.0,
stop_sequences: vec!["<|im_end|>".to_string(), "<|im_end_of_text|>".to_string()], stop_sequences: vec!["<|im_end|>".to_string(), "<|im_end_of_text|>".to_string()],
grammar: Some(grammars::CONTENT_TAGS_GRAMMAR.to_string()), grammar: None,
}, },
)?; )?;
let tags: prompts::ContentTags = serde_json::from_str(raw.trim()) let tags: prompts::ContentTags = parse_json_payload(&raw)?;
.map_err(|e| EngineError::InvalidJson(format!("{e}: raw={raw:?}")))?;
if !prompts::is_valid_intent(&tags.intent) { if !prompts::is_valid_intent(&tags.intent) {
return Err(EngineError::InvalidJson(format!( return Err(EngineError::InvalidJson(format!(
"intent out of closed set: {}", "intent out of closed set: {}",
@@ -459,6 +461,62 @@ fn first_stop_index(text: &str, stop_sequences: &[String]) -> Option<usize> {
.min() .min()
} }
fn json_envelope_complete(text: &str) -> bool {
extract_json_envelope(text) == Some(text.trim())
}
fn extract_json_envelope(text: &str) -> Option<&str> {
let start = text
.char_indices()
.find_map(|(idx, ch)| (ch == '{' || ch == '[').then_some(idx))?;
let mut chars = text[start..].char_indices();
let (_, first) = chars.next()?;
let mut stack = vec![match first {
'{' => '}',
'[' => ']',
_ => unreachable!(),
}];
let mut in_string = false;
let mut escaped = false;
while let Some((offset, ch)) = chars.next() {
if in_string {
if escaped {
escaped = false;
} else if ch == '\\' {
escaped = true;
} else if ch == '"' {
in_string = false;
}
continue;
}
match ch {
'"' => in_string = true,
'{' => stack.push('}'),
'[' => stack.push(']'),
'}' | ']' => {
if stack.pop() != Some(ch) {
return None;
}
if stack.is_empty() {
let end = start + offset + ch.len_utf8();
return Some(&text[start..end]);
}
}
_ => {}
}
}
None
}
fn parse_json_payload<T: for<'de> Deserialize<'de>>(raw: &str) -> Result<T, EngineError> {
let payload = extract_json_envelope(raw).unwrap_or(raw.trim());
serde_json::from_str(payload).map_err(|e| EngineError::InvalidJson(format!("{e}: raw={raw:?}")))
}
fn render_chat_prompt( fn render_chat_prompt(
model: &LlamaModel, model: &LlamaModel,
messages: &[(&str, &str)], messages: &[(&str, &str)],
@@ -548,6 +606,47 @@ mod tests {
assert_eq!(index, Some(5)); assert_eq!(index, Some(5));
} }
#[test]
fn json_envelope_complete_detects_finished_object() {
assert!(json_envelope_complete(
r#"{"topic":"meeting","intent":"planning"}"#
));
}
#[test]
fn json_envelope_complete_detects_finished_array() {
assert!(json_envelope_complete(r#"["Call plumber","Buy milk"]"#));
}
#[test]
fn json_envelope_complete_ignores_braces_inside_strings() {
assert!(!json_envelope_complete(r#"{"topic":"literal } brace""#));
}
#[test]
fn json_envelope_complete_rejects_prefixes_and_trailing_text() {
assert!(!json_envelope_complete(r#"{"topic":"meeting""#));
assert!(!json_envelope_complete(r#"{"topic":"meeting"} extra"#));
}
#[test]
fn extract_json_envelope_skips_qwen_thinking_prefix() {
let raw =
"<think>\n\n</think>\n\n{\"topic\":\"grant-application\",\"intent\":\"planning\"}";
assert_eq!(
extract_json_envelope(raw),
Some("{\"topic\":\"grant-application\",\"intent\":\"planning\"}"),
);
}
#[test]
fn extract_json_envelope_handles_arrays_and_trailing_stop_text() {
assert_eq!(
extract_json_envelope("prefix [\"Call plumber\",\"Buy milk\"]<|im_end|>"),
Some("[\"Call plumber\",\"Buy milk\"]"),
);
}
#[test] #[test]
fn prompt_preflight_rejects_oversized_prompt_tokens() { fn prompt_preflight_rejects_oversized_prompt_tokens() {
let err = preflight_context_window(7_105, 1_024).unwrap_err(); let err = preflight_context_window(7_105, 1_024).unwrap_err();

View File

@@ -24,5 +24,8 @@ serde = { version = "1", features = ["derive"] }
# Logging # Logging
log = "0.4" log = "0.4"
# Structured error derivation for magnotia_storage::Error.
thiserror = "1"
# UUIDs for profile + profile_terms ids (v7 random). # UUIDs for profile + profile_terms ids (v7 random).
uuid = { version = "1", features = ["v4"] } uuid = { version = "1", features = ["v4"] }

View File

@@ -3,12 +3,15 @@ use std::path::Path;
use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions}; use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions};
use sqlx::{Row, SqlitePool}; use sqlx::{Row, SqlitePool};
use magnotia_core::error::{MagnotiaError, Result}; use crate::error::{Entity, Error, OpenOp, Result};
/// Initialise the SQLite database with connection pool and run migrations. /// Initialise the SQLite database with connection pool and run migrations.
pub async fn init(db_path: &Path) -> Result<SqlitePool> { pub async fn init(db_path: &Path) -> Result<SqlitePool> {
if let Some(parent) = db_path.parent() { if let Some(parent) = db_path.parent() {
std::fs::create_dir_all(parent)?; std::fs::create_dir_all(parent).map_err(|source| Error::Filesystem {
path: parent.to_path_buf(),
source,
})?;
} }
let options = SqliteConnectOptions::new() let options = SqliteConnectOptions::new()
@@ -19,12 +22,18 @@ pub async fn init(db_path: &Path) -> Result<SqlitePool> {
.max_connections(5) .max_connections(5)
.connect_with(options) .connect_with(options)
.await .await
.map_err(|e| MagnotiaError::StorageError(format!("Database connect failed: {e}")))?; .map_err(|source| Error::DatabaseOpen {
operation: OpenOp::Connect,
source,
})?;
sqlx::query("PRAGMA foreign_keys = ON") sqlx::query("PRAGMA foreign_keys = ON")
.execute(&pool) .execute(&pool)
.await .await
.map_err(|e| MagnotiaError::StorageError(format!("foreign_keys pragma failed: {e}")))?; .map_err(|source| Error::DatabaseOpen {
operation: OpenOp::ForeignKeysPragma,
source,
})?;
run_migrations(&pool).await?; run_migrations(&pool).await?;
@@ -47,7 +56,10 @@ pub async fn init_readonly(db_path: &Path) -> Result<SqlitePool> {
.max_connections(2) .max_connections(2)
.connect_with(options) .connect_with(options)
.await .await
.map_err(|e| MagnotiaError::StorageError(format!("Read-only connect failed: {e}"))) .map_err(|source| Error::DatabaseOpen {
operation: OpenOp::ReadOnlyConnect,
source,
})
} }
/// Run schema migrations via the versioned migration system. /// Run schema migrations via the versioned migration system.
@@ -82,10 +94,10 @@ pub async fn insert_transcript(
params: &InsertTranscriptParams<'_>, params: &InsertTranscriptParams<'_>,
) -> Result<()> { ) -> Result<()> {
if !profile_exists(pool, params.profile_id).await? { if !profile_exists(pool, params.profile_id).await? {
return Err(MagnotiaError::StorageError(format!( return Err(Error::InvalidReference {
"Insert transcript failed: unknown profile id '{}'", entity: Entity::Profile,
params.profile_id reason: format!("unknown profile id '{}'", params.profile_id).into(),
))); });
} }
sqlx::query( sqlx::query(
@@ -110,7 +122,10 @@ pub async fn insert_transcript(
.bind(params.anti_hallucination) .bind(params.anti_hallucination)
.execute(pool) .execute(pool)
.await .await
.map_err(|e| MagnotiaError::StorageError(format!("Insert transcript failed: {e}")))?; .map_err(|source| Error::Query {
operation: "insert_transcript".into(),
source,
})?;
Ok(()) Ok(())
} }
@@ -121,7 +136,10 @@ pub async fn get_transcript(pool: &SqlitePool, id: &str) -> Result<Option<Transc
.bind(id) .bind(id)
.fetch_optional(pool) .fetch_optional(pool)
.await .await
.map_err(|e| MagnotiaError::StorageError(format!("Get transcript failed: {e}")))?; .map_err(|source| Error::Query {
operation: "get_transcript".into(),
source,
})?;
Ok(row.map(|r| transcript_row_from(&r))) Ok(row.map(|r| transcript_row_from(&r)))
} }
@@ -144,7 +162,10 @@ pub async fn list_transcripts_paged(
.bind(offset) .bind(offset)
.fetch_all(pool) .fetch_all(pool)
.await .await
.map_err(|e| MagnotiaError::StorageError(format!("List transcripts failed: {e}")))?; .map_err(|source| Error::Query {
operation: "list_transcripts".into(),
source,
})?;
Ok(rows.iter().map(transcript_row_from).collect()) Ok(rows.iter().map(transcript_row_from).collect())
} }
@@ -154,7 +175,10 @@ pub async fn count_transcripts(pool: &SqlitePool) -> Result<i64> {
let n: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM transcripts") let n: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM transcripts")
.fetch_one(pool) .fetch_one(pool)
.await .await
.map_err(|e| MagnotiaError::StorageError(format!("Count transcripts failed: {e}")))?; .map_err(|source| Error::Query {
operation: "count_transcripts".into(),
source,
})?;
Ok(n) Ok(n)
} }
@@ -182,7 +206,10 @@ pub async fn update_transcript(
.bind(id) .bind(id)
.execute(pool) .execute(pool)
.await .await
.map_err(|e| MagnotiaError::StorageError(format!("Update transcript failed: {e}")))?; .map_err(|source| Error::Query {
operation: "update_transcript".into(),
source,
})?;
Ok(res.rows_affected()) Ok(res.rows_affected())
} }
(Some(t), None) => { (Some(t), None) => {
@@ -191,7 +218,10 @@ pub async fn update_transcript(
.bind(id) .bind(id)
.execute(pool) .execute(pool)
.await .await
.map_err(|e| MagnotiaError::StorageError(format!("Update transcript failed: {e}")))?; .map_err(|source| Error::Query {
operation: "update_transcript".into(),
source,
})?;
Ok(res.rows_affected()) Ok(res.rows_affected())
} }
(None, Some(ttl)) => { (None, Some(ttl)) => {
@@ -200,7 +230,10 @@ pub async fn update_transcript(
.bind(id) .bind(id)
.execute(pool) .execute(pool)
.await .await
.map_err(|e| MagnotiaError::StorageError(format!("Update transcript failed: {e}")))?; .map_err(|source| Error::Query {
operation: "update_transcript".into(),
source,
})?;
Ok(res.rows_affected()) Ok(res.rows_affected())
} }
(None, None) => Ok(0), (None, None) => Ok(0),
@@ -247,11 +280,17 @@ pub async fn update_transcript_meta(
.bind(id) .bind(id)
.execute(pool) .execute(pool)
.await .await
.map_err(|e| MagnotiaError::StorageError(format!("update_transcript_meta: {e}")))?; .map_err(|source| Error::Query {
operation: "update_transcript_meta".into(),
source,
})?;
get_transcript(pool, id).await?.ok_or_else(|| { get_transcript(pool, id)
MagnotiaError::StorageError(format!("update_transcript_meta: transcript {id} not found")) .await?
}) .ok_or_else(|| Error::NotFound {
entity: Entity::Transcript,
key: id.to_string(),
})
} }
pub async fn delete_transcript(pool: &SqlitePool, id: &str) -> Result<()> { pub async fn delete_transcript(pool: &SqlitePool, id: &str) -> Result<()> {
@@ -259,7 +298,10 @@ pub async fn delete_transcript(pool: &SqlitePool, id: &str) -> Result<()> {
.bind(id) .bind(id)
.execute(pool) .execute(pool)
.await .await
.map_err(|e| MagnotiaError::StorageError(format!("Delete transcript failed: {e}")))?; .map_err(|source| Error::Query {
operation: "delete_transcript".into(),
source,
})?;
Ok(()) Ok(())
} }
@@ -283,7 +325,10 @@ pub async fn search_transcripts(
.bind(limit) .bind(limit)
.fetch_all(pool) .fetch_all(pool)
.await .await
.map_err(|e| MagnotiaError::StorageError(format!("FTS search failed: {e}")))?; .map_err(|source| Error::Query {
operation: "search_transcripts".into(),
source,
})?;
Ok(rows.iter().map(transcript_row_from).collect()) Ok(rows.iter().map(transcript_row_from).collect())
} }
@@ -321,7 +366,10 @@ pub async fn insert_task(
.bind(energy) .bind(energy)
.execute(pool) .execute(pool)
.await .await
.map_err(|e| MagnotiaError::StorageError(format!("Insert task failed: {e}")))?; .map_err(|source| Error::Query {
operation: "insert_task".into(),
source,
})?;
Ok(()) Ok(())
} }
@@ -333,7 +381,10 @@ pub async fn list_tasks(pool: &SqlitePool) -> Result<Vec<TaskRow>> {
) )
.fetch_all(pool) .fetch_all(pool)
.await .await
.map_err(|e| MagnotiaError::StorageError(format!("List tasks failed: {e}")))?; .map_err(|source| Error::Query {
operation: "list_tasks".into(),
source,
})?;
Ok(rows.into_iter().map(task_row_from).collect()) Ok(rows.into_iter().map(task_row_from).collect())
} }
@@ -346,7 +397,10 @@ pub async fn get_task_by_id(pool: &SqlitePool, id: &str) -> Result<Option<TaskRo
.bind(id) .bind(id)
.fetch_optional(pool) .fetch_optional(pool)
.await .await
.map_err(|e| MagnotiaError::StorageError(format!("Get task failed: {e}")))?; .map_err(|source| Error::Query {
operation: "get_task_by_id".into(),
source,
})?;
Ok(row.map(task_row_from)) Ok(row.map(task_row_from))
} }
@@ -384,11 +438,17 @@ pub async fn update_task(
.bind(id) .bind(id)
.execute(pool) .execute(pool)
.await .await
.map_err(|e| MagnotiaError::StorageError(format!("Update task failed: {e}")))?; .map_err(|source| Error::Query {
operation: "update_task".into(),
source,
})?;
get_task_by_id(pool, id).await?.ok_or_else(|| { get_task_by_id(pool, id)
MagnotiaError::StorageError(format!("update_task: task {id} not found after update")) .await?
}) .ok_or_else(|| Error::NotFound {
entity: Entity::Task,
key: id.to_string(),
})
} }
/// Dedicated tri-state energy setter. Exists as its own function because /// Dedicated tri-state energy setter. Exists as its own function because
@@ -405,11 +465,17 @@ pub async fn set_task_energy(pool: &SqlitePool, id: &str, energy: Option<&str>)
.bind(id) .bind(id)
.execute(pool) .execute(pool)
.await .await
.map_err(|e| MagnotiaError::StorageError(format!("set_task_energy failed: {e}")))?; .map_err(|source| Error::Query {
operation: "set_task_energy".into(),
source,
})?;
get_task_by_id(pool, id).await?.ok_or_else(|| { get_task_by_id(pool, id)
MagnotiaError::StorageError(format!("set_task_energy: task {id} not found after update")) .await?
}) .ok_or_else(|| Error::NotFound {
entity: Entity::Task,
key: id.to_string(),
})
} }
pub async fn insert_subtask( pub async fn insert_subtask(
@@ -424,7 +490,10 @@ pub async fn insert_subtask(
.bind(parent_task_id) .bind(parent_task_id)
.execute(pool) .execute(pool)
.await .await
.map_err(|e| MagnotiaError::StorageError(format!("Insert subtask failed: {e}")))?; .map_err(|source| Error::Query {
operation: "insert_subtask".into(),
source,
})?;
Ok(()) Ok(())
} }
@@ -437,7 +506,10 @@ pub async fn list_subtasks(pool: &SqlitePool, parent_id: &str) -> Result<Vec<Tas
.bind(parent_id) .bind(parent_id)
.fetch_all(pool) .fetch_all(pool)
.await .await
.map_err(|e| MagnotiaError::StorageError(format!("List subtasks failed: {e}")))?; .map_err(|source| Error::Query {
operation: "list_subtasks".into(),
source,
})?;
Ok(rows.into_iter().map(task_row_from).collect()) Ok(rows.into_iter().map(task_row_from).collect())
} }
@@ -445,23 +517,29 @@ pub async fn list_subtasks(pool: &SqlitePool, parent_id: &str) -> Result<Vec<Tas
/// Mark a subtask done. If all siblings are now done, auto-complete the parent. /// Mark a subtask done. If all siblings are now done, auto-complete the parent.
/// Runs in a transaction so concurrent completions see consistent sibling counts. /// Runs in a transaction so concurrent completions see consistent sibling counts.
pub async fn complete_subtask_and_check_parent(pool: &SqlitePool, subtask_id: &str) -> Result<()> { pub async fn complete_subtask_and_check_parent(pool: &SqlitePool, subtask_id: &str) -> Result<()> {
let mut tx = pool let mut tx = pool.begin().await.map_err(|source| Error::Query {
.begin() operation: "complete_subtask_and_check_parent.begin_transaction".into(),
.await source,
.map_err(|e| MagnotiaError::StorageError(format!("Begin transaction failed: {e}")))?; })?;
sqlx::query("UPDATE tasks SET done = 1, done_at = datetime('now') WHERE id = ?") sqlx::query("UPDATE tasks SET done = 1, done_at = datetime('now') WHERE id = ?")
.bind(subtask_id) .bind(subtask_id)
.execute(&mut *tx) .execute(&mut *tx)
.await .await
.map_err(|e| MagnotiaError::StorageError(format!("Complete subtask failed: {e}")))?; .map_err(|source| Error::Query {
operation: "complete_subtask_and_check_parent.complete_subtask".into(),
source,
})?;
let parent_id: Option<String> = let parent_id: Option<String> =
sqlx::query_scalar("SELECT parent_task_id FROM tasks WHERE id = ?") sqlx::query_scalar("SELECT parent_task_id FROM tasks WHERE id = ?")
.bind(subtask_id) .bind(subtask_id)
.fetch_one(&mut *tx) .fetch_one(&mut *tx)
.await .await
.map_err(|e| MagnotiaError::StorageError(format!("Get parent_task_id failed: {e}")))?; .map_err(|source| Error::Query {
operation: "complete_subtask_and_check_parent.get_parent_task_id".into(),
source,
})?;
if let Some(pid) = parent_id { if let Some(pid) = parent_id {
let pending: i64 = let pending: i64 =
@@ -469,8 +547,9 @@ pub async fn complete_subtask_and_check_parent(pool: &SqlitePool, subtask_id: &s
.bind(&pid) .bind(&pid)
.fetch_one(&mut *tx) .fetch_one(&mut *tx)
.await .await
.map_err(|e| { .map_err(|source| Error::Query {
MagnotiaError::StorageError(format!("Count pending subtasks failed: {e}")) operation: "complete_subtask_and_check_parent.count_pending_subtasks".into(),
source,
})?; })?;
if pending == 0 { if pending == 0 {
@@ -484,13 +563,17 @@ pub async fn complete_subtask_and_check_parent(pool: &SqlitePool, subtask_id: &s
.bind(&pid) .bind(&pid)
.execute(&mut *tx) .execute(&mut *tx)
.await .await
.map_err(|e| MagnotiaError::StorageError(format!("Auto-complete parent failed: {e}")))?; .map_err(|source| Error::Query {
operation: "complete_subtask_and_check_parent.auto_complete_parent".into(),
source,
})?;
} }
} }
tx.commit() tx.commit().await.map_err(|source| Error::Query {
.await operation: "complete_subtask_and_check_parent.commit_transaction".into(),
.map_err(|e| MagnotiaError::StorageError(format!("Commit transaction failed: {e}")))?; source,
})?;
Ok(()) Ok(())
} }
@@ -500,21 +583,27 @@ pub async fn complete_task(pool: &SqlitePool, id: &str) -> Result<()> {
.bind(id) .bind(id)
.execute(pool) .execute(pool)
.await .await
.map_err(|e| MagnotiaError::StorageError(format!("Complete task failed: {e}")))?; .map_err(|source| Error::Query {
operation: "complete_task".into(),
source,
})?;
Ok(()) Ok(())
} }
pub async fn uncomplete_task(pool: &SqlitePool, id: &str) -> Result<()> { pub async fn uncomplete_task(pool: &SqlitePool, id: &str) -> Result<()> {
let mut tx = pool let mut tx = pool.begin().await.map_err(|source| Error::Query {
.begin() operation: "uncomplete_task.begin_transaction".into(),
.await source,
.map_err(|e| MagnotiaError::StorageError(format!("Begin transaction failed: {e}")))?; })?;
sqlx::query("UPDATE tasks SET done = 0, done_at = NULL, auto_completed = 0 WHERE id = ?") sqlx::query("UPDATE tasks SET done = 0, done_at = NULL, auto_completed = 0 WHERE id = ?")
.bind(id) .bind(id)
.execute(&mut *tx) .execute(&mut *tx)
.await .await
.map_err(|e| MagnotiaError::StorageError(format!("Uncomplete task failed: {e}")))?; .map_err(|source| Error::Query {
operation: "uncomplete_task.uncomplete_row".into(),
source,
})?;
// Mirror the auto-complete invariant from // Mirror the auto-complete invariant from
// `complete_subtask_and_check_parent`: a parent task is done iff // `complete_subtask_and_check_parent`: a parent task is done iff
@@ -526,7 +615,10 @@ pub async fn uncomplete_task(pool: &SqlitePool, id: &str) -> Result<()> {
.bind(id) .bind(id)
.fetch_optional(&mut *tx) .fetch_optional(&mut *tx)
.await .await
.map_err(|e| MagnotiaError::StorageError(format!("Get parent_task_id failed: {e}")))? .map_err(|source| Error::Query {
operation: "uncomplete_task.get_parent_task_id".into(),
source,
})?
.flatten(); .flatten();
if let Some(pid) = parent_id { if let Some(pid) = parent_id {
@@ -537,12 +629,16 @@ pub async fn uncomplete_task(pool: &SqlitePool, id: &str) -> Result<()> {
.bind(&pid) .bind(&pid)
.execute(&mut *tx) .execute(&mut *tx)
.await .await
.map_err(|e| MagnotiaError::StorageError(format!("Reopen parent failed: {e}")))?; .map_err(|source| Error::Query {
operation: "uncomplete_task.reopen_parent".into(),
source,
})?;
} }
tx.commit() tx.commit().await.map_err(|source| Error::Query {
.await operation: "uncomplete_task.commit_transaction".into(),
.map_err(|e| MagnotiaError::StorageError(format!("Commit transaction failed: {e}")))?; source,
})?;
Ok(()) Ok(())
} }
@@ -552,7 +648,10 @@ pub async fn delete_task(pool: &SqlitePool, id: &str) -> Result<()> {
.bind(id) .bind(id)
.execute(pool) .execute(pool)
.await .await
.map_err(|e| MagnotiaError::StorageError(format!("Delete task failed: {e}")))?; .map_err(|source| Error::Query {
operation: "delete_task".into(),
source,
})?;
Ok(()) Ok(())
} }
@@ -599,7 +698,10 @@ pub async fn list_recent_completions(
.bind(format!("-{} days", days - 1)) .bind(format!("-{} days", days - 1))
.fetch_all(pool) .fetch_all(pool)
.await .await
.map_err(|e| MagnotiaError::StorageError(format!("List recent completions failed: {e}")))?; .map_err(|source| Error::Query {
operation: "list_recent_completions.fetch_grouped".into(),
source,
})?;
let lookup: std::collections::HashMap<String, u32> = rows let lookup: std::collections::HashMap<String, u32> = rows
.into_iter() .into_iter()
@@ -610,7 +712,10 @@ pub async fn list_recent_completions(
let today_row: (String,) = sqlx::query_as("SELECT DATE('now', 'localtime')") let today_row: (String,) = sqlx::query_as("SELECT DATE('now', 'localtime')")
.fetch_one(pool) .fetch_one(pool)
.await .await
.map_err(|e| MagnotiaError::StorageError(format!("Get local today failed: {e}")))?; .map_err(|source| Error::Query {
operation: "list_recent_completions.get_local_today".into(),
source,
})?;
let today = today_row.0; let today = today_row.0;
let mut series = Vec::with_capacity(days as usize); let mut series = Vec::with_capacity(days as usize);
@@ -622,7 +727,10 @@ pub async fn list_recent_completions(
.bind(format!("-{offset} days")) .bind(format!("-{offset} days"))
.fetch_one(pool) .fetch_one(pool)
.await .await
.map_err(|e| MagnotiaError::StorageError(format!("Compute spine day failed: {e}")))?; .map_err(|source| Error::Query {
operation: "list_recent_completions.compute_spine_day".into(),
source,
})?;
let count = lookup.get(&day).copied().unwrap_or(0); let count = lookup.get(&day).copied().unwrap_or(0);
series.push(DailyCompletionCount { day, count }); series.push(DailyCompletionCount { day, count });
} }
@@ -655,13 +763,17 @@ pub async fn insert_implementation_rule(
.bind(last_fired_key) .bind(last_fired_key)
.execute(pool) .execute(pool)
.await .await
.map_err(|e| MagnotiaError::StorageError(format!("Insert implementation rule failed: {e}")))?; .map_err(|source| Error::Query {
operation: "insert_implementation_rule".into(),
source,
})?;
get_implementation_rule(pool, id).await?.ok_or_else(|| { get_implementation_rule(pool, id)
MagnotiaError::StorageError(format!( .await?
"insert_implementation_rule: rule {id} not found after insert" .ok_or_else(|| Error::NotFound {
)) entity: Entity::ImplementationRule,
}) key: id.to_string(),
})
} }
pub async fn list_implementation_rules(pool: &SqlitePool) -> Result<Vec<ImplementationRuleRow>> { pub async fn list_implementation_rules(pool: &SqlitePool) -> Result<Vec<ImplementationRuleRow>> {
@@ -672,7 +784,10 @@ pub async fn list_implementation_rules(pool: &SqlitePool) -> Result<Vec<Implemen
) )
.fetch_all(pool) .fetch_all(pool)
.await .await
.map_err(|e| MagnotiaError::StorageError(format!("List implementation rules failed: {e}")))?; .map_err(|source| Error::Query {
operation: "list_implementation_rules".into(),
source,
})?;
Ok(rows.into_iter().map(implementation_rule_row_from).collect()) Ok(rows.into_iter().map(implementation_rule_row_from).collect())
} }
@@ -689,7 +804,10 @@ pub async fn get_implementation_rule(
.bind(id) .bind(id)
.fetch_optional(pool) .fetch_optional(pool)
.await .await
.map_err(|e| MagnotiaError::StorageError(format!("Get implementation rule failed: {e}")))?; .map_err(|source| Error::Query {
operation: "get_implementation_rule".into(),
source,
})?;
Ok(row.map(implementation_rule_row_from)) Ok(row.map(implementation_rule_row_from))
} }
@@ -708,13 +826,17 @@ pub async fn set_implementation_rule_enabled(
.bind(id) .bind(id)
.execute(pool) .execute(pool)
.await .await
.map_err(|e| MagnotiaError::StorageError(format!("Set implementation rule enabled failed: {e}")))?; .map_err(|source| Error::Query {
operation: "set_implementation_rule_enabled".into(),
source,
})?;
get_implementation_rule(pool, id).await?.ok_or_else(|| { get_implementation_rule(pool, id)
MagnotiaError::StorageError(format!( .await?
"set_implementation_rule_enabled: rule {id} not found after update" .ok_or_else(|| Error::NotFound {
)) entity: Entity::ImplementationRule,
}) key: id.to_string(),
})
} }
pub async fn mark_implementation_rule_fired( pub async fn mark_implementation_rule_fired(
@@ -731,13 +853,17 @@ pub async fn mark_implementation_rule_fired(
.bind(id) .bind(id)
.execute(pool) .execute(pool)
.await .await
.map_err(|e| MagnotiaError::StorageError(format!("Mark implementation rule fired failed: {e}")))?; .map_err(|source| Error::Query {
operation: "mark_implementation_rule_fired".into(),
source,
})?;
get_implementation_rule(pool, id).await?.ok_or_else(|| { get_implementation_rule(pool, id)
MagnotiaError::StorageError(format!( .await?
"mark_implementation_rule_fired: rule {id} not found after update" .ok_or_else(|| Error::NotFound {
)) entity: Entity::ImplementationRule,
}) key: id.to_string(),
})
} }
pub async fn delete_implementation_rule(pool: &SqlitePool, id: &str) -> Result<()> { pub async fn delete_implementation_rule(pool: &SqlitePool, id: &str) -> Result<()> {
@@ -745,7 +871,10 @@ pub async fn delete_implementation_rule(pool: &SqlitePool, id: &str) -> Result<(
.bind(id) .bind(id)
.execute(pool) .execute(pool)
.await .await
.map_err(|e| MagnotiaError::StorageError(format!("Delete implementation rule failed: {e}")))?; .map_err(|source| Error::Query {
operation: "delete_implementation_rule".into(),
source,
})?;
Ok(()) Ok(())
} }
@@ -757,7 +886,10 @@ pub async fn set_setting(pool: &SqlitePool, key: &str, value: &str) -> Result<()
.bind(value) .bind(value)
.execute(pool) .execute(pool)
.await .await
.map_err(|e| MagnotiaError::StorageError(format!("Set setting failed: {e}")))?; .map_err(|source| Error::Query {
operation: "set_setting".into(),
source,
})?;
Ok(()) Ok(())
} }
@@ -766,7 +898,10 @@ pub async fn get_setting(pool: &SqlitePool, key: &str) -> Result<Option<String>>
.bind(key) .bind(key)
.fetch_optional(pool) .fetch_optional(pool)
.await .await
.map_err(|e| MagnotiaError::StorageError(format!("Get setting failed: {e}")))?; .map_err(|source| Error::Query {
operation: "get_setting".into(),
source,
})?;
Ok(row.map(|r| r.get("value"))) Ok(row.map(|r| r.get("value")))
} }
@@ -944,15 +1079,19 @@ fn implementation_rule_row_from(r: sqlx::sqlite::SqliteRow) -> ImplementationRul
// 1. The DB triggers `trg_protect_default_profile_delete` / // 1. The DB triggers `trg_protect_default_profile_delete` /
// `trg_protect_default_profile_rename` from migration v6. // `trg_protect_default_profile_rename` from migration v6.
// 2. Rust-layer fail-fast checks below that short-circuit BEFORE the // 2. Rust-layer fail-fast checks below that short-circuit BEFORE the
// query hits sqlite, so UI callers get a friendly MagnotiaError::StorageError // query hits sqlite, so UI callers get a friendly typed
// instead of an opaque `SQLITE_CONSTRAINT_TRIGGER` wrapped in text. // `Error::InvalidReference` instead of an opaque
// `SQLITE_CONSTRAINT_TRIGGER` wrapped in text.
pub async fn list_profiles(pool: &SqlitePool) -> Result<Vec<ProfileRow>> { pub async fn list_profiles(pool: &SqlitePool) -> Result<Vec<ProfileRow>> {
let rows = let rows =
sqlx::query("SELECT id, name, initial_prompt, created_at FROM profiles ORDER BY name ASC") sqlx::query("SELECT id, name, initial_prompt, created_at FROM profiles ORDER BY name ASC")
.fetch_all(pool) .fetch_all(pool)
.await .await
.map_err(|e| MagnotiaError::StorageError(format!("List profiles failed: {e}")))?; .map_err(|source| Error::Query {
operation: "list_profiles".into(),
source,
})?;
Ok(rows.iter().map(profile_row_from).collect()) Ok(rows.iter().map(profile_row_from).collect())
} }
@@ -961,7 +1100,10 @@ pub async fn get_profile(pool: &SqlitePool, id: &str) -> Result<Option<ProfileRo
.bind(id) .bind(id)
.fetch_optional(pool) .fetch_optional(pool)
.await .await
.map_err(|e| MagnotiaError::StorageError(format!("Get profile failed: {e}")))?; .map_err(|source| Error::Query {
operation: "get_profile".into(),
source,
})?;
Ok(row.as_ref().map(profile_row_from)) Ok(row.as_ref().map(profile_row_from))
} }
@@ -981,7 +1123,10 @@ pub async fn create_profile(
.bind(initial_prompt) .bind(initial_prompt)
.fetch_one(pool) .fetch_one(pool)
.await .await
.map_err(|e| MagnotiaError::StorageError(format!("Create profile failed: {e}")))?; .map_err(|source| Error::Query {
operation: "create_profile".into(),
source,
})?;
Ok(profile_row_from(&row)) Ok(profile_row_from(&row))
} }
@@ -999,14 +1144,16 @@ pub async fn update_profile(
initial_prompt: &str, initial_prompt: &str,
) -> Result<()> { ) -> Result<()> {
if id == crate::DEFAULT_PROFILE_ID && name != "Default" { if id == crate::DEFAULT_PROFILE_ID && name != "Default" {
return Err(MagnotiaError::StorageError( return Err(Error::InvalidReference {
"Default profile cannot be renamed".into(), entity: Entity::Profile,
)); reason: "Default profile cannot be renamed".into(),
});
} }
if id != crate::DEFAULT_PROFILE_ID && name == "Default" { if id != crate::DEFAULT_PROFILE_ID && name == "Default" {
return Err(MagnotiaError::StorageError( return Err(Error::InvalidReference {
"Cannot rename another profile to 'Default'".into(), entity: Entity::Profile,
)); reason: "cannot rename another profile to 'Default'".into(),
});
} }
sqlx::query("UPDATE profiles SET name = ?, initial_prompt = ? WHERE id = ?") sqlx::query("UPDATE profiles SET name = ?, initial_prompt = ? WHERE id = ?")
.bind(name) .bind(name)
@@ -1014,7 +1161,10 @@ pub async fn update_profile(
.bind(id) .bind(id)
.execute(pool) .execute(pool)
.await .await
.map_err(|e| MagnotiaError::StorageError(format!("Update profile failed: {e}")))?; .map_err(|source| Error::Query {
operation: "update_profile".into(),
source,
})?;
Ok(()) Ok(())
} }
@@ -1023,21 +1173,28 @@ pub async fn update_profile(
/// ON DELETE CASCADE on `profile_terms.profile_id` cleans up children. /// ON DELETE CASCADE on `profile_terms.profile_id` cleans up children.
pub async fn delete_profile(pool: &SqlitePool, id: &str) -> Result<()> { pub async fn delete_profile(pool: &SqlitePool, id: &str) -> Result<()> {
if id == crate::DEFAULT_PROFILE_ID { if id == crate::DEFAULT_PROFILE_ID {
return Err(MagnotiaError::StorageError( return Err(Error::InvalidReference {
"Default profile cannot be deleted".into(), entity: Entity::Profile,
)); reason: "Default profile cannot be deleted".into(),
});
} }
let transcript_count = transcript_count_for_profile(pool, id).await?; let transcript_count = transcript_count_for_profile(pool, id).await?;
if transcript_count > 0 { if transcript_count > 0 {
return Err(MagnotiaError::StorageError(format!( return Err(Error::InvalidReference {
"Cannot delete profile while {transcript_count} transcript(s) still reference it; reassign transcripts first" entity: Entity::Profile,
))); reason: format!(
"cannot delete profile while {transcript_count} transcript(s) still reference it; reassign transcripts first"
).into(),
});
} }
sqlx::query("DELETE FROM profiles WHERE id = ?") sqlx::query("DELETE FROM profiles WHERE id = ?")
.bind(id) .bind(id)
.execute(pool) .execute(pool)
.await .await
.map_err(|e| MagnotiaError::StorageError(format!("Delete profile failed: {e}")))?; .map_err(|source| Error::Query {
operation: "delete_profile".into(),
source,
})?;
Ok(()) Ok(())
} }
@@ -1052,7 +1209,10 @@ pub async fn list_profile_terms(
.bind(profile_id) .bind(profile_id)
.fetch_all(pool) .fetch_all(pool)
.await .await
.map_err(|e| MagnotiaError::StorageError(format!("List profile terms failed: {e}")))?; .map_err(|source| Error::Query {
operation: "list_profile_terms".into(),
source,
})?;
Ok(rows.iter().map(profile_term_row_from).collect()) Ok(rows.iter().map(profile_term_row_from).collect())
} }
@@ -1078,7 +1238,10 @@ pub async fn add_profile_term(
.bind(note) .bind(note)
.fetch_one(pool) .fetch_one(pool)
.await .await
.map_err(|e| MagnotiaError::StorageError(format!("Add profile term failed: {e}")))?; .map_err(|source| Error::Query {
operation: "add_profile_term".into(),
source,
})?;
Ok(profile_term_row_from(&row)) Ok(profile_term_row_from(&row))
} }
@@ -1087,7 +1250,10 @@ pub async fn delete_profile_term(pool: &SqlitePool, id: &str) -> Result<()> {
.bind(id) .bind(id)
.execute(pool) .execute(pool)
.await .await
.map_err(|e| MagnotiaError::StorageError(format!("Delete profile term failed: {e}")))?; .map_err(|source| Error::Query {
operation: "delete_profile_term".into(),
source,
})?;
Ok(()) Ok(())
} }
@@ -1096,7 +1262,10 @@ async fn profile_exists(pool: &SqlitePool, id: &str) -> Result<bool> {
.bind(id) .bind(id)
.fetch_optional(pool) .fetch_optional(pool)
.await .await
.map_err(|e| MagnotiaError::StorageError(format!("Profile existence check failed: {e}")))?; .map_err(|source| Error::Query {
operation: "profile_exists".into(),
source,
})?;
Ok(exists.is_some()) Ok(exists.is_some())
} }
@@ -1105,7 +1274,10 @@ async fn transcript_count_for_profile(pool: &SqlitePool, id: &str) -> Result<i64
.bind(id) .bind(id)
.fetch_one(pool) .fetch_one(pool)
.await .await
.map_err(|e| MagnotiaError::StorageError(format!("Profile transcript count failed: {e}"))) .map_err(|source| Error::Query {
operation: "transcript_count_for_profile".into(),
source,
})
} }
// --- Error Logging --- // --- Error Logging ---
@@ -1139,7 +1311,10 @@ pub async fn log_error(
.bind(metadata) .bind(metadata)
.execute(pool) .execute(pool)
.await .await
.map_err(|e| MagnotiaError::StorageError(format!("Error log failed: {e}")))?; .map_err(|source| Error::Query {
operation: "log_error".into(),
source,
})?;
Ok(()) Ok(())
} }
@@ -1171,7 +1346,10 @@ pub async fn prune_error_log(pool: &SqlitePool, keep_days: i64) -> Result<u64> {
.bind(format!("-{keep_days} days")) .bind(format!("-{keep_days} days"))
.execute(pool) .execute(pool)
.await .await
.map_err(|e| MagnotiaError::StorageError(format!("Prune error_log failed: {e}")))?; .map_err(|source| Error::Query {
operation: "prune_error_log".into(),
source,
})?;
Ok(result.rows_affected()) Ok(result.rows_affected())
} }
@@ -1183,7 +1361,10 @@ pub async fn list_recent_errors(pool: &SqlitePool, limit: i64) -> Result<Vec<Err
.bind(limit) .bind(limit)
.fetch_all(pool) .fetch_all(pool)
.await .await
.map_err(|e| MagnotiaError::StorageError(format!("Read error_log failed: {e}")))?; .map_err(|source| Error::Query {
operation: "list_recent_errors".into(),
source,
})?;
Ok(rows Ok(rows
.into_iter() .into_iter()
@@ -1264,10 +1445,10 @@ pub struct FeedbackRow {
pub async fn record_feedback(pool: &SqlitePool, params: RecordFeedbackParams) -> Result<i64> { pub async fn record_feedback(pool: &SqlitePool, params: RecordFeedbackParams) -> Result<i64> {
if !matches!(params.rating, -1..=1) { if !matches!(params.rating, -1..=1) {
return Err(MagnotiaError::StorageError(format!( return Err(Error::InvalidReference {
"invalid feedback rating {} (must be -1, 0, or 1)", entity: Entity::Feedback,
params.rating reason: format!("invalid rating {} (must be -1, 0, or 1)", params.rating).into(),
))); });
} }
let profile_id = params let profile_id = params
.profile_id .profile_id
@@ -1288,7 +1469,10 @@ pub async fn record_feedback(pool: &SqlitePool, params: RecordFeedbackParams) ->
.bind(profile_id) .bind(profile_id)
.fetch_one(pool) .fetch_one(pool)
.await .await
.map_err(|e| MagnotiaError::StorageError(format!("record_feedback failed: {e}")))?; .map_err(|source| Error::Query {
operation: "record_feedback".into(),
source,
})?;
Ok(row.get::<i64, _>("id")) Ok(row.get::<i64, _>("id"))
} }
@@ -1325,7 +1509,10 @@ pub async fn list_feedback_examples(
.bind(limit) .bind(limit)
.fetch_all(pool) .fetch_all(pool)
.await .await
.map_err(|e| MagnotiaError::StorageError(format!("list_feedback_examples failed: {e}")))?; .map_err(|source| Error::Query {
operation: "list_feedback_examples".into(),
source,
})?;
Ok(rows Ok(rows
.into_iter() .into_iter()
@@ -1942,7 +2129,7 @@ mod tests {
#[tokio::test] #[tokio::test]
async fn create_profile_rejects_duplicate_name() { async fn create_profile_rejects_duplicate_name() {
// Guardrail: UNIQUE(name) collision surfaces as StorageError. // Guardrail: UNIQUE(name) collision surfaces as Error::Query.
let pool = test_pool().await; let pool = test_pool().await;
create_profile(&pool, "Personal", "").await.unwrap(); create_profile(&pool, "Personal", "").await.unwrap();
let res = create_profile(&pool, "Personal", "").await; let res = create_profile(&pool, "Personal", "").await;
@@ -2511,24 +2698,21 @@ mod tests {
let removed = prune_error_log(&pool, 90).await.unwrap(); let removed = prune_error_log(&pool, 90).await.unwrap();
assert_eq!(removed, 1, "only the 200-day row should be pruned"); assert_eq!(removed, 1, "only the 200-day row should be pruned");
let remaining: Vec<String> = sqlx::query_scalar( let remaining: Vec<String> =
"SELECT context FROM error_log ORDER BY id ASC", sqlx::query_scalar("SELECT context FROM error_log ORDER BY id ASC")
) .fetch_all(&pool)
.fetch_all(&pool) .await
.await .unwrap();
.unwrap();
assert_eq!(remaining, vec!["recent".to_string(), "older".to_string()]); assert_eq!(remaining, vec!["recent".to_string(), "older".to_string()]);
// A 14-day window prunes the 30-day row too. // A 14-day window prunes the 30-day row too.
let removed = prune_error_log(&pool, 14).await.unwrap(); let removed = prune_error_log(&pool, 14).await.unwrap();
assert_eq!(removed, 1); assert_eq!(removed, 1);
let remaining: Vec<String> = sqlx::query_scalar( let remaining: Vec<String> = sqlx::query_scalar("SELECT context FROM error_log")
"SELECT context FROM error_log", .fetch_all(&pool)
) .await
.fetch_all(&pool) .unwrap();
.await
.unwrap();
assert_eq!(remaining, vec!["recent".to_string()]); assert_eq!(remaining, vec!["recent".to_string()]);
} }
} }

194
crates/storage/src/error.rs Normal file
View File

@@ -0,0 +1,194 @@
//! Storage-local typed error.
//!
//! Backend code that wants to branch on storage failure modes pattern-matches
//! on [`Error`] directly. The crate boundary into [`magnotia_core::error::MagnotiaError`]
//! flattens this into a [`MagnotiaError::Storage`] variant carrying a serde-friendly
//! `kind`, an `operation` label, and the Display output as `detail` — so the
//! frontend (which still receives stringified errors from Tauri commands today)
//! keeps working, and Area E can later expose the typed `kind` to the FE without
//! touching call sites.
//!
//! The `sqlx::Error` source is deliberately not serialized. Sources stay sources;
//! only the boundary shape is wire-friendly.
use std::borrow::Cow;
use std::path::PathBuf;
use magnotia_core::error::{MagnotiaError, StorageKind};
/// Kinds of database-open operation that can fail before the pool is ready.
#[derive(Debug, Clone, Copy)]
pub enum OpenOp {
Connect,
ReadOnlyConnect,
ForeignKeysPragma,
}
impl OpenOp {
fn label(self) -> &'static str {
match self {
OpenOp::Connect => "connect",
OpenOp::ReadOnlyConnect => "read_only_connect",
OpenOp::ForeignKeysPragma => "foreign_keys_pragma",
}
}
}
impl std::fmt::Display for OpenOp {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.label())
}
}
/// Per-step phase inside the migration framework.
#[derive(Debug, Clone, Copy)]
pub enum MigrationStep {
SchemaVersionTableCreate,
SchemaVersionQuery,
TxBegin,
Apply,
RecordVersion,
Commit,
}
impl MigrationStep {
fn label(self) -> &'static str {
match self {
MigrationStep::SchemaVersionTableCreate => "schema_version_table_create",
MigrationStep::SchemaVersionQuery => "schema_version_query",
MigrationStep::TxBegin => "tx_begin",
MigrationStep::Apply => "apply",
MigrationStep::RecordVersion => "record_version",
MigrationStep::Commit => "commit",
}
}
}
impl std::fmt::Display for MigrationStep {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.label())
}
}
/// Entities the storage layer can fail to find or validate.
///
/// Seeded with only the three real entities (Transcript, Task, Profile) — add
/// more here only when a typed `NotFound` / `InvalidReference` case actually
/// requires them.
#[derive(Debug, Clone, Copy)]
pub enum Entity {
Transcript,
Task,
Profile,
ImplementationRule,
Feedback,
}
impl Entity {
fn label(self) -> &'static str {
match self {
Entity::Transcript => "transcript",
Entity::Task => "task",
Entity::Profile => "profile",
Entity::ImplementationRule => "implementation_rule",
Entity::Feedback => "feedback",
}
}
}
impl std::fmt::Display for Entity {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.label())
}
}
/// Storage-crate-local typed error.
#[derive(Debug, thiserror::Error)]
pub enum Error {
#[error("database open failed during {operation}: {source}")]
DatabaseOpen {
operation: OpenOp,
#[source]
source: sqlx::Error,
},
#[error("migration step {step} (version {version:?}) failed: {source}")]
Migration {
version: Option<i64>,
step: MigrationStep,
#[source]
source: sqlx::Error,
},
#[error("query failed during {operation}: {source}")]
Query {
operation: Cow<'static, str>,
#[source]
source: sqlx::Error,
},
#[error("{entity} not found: '{key}'")]
NotFound { entity: Entity, key: String },
#[error("invalid {entity} reference: {reason}")]
InvalidReference {
entity: Entity,
reason: Cow<'static, str>,
},
#[error("filesystem operation failed at '{}': {source}", path.display())]
Filesystem {
path: PathBuf,
#[source]
source: std::io::Error,
},
}
impl Error {
/// Discriminator for the boundary conversion into [`MagnotiaError`].
pub fn kind(&self) -> StorageKind {
match self {
Error::DatabaseOpen { .. } => StorageKind::DatabaseOpen,
Error::Migration { .. } => StorageKind::Migration,
Error::Query { .. } => StorageKind::Query,
Error::NotFound { .. } => StorageKind::NotFound,
Error::InvalidReference { .. } => StorageKind::InvalidReference,
Error::Filesystem { .. } => StorageKind::Filesystem,
}
}
/// Operation label that flows into the [`MagnotiaError::Storage`] boundary
/// shape. For per-query failures this is the caller-provided label;
/// for the other variants it's the variant's intrinsic label.
pub fn operation_label(&self) -> Cow<'static, str> {
match self {
Error::DatabaseOpen { operation, .. } => Cow::Borrowed(operation.label()),
Error::Migration { step, .. } => Cow::Borrowed(step.label()),
Error::Query { operation, .. } => operation.clone(),
Error::NotFound { entity, .. } => Cow::Owned(format!("not_found:{}", entity.label())),
Error::InvalidReference { entity, .. } => {
Cow::Owned(format!("invalid_reference:{}", entity.label()))
}
Error::Filesystem { .. } => Cow::Borrowed("filesystem"),
}
}
}
/// Boundary conversion — flattens the typed error into the wire-friendly
/// [`MagnotiaError::Storage`] variant. Lives in the storage crate (not in core)
/// to avoid a `core -> storage` dependency cycle.
impl From<Error> for MagnotiaError {
fn from(error: Error) -> Self {
let kind = error.kind();
let operation = error.operation_label().into_owned();
let detail = error.to_string();
MagnotiaError::Storage {
kind,
operation,
detail,
}
}
}
/// `Result` alias for storage-crate functions.
pub type Result<T> = std::result::Result<T, Error>;

View File

@@ -1,7 +1,10 @@
pub mod database; pub mod database;
pub mod error;
pub mod file_storage; pub mod file_storage;
pub mod migrations; pub mod migrations;
pub use error::{Entity, Error, MigrationStep, OpenOp, Result};
/// Stable identifier for the seeded Default profile (see migration v6). /// Stable identifier for the seeded Default profile (see migration v6).
/// The Default profile cannot be renamed or deleted — guarded by SQLite triggers. /// The Default profile cannot be renamed or deleted — guarded by SQLite triggers.
pub const DEFAULT_PROFILE_ID: &str = "00000000-0000-0000-0000-000000000001"; pub const DEFAULT_PROFILE_ID: &str = "00000000-0000-0000-0000-000000000001";
@@ -15,8 +18,7 @@ pub use database::{
list_profiles, list_recent_completions, list_recent_errors, list_subtasks, list_tasks, list_profiles, list_recent_completions, list_recent_errors, list_subtasks, list_tasks,
list_transcripts, list_transcripts_paged, log_error, mark_implementation_rule_fired, list_transcripts, list_transcripts_paged, log_error, mark_implementation_rule_fired,
prune_error_log, record_feedback, search_transcripts, set_implementation_rule_enabled, prune_error_log, record_feedback, search_transcripts, set_implementation_rule_enabled,
set_setting, set_setting, set_task_energy, uncomplete_task, update_profile, update_task, update_transcript,
set_task_energy, uncomplete_task, update_profile, update_task, update_transcript,
update_transcript_meta, DailyCompletionCount, ErrorLogRow, FeedbackRow, FeedbackTargetType, update_transcript_meta, DailyCompletionCount, ErrorLogRow, FeedbackRow, FeedbackTargetType,
ImplementationRuleRow, InsertTranscriptParams, ProfileRow, ProfileTermRow, ImplementationRuleRow, InsertTranscriptParams, ProfileRow, ProfileTermRow,
RecordFeedbackParams, TaskRow, TranscriptRow, RecordFeedbackParams, TaskRow, TranscriptRow,

View File

@@ -1,4 +1,4 @@
use magnotia_core::error::{MagnotiaError, Result}; use crate::error::{Error, MigrationStep, Result};
use sqlx::SqlitePool; use sqlx::SqlitePool;
/// Each migration is a (version, description, sql) tuple. /// Each migration is a (version, description, sql) tuple.
@@ -553,27 +553,39 @@ async fn run_migrations_slice(pool: &SqlitePool, migrations: &[(i64, &str, &str)
) )
.execute(pool) .execute(pool)
.await .await
.map_err(|e| MagnotiaError::StorageError(format!("Schema version table creation failed: {e}")))?; .map_err(|source| Error::Migration {
version: None,
step: MigrationStep::SchemaVersionTableCreate,
source,
})?;
let current: i64 = sqlx::query_scalar("SELECT COALESCE(MAX(version), 0) FROM schema_version") let current: i64 = sqlx::query_scalar("SELECT COALESCE(MAX(version), 0) FROM schema_version")
.fetch_one(pool) .fetch_one(pool)
.await .await
.map_err(|e| MagnotiaError::StorageError(format!("Schema version query failed: {e}")))?; .map_err(|source| Error::Migration {
version: None,
step: MigrationStep::SchemaVersionQuery,
source,
})?;
for (version, description, sql) in migrations { for (version, description, sql) in migrations {
if *version > current { if *version > current {
log::info!("Running migration {}: {}", version, description); log::info!("Running migration {}: {}", version, description);
let mut tx = pool.begin().await.map_err(|e| { let mut tx = pool.begin().await.map_err(|source| Error::Migration {
MagnotiaError::StorageError(format!("Migration {} tx begin failed: {e}", version)) version: Some(*version),
step: MigrationStep::TxBegin,
source,
})?; })?;
for statement in split_statements(sql) { for statement in split_statements(sql) {
sqlx::query(&statement) sqlx::query(&statement)
.execute(&mut *tx) .execute(&mut *tx)
.await .await
.map_err(|e| { .map_err(|source| Error::Migration {
MagnotiaError::StorageError(format!("Migration {} failed: {e}", version)) version: Some(*version),
step: MigrationStep::Apply,
source,
})?; })?;
} }
@@ -582,12 +594,16 @@ async fn run_migrations_slice(pool: &SqlitePool, migrations: &[(i64, &str, &str)
.bind(description) .bind(description)
.execute(&mut *tx) .execute(&mut *tx)
.await .await
.map_err(|e| { .map_err(|source| Error::Migration {
MagnotiaError::StorageError(format!("Migration version record failed: {e}")) version: Some(*version),
step: MigrationStep::RecordVersion,
source,
})?; })?;
tx.commit().await.map_err(|e| { tx.commit().await.map_err(|source| Error::Migration {
MagnotiaError::StorageError(format!("Migration {} commit failed: {e}", version)) version: Some(*version),
step: MigrationStep::Commit,
source,
})?; })?;
log::info!("Migration {} complete", version); log::info!("Migration {} complete", version);
@@ -1178,7 +1194,8 @@ mod tests {
.map(|r| r.get::<String, _>("detail")) .map(|r| r.get::<String, _>("detail"))
.collect(); .collect();
assert!( assert!(
plan.iter().any(|row| row.contains("idx_transcripts_profile_created")), plan.iter()
.any(|row| row.contains("idx_transcripts_profile_created")),
"planner should use the composite index, got plan: {plan:?}", "planner should use the composite index, got plan: {plan:?}",
); );
} }

View File

@@ -23,6 +23,14 @@ whisper-vulkan = ["whisper-rs?/vulkan"]
[dependencies] [dependencies]
magnotia-core = { path = "../core" } magnotia-core = { path = "../core" }
# TranscriptionProvider async trait + EngineProfile + ProviderId. The
# trait lives in cloud-providers so an OEM licensee can implement it
# without depending on transcription internals.
magnotia-cloud-providers = { path = "../cloud-providers" }
# Async-trait for the LocalProviderAdapter impl.
async-trait = "0.1"
# Parakeet via ONNX. Whisper is handled directly via whisper-rs below. # Parakeet via ONNX. Whisper is handled directly via whisper-rs below.
transcribe-rs = { version = "0.3", default-features = false, features = ["onnx"] } transcribe-rs = { version = "0.3", default-features = false, features = ["onnx"] }
@@ -50,7 +58,9 @@ tracing = "0.1"
[dev-dependencies] [dev-dependencies]
# TcpListener fixture for the download resume tests (mirrors magnotia-llm). # TcpListener fixture for the download resume tests (mirrors magnotia-llm).
tokio = { version = "1", features = ["rt", "sync", "net", "io-util", "macros"] } # `macros` and `rt-multi-thread` enable #[tokio::test] and the multi-threaded
# scheduler used by the orchestrator tests.
tokio = { version = "1", features = ["rt", "rt-multi-thread", "sync", "net", "io-util", "macros"] }
tempfile = "3" tempfile = "3"
# Test-only — used by tests/thread_sweep.rs to label physical vs logical # Test-only — used by tests/thread_sweep.rs to label physical vs logical
# core counts in the scaling table. Production code uses the # core counts in the scaling table. Production code uses the

View File

@@ -1,6 +1,8 @@
pub mod concurrency; pub mod concurrency;
pub mod local_engine; pub mod local_engine;
pub mod model_manager; pub mod model_manager;
pub mod orchestrator;
pub mod registry;
pub mod streaming; pub mod streaming;
pub mod transcriber; pub mod transcriber;
#[cfg(feature = "whisper")] #[cfg(feature = "whisper")]
@@ -11,9 +13,21 @@ pub use concurrency::run_inference;
pub use local_engine::load_whisper; pub use local_engine::load_whisper;
pub use local_engine::{load_parakeet, LocalEngine, SpeechModelAdapter, TimedTranscript}; pub use local_engine::{load_parakeet, LocalEngine, SpeechModelAdapter, TimedTranscript};
pub use model_manager::{download, is_downloaded, list_downloaded, model_dir, models_dir}; pub use model_manager::{download, is_downloaded, list_downloaded, model_dir, models_dir};
pub use orchestrator::{LocalProviderAdapter, Orchestrator};
pub use registry::EngineRegistry;
pub use streaming::{ pub use streaming::{
sample_index_for_seconds, trim_buffer_to_commit_point, CommitDecision, CommitPolicy, sample_index_for_seconds, trim_buffer_to_commit_point, CommitDecision, CommitPolicy,
LocalAgreement, RmsVadChunker, Token, VadChunk, VadChunker, LocalAgreement, RmsVadChunker, Token, VadChunk, VadChunker,
}; };
pub use transcribe_rs::SpeechModel; pub use transcribe_rs::SpeechModel;
pub use transcriber::{Transcriber, TranscriberCapabilities}; pub use transcriber::{Transcriber, TranscriberCapabilities};
// Re-export the trait surface so downstream crates depend only on
// `magnotia_transcription` to access both local engines and the
// provider trait without a separate `magnotia_cloud_providers`
// dependency. This is the seam an OEM licensee uses when they swap
// providers.
pub use magnotia_cloud_providers::{
CostClass, EngineProfile, NetworkRequirement, ProviderCapabilities, ProviderId, ProviderKind,
ProviderTranscript, TranscriptionProvider,
};

View File

@@ -116,7 +116,10 @@ fn verified_manifest_path(dir: &Path) -> PathBuf {
dir.join(".magnotia-verified") dir.join(".magnotia-verified")
} }
fn verified_manifest_matches(entry: &magnotia_core::model_registry::ModelEntry, dir: &Path) -> bool { fn verified_manifest_matches(
entry: &magnotia_core::model_registry::ModelEntry,
dir: &Path,
) -> bool {
let manifest = match std::fs::read_to_string(verified_manifest_path(dir)) { let manifest = match std::fs::read_to_string(verified_manifest_path(dir)) {
Ok(contents) => contents, Ok(contents) => contents,
Err(_) => return false, Err(_) => return false,

View File

@@ -0,0 +1,281 @@
//! `Orchestrator` is the single entry point for transcription. Tauri
//! commands resolve a provider through it; nothing else calls a
//! provider directly. This is where the AGPL OEM trait boundary meets
//! Wyrdnote's local engines.
//!
//! `LocalProviderAdapter` lives here, not as an `impl
//! TranscriptionProvider for LocalEngine`. The adapter wraps an
//! `Arc<LocalEngine>` and bridges the synchronous `Transcriber` trait
//! to the async `TranscriptionProvider` interface via
//! `tokio::task::spawn_blocking`. Keeping the adapter in the
//! orchestrator (rather than in `local_engine.rs`) means the
//! transcription crate's core types do not need to depend on
//! `async_trait`, and the dependency edge stays one-directional:
//! `cloud_providers` defines the trait, `transcription` implements it
//! via adapter without leaking async-runtime requirements onto
//! `Transcriber`.
use std::sync::Arc;
use async_trait::async_trait;
use magnotia_cloud_providers::{
CostClass, EngineProfile, ProviderCapabilities, ProviderId, ProviderKind, ProviderTranscript,
TranscriptionProvider,
};
use magnotia_core::error::{MagnotiaError, Result};
use magnotia_core::types::{AudioSamples, TranscriptionOptions};
use crate::local_engine::LocalEngine;
use crate::registry::EngineRegistry;
/// Wraps a `LocalEngine` and presents the async `TranscriptionProvider`
/// interface upward. One adapter per local engine instance; multiple
/// engines (Whisper, Parakeet) live as multiple adapters in the
/// registry.
pub struct LocalProviderAdapter {
provider_id: ProviderId,
engine: Arc<LocalEngine>,
}
impl LocalProviderAdapter {
pub fn new(provider_id: ProviderId, engine: Arc<LocalEngine>) -> Self {
Self {
provider_id,
engine,
}
}
/// Direct access to the wrapped engine. Used by warmup, model
/// management, and the GPU-sequencing guard, none of which want to
/// route through the async trait surface.
pub fn engine(&self) -> Arc<LocalEngine> {
self.engine.clone()
}
}
#[async_trait]
impl TranscriptionProvider for LocalProviderAdapter {
fn provider_id(&self) -> ProviderId {
self.provider_id.clone()
}
fn kind(&self) -> ProviderKind {
ProviderKind::Local
}
fn capabilities(&self) -> ProviderCapabilities {
let local = self.engine.capabilities();
ProviderCapabilities {
sample_rate: local
.map(|c| c.sample_rate)
.unwrap_or(magnotia_core::constants::WHISPER_SAMPLE_RATE),
channels: local.map(|c| c.channels).unwrap_or(1),
initial_prompt_supported: local.map(|c| c.supports_initial_prompt).unwrap_or(false),
language_hint_supported: true,
streaming_supported: false,
cost_class: CostClass::Free,
}
}
async fn prepare(&self, _profile: &EngineProfile) -> Result<()> {
if self.engine.is_loaded() {
Ok(())
} else {
Err(MagnotiaError::EngineNotLoaded)
}
}
async fn transcribe(
&self,
audio: AudioSamples,
options: TranscriptionOptions,
) -> Result<ProviderTranscript> {
let engine = self.engine.clone();
let timed = tokio::task::spawn_blocking(move || engine.transcribe_sync(&audio, &options))
.await
.map_err(|e| MagnotiaError::TranscriptionFailed(format!("Task join error: {e}")))??;
Ok(ProviderTranscript {
transcript: timed.transcript,
inference_ms: timed.inference_ms,
})
}
}
/// The single entry point through which all transcription flows.
/// Resolves a provider against the registry, runs `prepare` if the
/// caller has not already done so, and dispatches `transcribe`.
pub struct Orchestrator {
registry: Arc<EngineRegistry>,
}
impl Orchestrator {
pub fn new(registry: Arc<EngineRegistry>) -> Self {
Self { registry }
}
/// Underlying registry. Exposed so the UI can list providers and
/// query capabilities without going through a transcribe call.
pub fn registry(&self) -> Arc<EngineRegistry> {
self.registry.clone()
}
/// Resolve the provider for a profile. Returns a clear error when
/// the profile names an unregistered engine.
pub fn resolve(&self, profile: &EngineProfile) -> Result<Arc<dyn TranscriptionProvider>> {
self.registry
.get(&profile.engine_id)
.ok_or_else(|| MagnotiaError::ProviderNotRegistered(profile.engine_id.to_string()))
}
/// Transcribe audio using the provider named in the profile. The
/// orchestrator builds `TranscriptionOptions` from the profile and
/// delegates to the provider.
///
/// Phase A scope: this is the new entry point. Existing call sites
/// (`commands/transcription.rs::transcribe_file`) still call
/// `LocalEngine` directly; their rewire is a follow-up commit so
/// the chunking + post-processing logic moves cleanly without
/// inflating this diff. See `KNOWN-ISSUES.md` KI-06.
pub async fn transcribe(
&self,
audio: AudioSamples,
profile: &EngineProfile,
) -> Result<ProviderTranscript> {
let provider = self.resolve(profile)?;
let options = profile.to_options();
provider.transcribe(audio, options).await
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::atomic::{AtomicUsize, Ordering};
use magnotia_core::types::{Segment, Transcript};
/// Mock provider that returns a canned transcript and counts
/// invocations. Used to validate the orchestrator's dispatch logic
/// without booting a real model.
struct CannedProvider {
id: ProviderId,
calls: Arc<AtomicUsize>,
canned_text: String,
}
#[async_trait]
impl TranscriptionProvider for CannedProvider {
fn provider_id(&self) -> ProviderId {
self.id.clone()
}
fn kind(&self) -> ProviderKind {
ProviderKind::Local
}
fn capabilities(&self) -> ProviderCapabilities {
ProviderCapabilities {
sample_rate: 16_000,
channels: 1,
initial_prompt_supported: true,
language_hint_supported: true,
streaming_supported: false,
cost_class: CostClass::Free,
}
}
async fn prepare(&self, _profile: &EngineProfile) -> Result<()> {
Ok(())
}
async fn transcribe(
&self,
audio: AudioSamples,
_options: TranscriptionOptions,
) -> Result<ProviderTranscript> {
self.calls.fetch_add(1, Ordering::SeqCst);
Ok(ProviderTranscript {
transcript: Transcript::new(
vec![Segment {
start: 0.0,
end: audio.duration_secs(),
text: self.canned_text.clone(),
}],
"en".to_string(),
audio.duration_secs(),
),
inference_ms: 7,
})
}
}
fn build_registry_with(id: &str, text: &str, calls: Arc<AtomicUsize>) -> Arc<EngineRegistry> {
let mut registry = EngineRegistry::new(ProviderId::new(id));
registry.register(Arc::new(CannedProvider {
id: ProviderId::new(id),
calls,
canned_text: text.to_string(),
}));
Arc::new(registry)
}
fn one_second_of_silence() -> AudioSamples {
AudioSamples::mono_16khz(vec![0.0_f32; 16_000])
}
#[tokio::test]
async fn orchestrator_dispatches_to_named_provider() {
let calls = Arc::new(AtomicUsize::new(0));
let registry = build_registry_with("canned", "hello wyrdnote", calls.clone());
let orchestrator = Orchestrator::new(registry);
let profile = EngineProfile::new(ProviderId::new("canned"));
let result = orchestrator
.transcribe(one_second_of_silence(), &profile)
.await
.expect("dispatch succeeds");
assert_eq!(result.transcript.text(), "hello wyrdnote");
assert_eq!(result.inference_ms, 7);
assert_eq!(calls.load(Ordering::SeqCst), 1);
}
#[tokio::test]
async fn orchestrator_returns_clear_error_for_unregistered_provider() {
let calls = Arc::new(AtomicUsize::new(0));
let registry = build_registry_with("canned", "_", calls);
let orchestrator = Orchestrator::new(registry);
let profile = EngineProfile::new(ProviderId::new("not-registered"));
let err = orchestrator
.transcribe(one_second_of_silence(), &profile)
.await
.expect_err("unregistered provider must error");
let msg = err.to_string();
assert!(
msg.contains("not-registered"),
"error must name the missing provider, got: {msg}"
);
}
#[tokio::test]
async fn orchestrator_routes_initial_prompt_through_options() {
let calls = Arc::new(AtomicUsize::new(0));
let registry = build_registry_with("canned", "transcript", calls.clone());
let orchestrator = Orchestrator::new(registry);
let mut profile = EngineProfile::new(ProviderId::new("canned"));
profile.language = Some("en".to_string());
profile.initial_prompt = Some("Wyrdnote".to_string());
let _ = orchestrator
.transcribe(one_second_of_silence(), &profile)
.await
.expect("dispatch succeeds");
assert_eq!(calls.load(Ordering::SeqCst), 1);
}
#[test]
fn local_provider_adapter_is_object_safe() {
let _: Option<Arc<dyn TranscriptionProvider>> = None;
}
}

View File

@@ -0,0 +1,183 @@
//! `EngineRegistry` is the catalogue of available providers, built
//! once at app boot and dependency-injected into the orchestrator.
//!
//! Registration is push-style: the boot code constructs each provider
//! (a `LocalProviderAdapter` wrapping a `LocalEngine`, or a cloud
//! provider implementing `TranscriptionProvider` directly) and calls
//! `register`. The default provider is set at construction time and
//! used when a profile does not specify one.
//!
//! The registry is read-only after boot; mutation requires a fresh
//! registry instance. Providers are held behind `Arc<dyn
//! TranscriptionProvider>` so the orchestrator can clone the handle
//! cheaply across tokio tasks.
use std::collections::HashMap;
use std::sync::Arc;
use magnotia_cloud_providers::{ProviderId, TranscriptionProvider};
/// Catalogue of providers known to the orchestrator.
pub struct EngineRegistry {
providers: HashMap<ProviderId, Arc<dyn TranscriptionProvider>>,
default_provider: ProviderId,
}
impl EngineRegistry {
/// Construct an empty registry with the given default provider id.
/// The default need not exist at construction time; callers are
/// expected to register it before the registry is queried.
pub fn new(default_provider: ProviderId) -> Self {
Self {
providers: HashMap::new(),
default_provider,
}
}
/// Register a provider. If a provider with the same id is already
/// registered, it is replaced. The provider's id is read once via
/// `provider.provider_id()` and used as the registry key.
pub fn register(&mut self, provider: Arc<dyn TranscriptionProvider>) {
let id = provider.provider_id();
self.providers.insert(id, provider);
}
/// Fetch a provider by id. Returns `None` when the id is not in the
/// registry; the orchestrator surfaces this as a clear error so the
/// UI can prompt the user to pick a different engine.
pub fn get(&self, id: &ProviderId) -> Option<Arc<dyn TranscriptionProvider>> {
self.providers.get(id).cloned()
}
/// Fetch the default provider. Returns `None` if the default has
/// not been registered.
pub fn default(&self) -> Option<Arc<dyn TranscriptionProvider>> {
self.providers.get(&self.default_provider).cloned()
}
/// The id of the default provider. Always returns; the provider
/// itself may not be registered.
pub fn default_id(&self) -> &ProviderId {
&self.default_provider
}
/// All registered provider ids. The order is unspecified; the UI
/// is responsible for any sorting it wants to present.
pub fn ids(&self) -> Vec<ProviderId> {
self.providers.keys().cloned().collect()
}
/// Number of registered providers.
pub fn len(&self) -> usize {
self.providers.len()
}
pub fn is_empty(&self) -> bool {
self.providers.is_empty()
}
}
#[cfg(test)]
mod tests {
use super::*;
use async_trait::async_trait;
use magnotia_cloud_providers::{
CostClass, EngineProfile, ProviderCapabilities, ProviderKind, ProviderTranscript,
};
use magnotia_core::error::Result;
use magnotia_core::types::{AudioSamples, Transcript, TranscriptionOptions};
struct DummyProvider {
id: ProviderId,
}
#[async_trait]
impl TranscriptionProvider for DummyProvider {
fn provider_id(&self) -> ProviderId {
self.id.clone()
}
fn kind(&self) -> ProviderKind {
ProviderKind::Local
}
fn capabilities(&self) -> ProviderCapabilities {
ProviderCapabilities {
sample_rate: 16_000,
channels: 1,
initial_prompt_supported: false,
language_hint_supported: true,
streaming_supported: false,
cost_class: CostClass::Free,
}
}
async fn prepare(&self, _profile: &EngineProfile) -> Result<()> {
Ok(())
}
async fn transcribe(
&self,
audio: AudioSamples,
_options: TranscriptionOptions,
) -> Result<ProviderTranscript> {
Ok(ProviderTranscript {
transcript: Transcript::new(Vec::new(), "en".to_string(), audio.duration_secs()),
inference_ms: 0,
})
}
}
fn dummy(id: &str) -> Arc<dyn TranscriptionProvider> {
Arc::new(DummyProvider {
id: ProviderId::new(id),
})
}
#[test]
fn empty_registry_returns_none_for_default() {
let registry = EngineRegistry::new(ProviderId::new("local-whisper"));
assert!(registry.default().is_none());
assert_eq!(registry.default_id().as_str(), "local-whisper");
assert!(registry.is_empty());
}
#[test]
fn register_and_get_round_trip() {
let mut registry = EngineRegistry::new(ProviderId::new("local-whisper"));
registry.register(dummy("local-whisper"));
registry.register(dummy("local-parakeet"));
assert_eq!(registry.len(), 2);
assert!(registry.get(&ProviderId::new("local-whisper")).is_some());
assert!(registry.get(&ProviderId::new("local-parakeet")).is_some());
assert!(registry.get(&ProviderId::new("nonexistent")).is_none());
}
#[test]
fn default_resolves_after_registration() {
let mut registry = EngineRegistry::new(ProviderId::new("local-whisper"));
registry.register(dummy("local-whisper"));
let default = registry.default().expect("default provider registered");
assert_eq!(default.provider_id().as_str(), "local-whisper");
}
#[test]
fn re_register_replaces() {
let mut registry = EngineRegistry::new(ProviderId::new("local-whisper"));
registry.register(dummy("local-whisper"));
registry.register(dummy("local-whisper"));
assert_eq!(registry.len(), 1);
}
#[test]
fn ids_lists_all_registered() {
let mut registry = EngineRegistry::new(ProviderId::new("local-whisper"));
registry.register(dummy("local-whisper"));
registry.register(dummy("local-parakeet"));
let mut ids: Vec<String> = registry
.ids()
.into_iter()
.map(|id| id.as_str().to_string())
.collect();
ids.sort();
assert_eq!(ids, vec!["local-parakeet", "local-whisper"]);
}
}

View File

@@ -65,7 +65,9 @@ impl Transcriber for WhisperRsBackend {
); );
let mut state = self.ctx.create_state().map_err(|e| { let mut state = self.ctx.create_state().map_err(|e| {
MagnotiaError::TranscriptionFailed(WhisperBackendError::State(e.to_string()).to_string()) MagnotiaError::TranscriptionFailed(
WhisperBackendError::State(e.to_string()).to_string(),
)
})?; })?;
let mut params = FullParams::new(SamplingStrategy::Greedy { best_of: 1 }); let mut params = FullParams::new(SamplingStrategy::Greedy { best_of: 1 });

View File

@@ -30,7 +30,10 @@ fn jfk_transcription_benchmark() {
let sample_rate = u32::from_le_bytes(bytes[24..28].try_into().unwrap()); let sample_rate = u32::from_le_bytes(bytes[24..28].try_into().unwrap());
let channels = u16::from_le_bytes(bytes[22..24].try_into().unwrap()); let channels = u16::from_le_bytes(bytes[22..24].try_into().unwrap());
let bits = u16::from_le_bytes(bytes[34..36].try_into().unwrap()); let bits = u16::from_le_bytes(bytes[34..36].try_into().unwrap());
eprintln!("[bench] wav spec: {} Hz, {} ch, {}-bit", sample_rate, channels, bits); eprintln!(
"[bench] wav spec: {} Hz, {} ch, {}-bit",
sample_rate, channels, bits
);
assert_eq!(sample_rate, 16_000, "expected 16 kHz wav"); assert_eq!(sample_rate, 16_000, "expected 16 kHz wav");
assert_eq!(channels, 1, "expected mono"); assert_eq!(channels, 1, "expected mono");
assert_eq!(bits, 16, "expected 16-bit PCM"); assert_eq!(bits, 16, "expected 16-bit PCM");
@@ -48,7 +51,10 @@ fn jfk_transcription_benchmark() {
); );
let rss_before_load_kb = read_rss_kb(); let rss_before_load_kb = read_rss_kb();
eprintln!("[bench] RSS before model load: {} MB", rss_before_load_kb / 1024); eprintln!(
"[bench] RSS before model load: {} MB",
rss_before_load_kb / 1024
);
let load_start = Instant::now(); let load_start = Instant::now();
let ctx = WhisperContext::new_with_params(&model_path, WhisperContextParameters::default()) let ctx = WhisperContext::new_with_params(&model_path, WhisperContextParameters::default())
@@ -57,9 +63,11 @@ fn jfk_transcription_benchmark() {
eprintln!("[bench] model load: {:.2}s", load_dur.as_secs_f64()); eprintln!("[bench] model load: {:.2}s", load_dur.as_secs_f64());
let rss_after_load_kb = read_rss_kb(); let rss_after_load_kb = read_rss_kb();
eprintln!("[bench] RSS after model load: {} MB (delta +{} MB)", eprintln!(
"[bench] RSS after model load: {} MB (delta +{} MB)",
rss_after_load_kb / 1024, rss_after_load_kb / 1024,
(rss_after_load_kb.saturating_sub(rss_before_load_kb)) / 1024); (rss_after_load_kb.saturating_sub(rss_before_load_kb)) / 1024
);
let mut state = ctx.create_state().expect("whisper state"); let mut state = ctx.create_state().expect("whisper state");
let mut params = FullParams::new(SamplingStrategy::Greedy { best_of: 1 }); let mut params = FullParams::new(SamplingStrategy::Greedy { best_of: 1 });
@@ -110,12 +118,20 @@ fn jfk_transcription_benchmark() {
let rss_final_kb = read_rss_kb(); let rss_final_kb = read_rss_kb();
eprintln!("[bench] RSS final: {} MB", rss_final_kb / 1024); eprintln!("[bench] RSS final: {} MB", rss_final_kb / 1024);
eprintln!(""); eprintln!();
eprintln!("=== SUMMARY ==="); eprintln!("=== SUMMARY ===");
eprintln!("audio: {:.2}s", audio_secs); eprintln!("audio: {:.2}s", audio_secs);
eprintln!("model_load: {:.2}s", load_dur.as_secs_f64()); eprintln!("model_load: {:.2}s", load_dur.as_secs_f64());
eprintln!("cold xc: {:.2}s RTF={:.3}", cold_dur.as_secs_f64(), cold_dur.as_secs_f64() / audio_secs); eprintln!(
eprintln!("warm xc: {:.2}s RTF={:.3}", warm_dur.as_secs_f64(), warm_dur.as_secs_f64() / audio_secs); "cold xc: {:.2}s RTF={:.3}",
cold_dur.as_secs_f64(),
cold_dur.as_secs_f64() / audio_secs
);
eprintln!(
"warm xc: {:.2}s RTF={:.3}",
warm_dur.as_secs_f64(),
warm_dur.as_secs_f64() / audio_secs
);
eprintln!("RSS peak: {} MB", rss_final_kb / 1024); eprintln!("RSS peak: {} MB", rss_final_kb / 1024);
} }
@@ -124,7 +140,9 @@ fn read_rss_kb() -> u64 {
let s = std::fs::read_to_string(format!("/proc/{pid}/status")).unwrap_or_default(); let s = std::fs::read_to_string(format!("/proc/{pid}/status")).unwrap_or_default();
for line in s.lines() { for line in s.lines() {
if let Some(rest) = line.strip_prefix("VmRSS:") { if let Some(rest) = line.strip_prefix("VmRSS:") {
return rest.trim().split_whitespace().next() return rest
.split_whitespace()
.next()
.and_then(|n| n.parse::<u64>().ok()) .and_then(|n| n.parse::<u64>().ok())
.unwrap_or(0); .unwrap_or(0);
} }

View File

@@ -82,14 +82,7 @@ fn whisper_thread_count_sweep() {
for (label, power, gpu_offloaded_for_helper) in panels { for (label, power, gpu_offloaded_for_helper) in panels {
env::set_var("MAGNOTIA_POWER_STATE_OVERRIDE", power); env::set_var("MAGNOTIA_POWER_STATE_OVERRIDE", power);
let helper_pick = inference_thread_count(Workload::Whisper, gpu_offloaded_for_helper); let helper_pick = inference_thread_count(Workload::Whisper, gpu_offloaded_for_helper);
run_sweep_panel( run_sweep_panel(label, helper_pick, &ctx, &samples, audio_secs, &targets);
label,
helper_pick,
&ctx,
&samples,
audio_secs,
&targets,
);
} }
env::remove_var("MAGNOTIA_POWER_STATE_OVERRIDE"); env::remove_var("MAGNOTIA_POWER_STATE_OVERRIDE");
} }
@@ -102,10 +95,8 @@ fn run_sweep_panel(
audio_secs: f64, audio_secs: f64,
targets: &[i32], targets: &[i32],
) { ) {
eprintln!(""); eprintln!();
eprintln!( eprintln!("=== n_threads scaling: {label} (helper picks: {helper_pick}) ===");
"=== n_threads scaling: {label} (helper picks: {helper_pick}) ==="
);
eprintln!("n_threads | xc_time | RTF | speedup_vs_1"); eprintln!("n_threads | xc_time | RTF | speedup_vs_1");
eprintln!("----------|---------|--------|-------------"); eprintln!("----------|---------|--------|-------------");
let mut baseline_dur: Option<f64> = None; let mut baseline_dur: Option<f64> = None;

View File

@@ -2,7 +2,7 @@
name: Slice 02 — Tauri runtime name: Slice 02 — Tauri runtime
type: architecture-map-page type: architecture-map-page
slice: 02-tauri-runtime slice: 02-tauri-runtime
last_verified: 2026/05/09 last_verified: 2026/05/12
--- ---
# Slice 02 — Tauri runtime # Slice 02 — Tauri runtime
@@ -29,7 +29,7 @@ last_verified: 2026/05/09
App boot, config, tests: App boot, config, tests:
- [App lifecycle](app-lifecycle.md). `src-tauri/src/main.rs` and `src-tauri/src/lib.rs`. Run entry, AppState construction, plugin wiring, preferences injection, X11-on-Wayland workaround, panic hook, error-log pruning, command registration. - [App lifecycle](app-lifecycle.md). `src-tauri/src/main.rs` and `src-tauri/src/lib.rs`. Run entry, AppState construction, plugin wiring, preferences injection, Linux launcher-env warning, panic hook, error-log pruning, command registration.
- [System tray](system-tray.md). Desktop-only tray icon and menu (`src-tauri/src/tray.rs`). - [System tray](system-tray.md). Desktop-only tray icon and menu (`src-tauri/src/tray.rs`).
- [Tauri config](tauri-config.md). `tauri.conf.json` plus the Linux native-decorations overlay; CSP, window defaults, bundle settings. - [Tauri config](tauri-config.md). `tauri.conf.json` plus the Linux native-decorations overlay; CSP, window defaults, bundle settings.
- [Capabilities and ACL](capabilities-and-acl.md). The two ACL files in `capabilities/`, what each scopes, and the Phase 9 high-risk-permission firewall. - [Capabilities and ACL](capabilities-and-acl.md). The two ACL files in `capabilities/`, what each scopes, and the Phase 9 high-risk-permission firewall.
@@ -57,7 +57,7 @@ Individual command pages (linked from the commands index):
- `commands::update::install_update` returns a hard-coded "Updates are disabled until release signing is configured." (`src-tauri/src/commands/update.rs:15`). The integration test `updater_is_signed_or_absent` (`src-tauri/tests/config_hardening.rs:35`) only asserts that *if* an updater config ships, it carries a non-empty pubkey. There is currently no updater config in `tauri.conf.json`, so the test is an empty no-op. - `commands::update::install_update` returns a hard-coded "Updates are disabled until release signing is configured." (`src-tauri/src/commands/update.rs:15`). The integration test `updater_is_signed_or_absent` (`src-tauri/tests/config_hardening.rs:35`) only asserts that *if* an updater config ships, it carries a non-empty pubkey. There is currently no updater config in `tauri.conf.json`, so the test is an empty no-op.
- `commands::power::PowerAssertion` is a no-op on Linux and Windows (`src-tauri/src/commands/power.rs:90`). Only macOS has a real implementation via `objc2`. The doc comment promises `SetThreadExecutionState` (Windows) and logind inhibitors (Linux), but neither is wired up. Symptom: long live-dictation sessions on Linux can be idled by the compositor. - `commands::power::PowerAssertion` is a no-op on Linux and Windows (`src-tauri/src/commands/power.rs:90`). Only macOS has a real implementation via `objc2`. The doc comment promises `SetThreadExecutionState` (Windows) and logind inhibitors (Linux), but neither is wired up. Symptom: long live-dictation sessions on Linux can be idled by the compositor.
- `src-tauri/.cargo/config.toml` hard-codes `LIBCLANG_PATH = "C:\\Program Files\\LLVM\\bin"` (Windows). It is harmless on non-Windows hosts (env var is just unused) but it is a foot-gun if someone moves Clang elsewhere on Windows. See [Cargo and features](cargo-and-features.md). - `src-tauri/.cargo/config.toml` hard-codes `LIBCLANG_PATH = "C:\\Program Files\\LLVM\\bin"` (Windows). It is harmless on non-Windows hosts (env var is just unused) but it is a foot-gun if someone moves Clang elsewhere on Windows. See [Cargo and features](cargo-and-features.md).
- The Linux Wayland workaround in `lib.rs` (`ensure_x11_on_wayland`) sets env vars before any threads spawn. This is the right pattern, but it ships even when the user is on a working DMA-BUF stack. The override knob is `WEBKIT_DISABLE_DMABUF_RENDERER=0` set by the user; documented in the function header but not surfaced anywhere user-visible. - The Linux rendering workaround in `lib.rs` (`warn_if_x11_env_unset_on_wayland`) now warns when the launcher has not set the expected env vars; it no longer mutates the process environment. In development, `run.sh` / `npm run dev:tauri` owns the defaults (`WEBKIT_DISABLE_DMABUF_RENDERER=1` on Linux; `GDK_BACKEND=x11` and `WINIT_UNIX_BACKEND=x11` on Wayland). The user opt-out remains `WEBKIT_DISABLE_DMABUF_RENDERER=0`, set before launching.
- `commands::diagnostics::install_panic_hook` writes a "minimal text dump" without backtraces unless the user has `RUST_BACKTRACE=1` set (`src-tauri/src/commands/diagnostics.rs:42`). The packaged binary does not set it, so production crash dumps will lack stack traces. Document or default-enable. - `commands::diagnostics::install_panic_hook` writes a "minimal text dump" without backtraces unless the user has `RUST_BACKTRACE=1` set (`src-tauri/src/commands/diagnostics.rs:42`). The packaged binary does not set it, so production crash dumps will lack stack traces. Document or default-enable.
- `commands::audio::list_audio_devices` is gated `ensure_main_window` but the `secondary-windows` capability does not invoke it, which is correct. The `clipboard::copy_to_clipboard` command, by contrast, has no main-window guard and any window with the `core:default` permission can call it; intentional, but worth flagging. - `commands::audio::list_audio_devices` is gated `ensure_main_window` but the `secondary-windows` capability does not invoke it, which is correct. The `clipboard::copy_to_clipboard` command, by contrast, has no main-window guard and any window with the `core:default` permission can call it; intentional, but worth flagging.

View File

@@ -2,19 +2,19 @@
name: App lifecycle name: App lifecycle
type: architecture-map-page type: architecture-map-page
slice: 02-tauri-runtime slice: 02-tauri-runtime
last_verified: 2026/05/09 last_verified: 2026/05/12
--- ---
# App lifecycle # App lifecycle
> **Where you are:** [Architecture map](../README.md) → [Tauri runtime](README.md) → App lifecycle > **Where you are:** [Architecture map](../README.md) → [Tauri runtime](README.md) → App lifecycle
**Plain English summary.** This is the entry point. `main.rs` calls `magnotia_lib::run()` and `lib.rs::run` does everything that has to happen before the user sees a window: sets a Linux Wayland workaround, installs the Rust panic hook, registers Tauri plugins, opens the SQLite database, prunes the error log, builds a JS preferences-injection script, configures the WebKit media-permission grant on Linux, wires close-to-tray, populates `AppState` and the per-domain managed states, emits any runtime warnings, sets up the system tray, and finally registers all 71 Tauri commands. **Plain English summary.** This is the entry point. `main.rs` calls `magnotia_lib::run()` and `lib.rs::run` does everything that has to happen before the user sees a window: initialises tracing, checks the Linux launcher env-var contract, installs the Rust panic hook, registers Tauri plugins, opens the SQLite database, prunes the error log, builds a JS preferences-injection script, configures the WebKit media-permission grant on Linux, wires close-to-tray, populates `AppState` and the per-domain managed states, emits any runtime warnings, sets up the system tray, and finally registers all 71 Tauri commands.
## At a glance ## At a glance
- Path: `src-tauri/src/main.rs`, `src-tauri/src/lib.rs`. - Path: `src-tauri/src/main.rs`, `src-tauri/src/lib.rs`.
- LOC: 5 (main) + 448 (lib). - LOC: 5 (main) + 491 (lib).
- Tauri commands exposed directly here: `save_preferences` (string preferences -> SQLite settings table). All other commands live under `commands::*` and are registered via `tauri::generate_handler!`. - Tauri commands exposed directly here: `save_preferences` (string preferences -> SQLite settings table). All other commands live under `commands::*` and are registered via `tauri::generate_handler!`.
- Events emitted directly here: none (runtime warnings are emitted by `commands::models::emit_runtime_warnings`, called from setup). - Events emitted directly here: none (runtime warnings are emitted by `commands::models::emit_runtime_warnings`, called from setup).
- Depends on: `tauri`, `sqlx::SqlitePool`, `magnotia_core::types::EngineName`, `magnotia_llm::LlmEngine`, `magnotia_storage::{init, database_path, get_setting, set_setting, prune_error_log}`, `magnotia_transcription::LocalEngine`, plus the `commands::*` and `tray` modules. - Depends on: `tauri`, `sqlx::SqlitePool`, `magnotia_core::types::EngineName`, `magnotia_llm::LlmEngine`, `magnotia_storage::{init, database_path, get_setting, set_setting, prune_error_log}`, `magnotia_transcription::LocalEngine`, plus the `commands::*` and `tray` modules.
@@ -46,16 +46,18 @@ Functions:
- `build_preferences_script(prefs_json: Option<String>) -> String` (`src-tauri/src/lib.rs:38`). Builds an IIFE that reads saved preferences (theme, zone, accessibility settings: font family, font size, letter spacing, line height, transcript size, bionic reading, reduce motion) and applies them to `<html>` before the rest of the document loads. Embeds the JSON via `serde_json::to_string` to keep it safe. - `build_preferences_script(prefs_json: Option<String>) -> String` (`src-tauri/src/lib.rs:38`). Builds an IIFE that reads saved preferences (theme, zone, accessibility settings: font family, font size, letter spacing, line height, transcript size, bionic reading, reduce motion) and applies them to `<html>` before the rest of the document loads. Embeds the JSON via `serde_json::to_string` to keep it safe.
- `save_preferences(state, preferences) -> Result<(), String>` (`src-tauri/src/lib.rs:73`). The single command in `lib.rs`. Persists the preferences blob to the SQLite settings table under key `magnotia_preferences`. - `save_preferences(state, preferences) -> Result<(), String>` (`src-tauri/src/lib.rs:73`). The single command in `lib.rs`. Persists the preferences blob to the SQLite settings table under key `magnotia_preferences`.
- `ensure_x11_on_wayland()` (`src-tauri/src/lib.rs:99`, Linux only). Sets `WEBKIT_DISABLE_DMABUF_RENDERER=1` unconditionally on Linux (iGPU idle-cost workaround), and additionally sets `GDK_BACKEND=x11` plus `WINIT_UNIX_BACKEND=x11` when `XDG_SESSION_TYPE=wayland`. Idempotent: if a value is already set, the function leaves it alone. Must run before any threads spawn — uses `unsafe { std::env::set_var(...) }`. - `init_tracing()` (Linux/macOS/Windows). Initialises the process-wide tracing subscriber once, honours `RUST_LOG`, and writes structured startup/runtime logs to stderr.
- `run()` (`src-tauri/src/lib.rs:135`). The Tauri builder pipeline. - `warn_if_x11_env_unset_on_wayland()` (Linux only). Emits a `magnotia_startup` warning when the launcher has not pre-set `WEBKIT_DISABLE_DMABUF_RENDERER` (always expected on Linux), or `GDK_BACKEND=x11` / `WINIT_UNIX_BACKEND=x11` when `XDG_SESSION_TYPE=wayland`. It does not mutate the process environment; `run.sh` owns the dev-time contract and package wrappers/.desktop files must own the distribution-time contract.
- `run()`. The Tauri builder pipeline.
### `run()` step-by-step ### `run()` step-by-step
1. **Linux env-var prelude.** Calls `ensure_x11_on_wayland()` on Linux (`src-tauri/src/lib.rs:137`). 1. **Tracing init.** Calls `init_tracing()` before any startup warnings/logs are emitted.
2. **Panic hook.** Calls `commands::diagnostics::install_panic_hook()` to dump panic info to `crashes_dir()` (`src-tauri/src/lib.rs:141`). 2. **Linux launcher contract check.** Calls `warn_if_x11_env_unset_on_wayland()` on Linux. Missing env vars produce warnings only; runtime env-var mutation was removed because Rust 2024 treats environment mutation in multi-threaded programs as unsafe.
3. **Plugin wiring (always-on).** `tauri_plugin_opener`, `tauri_plugin_dialog`, `tauri_plugin_notification` (`src-tauri/src/lib.rs:144`). 3. **Panic hook.** Calls `commands::diagnostics::install_panic_hook()` to dump panic info to `crashes_dir()`.
4. **Plugin wiring (desktop-only).** `tauri_plugin_global_shortcut`, `tauri_plugin_autostart` (LaunchAgent on macOS), `tauri_plugin_window_state` (`src-tauri/src/lib.rs:158`). 4. **Plugin wiring (always-on).** `tauri_plugin_opener`, `tauri_plugin_dialog`, `tauri_plugin_notification` (`src-tauri/src/lib.rs:144`).
5. **Setup hook.** This is where the bulk of startup work lives: 5. **Plugin wiring (desktop-only).** `tauri_plugin_global_shortcut`, `tauri_plugin_autostart` (LaunchAgent on macOS), `tauri_plugin_window_state` (`src-tauri/src/lib.rs:158`).
6. **Setup hook.** This is where the bulk of startup work lives:
- Initialise SQLite via `magnotia_storage::init(&database_path()).await` using `tauri::async_runtime::block_on` (`src-tauri/src/lib.rs:180`). The `Instant::now()` timing is logged. - Initialise SQLite via `magnotia_storage::init(&database_path()).await` using `tauri::async_runtime::block_on` (`src-tauri/src/lib.rs:180`). The `Instant::now()` timing is logged.
- Prune `error_log` rows older than 90 days (`src-tauri/src/lib.rs:189`). Best-effort: a failure logs but does not block startup. - Prune `error_log` rows older than 90 days (`src-tauri/src/lib.rs:189`). Best-effort: a failure logs but does not block startup.
- Load saved preferences from the settings table; build the JS injection script (`src-tauri/src/lib.rs:204`). - Load saved preferences from the settings table; build the JS injection script (`src-tauri/src/lib.rs:204`).
@@ -66,8 +68,8 @@ Functions:
- Build the `AppState` itself: fresh `LocalEngine`s for whisper and parakeet, the open `SqlitePool`, a fresh `LlmEngine` (`src-tauri/src/lib.rs:302`). - Build the `AppState` itself: fresh `LocalEngine`s for whisper and parakeet, the open `SqlitePool`, a fresh `LlmEngine` (`src-tauri/src/lib.rs:302`).
- Emit runtime warnings (CPU baseline, Vulkan loader) via `commands::models::emit_runtime_warnings` (`src-tauri/src/lib.rs:312`). - Emit runtime warnings (CPU baseline, Vulkan loader) via `commands::models::emit_runtime_warnings` (`src-tauri/src/lib.rs:312`).
- Setup the system tray on desktop (`src-tauri/src/lib.rs:314`). - Setup the system tray on desktop (`src-tauri/src/lib.rs:314`).
6. **Command registration.** `tauri::generate_handler![...]` lists 71 commands (`src-tauri/src/lib.rs:321`). The order in the macro is grouped by domain (preferences, models, LLM, transcription, audio, tasks, feedback, TTS, rituals, nudges, intentions, profiles, transcripts, diagnostics, live, windows, clipboard, fs, paste, meeting, hardware, hotkey, updater). 7. **Command registration.** `tauri::generate_handler![...]` lists 71 commands (`src-tauri/src/lib.rs:321`). The order in the macro is grouped by domain (preferences, models, LLM, transcription, audio, tasks, feedback, TTS, rituals, nudges, intentions, profiles, transcripts, diagnostics, live, windows, clipboard, fs, paste, meeting, hardware, hotkey, updater).
7. **Run.** `.run(tauri::generate_context!())` blocks the main thread until the app exits. Panics are wrapped with `expect("error while running Magnotia")`. 8. **Run.** `.run(tauri::generate_context!())` blocks the main thread until the app exits. Panics are wrapped with `expect("error while running Magnotia")`.
## Data flow ## Data flow
@@ -79,8 +81,8 @@ Functions:
## Watch-outs ## Watch-outs
- `tauri::async_runtime::block_on` inside `setup` blocks startup. The DB init and prefs read are explicitly timed and logged so regressions show up. Adding more synchronous async work here directly pushes the time-to-first-paint up. - `tauri::async_runtime::block_on` inside `setup` blocks startup. The DB init and prefs read are explicitly timed and logged so regressions show up. Adding more synchronous async work here directly pushes the time-to-first-paint up.
- The Linux media-permission wire-up is non-fatal: if `with_webview` fails the app still boots, but `getUserMedia` will be silently denied or fall back to a prompt the user cannot answer (no UI). The error path logs `[startup] failed to configure webview media permissions: ...` to stderr. - The Linux media-permission wire-up is non-fatal: if `with_webview` fails the app still boots, but `getUserMedia` will be silently denied or fall back to a prompt the user cannot answer (no UI). The error path logs a `magnotia_startup` warning.
- The `unsafe std::env::set_var` calls in `ensure_x11_on_wayland` are sound only because nothing else in the binary has spawned a thread yet at that point. Do not introduce another startup-time env mutation outside this function unless the same invariant is preserved. - Linux rendering env vars are a launcher contract, not a runtime mutation. In development, use `npm run dev:tauri` / `./run.sh`; packaged Linux builds need an equivalent wrapper or `.desktop` `Exec=env` policy. `WEBKIT_DISABLE_DMABUF_RENDERER=0` remains the user opt-out for the DMA-BUF workaround.
- Close-to-tray works only on desktop (the `cfg!(not(target_os = "android"))` block). On Android, closing the activity terminates the process, which is the expected platform behaviour. - Close-to-tray works only on desktop (the `cfg!(not(target_os = "android"))` block). On Android, closing the activity terminates the process, which is the expected platform behaviour.
- The `prewarm_default_model` call is *not* wired here. `commands::models::prewarm_default_model` exists, but `setup` does not invoke it. The frontend invokes the matching `prewarm_default_model_cmd` command after the main page mounts. If you ever want to shift pre-warm into setup, watch the spawn_blocking ordering against the engine `Arc` clones. - The `prewarm_default_model` call is *not* wired here. `commands::models::prewarm_default_model` exists, but `setup` does not invoke it. The frontend invokes the matching `prewarm_default_model_cmd` command after the main page mounts. If you ever want to shift pre-warm into setup, watch the spawn_blocking ordering against the engine `Arc` clones.

View File

@@ -34,7 +34,7 @@ last_verified: 2026/05/09
| `AudioCaptureFailed(String)` | `audio capture failed: {0}` | cpal / native capture failures (slice 3). | | `AudioCaptureFailed(String)` | `audio capture failed: {0}` | cpal / native capture failures (slice 3). |
| `DownloadFailed(String)` | `model download failed: {0}` | Resumable download errors. | | `DownloadFailed(String)` | `model download failed: {0}` | Resumable download errors. |
| `FileNotFound(PathBuf)` | `file not found: {<displayed>}` | `PathBuf::display()` interpolated. | | `FileNotFound(PathBuf)` | `file not found: {<displayed>}` | `PathBuf::display()` interpolated. |
| `StorageError(String)` | `storage error: {0}` | sqlx, profile FK violations, migration failures. | | `Storage { kind, operation, detail }` | `{detail}` | Boundary shape produced by `From<magnotia_storage::Error>`. `kind` is the serialisable discriminator (`StorageKind`), `operation` is the typed operation label, `detail` is the storage crate's own `Display` output (no double prefix). |
| `Io(std::io::Error)` | `io error: {0}` | `#[from]` so `?`-conversion from `std::io::Error` is automatic. | | `Io(std::io::Error)` | `io error: {0}` | `#[from]` so `?`-conversion from `std::io::Error` is automatic. |
| `Other(String)` | `{0}` | Catch-all bucket. | | `Other(String)` | `{0}` | Catch-all bucket. |
@@ -49,12 +49,12 @@ Every public function in the workspace that can fail returns `magnotia_core::Res
## Data flow / contract ## Data flow / contract
- All variants are `Serialize`-able. `std::io::Error` does not derive `Serialize`, so the `Io` variant uses a custom `serialize_with` adaptor (`serialize_io_error` at `crates/core/src/error.rs:53`) that emits the error's `Display` string. - All variants are `Serialize`-able. `std::io::Error` does not derive `Serialize`, so the `Io` variant uses a custom `serialize_with` adaptor (`serialize_io_error` at `crates/core/src/error.rs:53`) that emits the error's `Display` string.
- Variants do not carry source-location information. If you need a stack-style trace, attach context at the call site by wrapping in `StorageError(format!("{action} failed: {e}"))` — the storage CRUD layer follows this convention universally. - Variants do not carry source-location information. The storage CRUD layer attaches context via the typed `magnotia_storage::Error::Query { operation, source }` shape; the `operation` label survives into `MagnotiaError::Storage.operation` at the boundary.
- Tauri serialises the enum verbatim. The frontend can switch on the discriminant by reading the JSON tag (the variant name). - Tauri serialises the enum verbatim. The frontend can switch on the discriminant by reading the JSON tag (the variant name).
## Watch-outs ## Watch-outs
- **No `From<sqlx::Error>` impl.** The storage crate manually converts every sqlx error to `MagnotiaError::StorageError(format!(...))`. Adding an automatic `From` would let raw sqlx error strings leak into the frontend; the explicit map step is intentional. - **No `From<sqlx::Error>` impl in core.** The storage crate manually converts every sqlx error to a typed `magnotia_storage::Error::Query` with an operation label, then `From<storage::Error> for MagnotiaError` (defined inside the storage crate to avoid a `core -> storage` dependency cycle) flattens it into `MagnotiaError::Storage { kind, operation, detail }` at the boundary. Adding an automatic `From<sqlx::Error>` would erase the per-site operation label; the explicit map step is intentional.
- **No `Source` chain.** `thiserror` would let you wrap source errors in fields with `#[source]` for chained `Display`. Today every wrapped error is flattened to `String` to keep the JSON shape simple. - **No `Source` chain.** `thiserror` would let you wrap source errors in fields with `#[source]` for chained `Display`. Today every wrapped error is flattened to `String` to keep the JSON shape simple.
- **`Other(String)` is a leaky bucket.** New error categories should get their own variant rather than reaching for `Other`. Audit `Other` usage if the error log starts hiding distinct failure modes behind the same string. - **`Other(String)` is a leaky bucket.** New error categories should get their own variant rather than reaching for `Other`. Audit `Other` usage if the error log starts hiding distinct failure modes behind the same string.

View File

@@ -2,65 +2,107 @@
name: Dev launcher and scripts name: Dev launcher and scripts
type: architecture-map-page type: architecture-map-page
slice: 05-core-storage-hotkey-build slice: 05-core-storage-hotkey-build
last_verified: 2026/05/09 last_verified: 2026/05/12
--- ---
# Dev launcher and scripts # Dev launcher and scripts
> **Where you are:** [Architecture map](../README.md) → [Core, Storage, Hotkey, Build](README.md) → Dev launcher and scripts > **Where you are:** [Architecture map](../README.md) → [Core, Storage, Hotkey, Build](README.md) → Dev launcher and scripts
**Plain English summary.** Two ways to start Magnotia in development. `run.sh` is the bespoke launcher that starts Vite first and then Tauri (avoiding a Tauri-spawned-second-Vite mess). The `package.json` scripts are the canonical npm-run interface — the same scripts CI uses for the frontend gate. **Plain English summary.** `run.sh` is the canonical full-stack development launcher and is exposed through `npm run dev:tauri`. It starts Vite first, waits for it to bind port 1420, then runs Tauri with Tauri's own `beforeDevCommand` disabled so a second Vite instance does not race the first one. On Linux it also owns the rendering env-var contract that `src-tauri/src/lib.rs::warn_if_x11_env_unset_on_wayland` checks.
## At a glance ## At a glance
- Files: `run.sh` (636 bytes, +x), `package.json` (1,211 bytes), `package-lock.json` (109 KB). - Files: `run.sh` (+x), `package.json`, `package-lock.json`.
- External tools: `npm`, `npx`, `curl`, `bash` (run.sh); the Node toolchain (package.json scripts). - External tools: `npm`, `npx`, `curl`, `bash` (`run.sh`); the Node toolchain (`package.json` scripts).
- Consumers: `run.sh` is invoked by Jake on his Monolith. The `package.json` scripts are invoked by both CI (`npm run build`) and developers (`npm run dev`). - Consumers: developers use `npm run dev:tauri` (canonical) or `./run.sh` (direct shell equivalent). CI uses package scripts such as `npm run build` and `npm run check`.
## `run.sh` — the local launcher ## `run.sh` — the full-stack dev launcher
```bash ```bash
#!/usr/bin/env bash #!/usr/bin/env bash
# Magnotia dev launcher. Starts Vite first, waits for it to bind port 1420, # Magnotia dev launcher. Starts Vite first, waits for it to bind port 1420,
# then runs Tauri without triggering a second Vite instance. # then runs Tauri without triggering a second Vite instance.
# Sets the Linux rendering env vars that warn_if_x11_env_unset_on_wayland
# (src-tauri/src/lib.rs) expects the launcher to own.
# For performance testing use: npm run tauri build # For performance testing use: npm run tauri build
set -euo pipefail set -euo pipefail
cd "$(dirname "$0")" cd "$(dirname "$0")"
export LIBCLANG_PATH=/usr/lib64/llvm21/lib64 case "$(uname -s)" in
Linux)
# Bindgen libclang path. Fedora 41/LLVM 21 default; user-set wins.
export LIBCLANG_PATH="${LIBCLANG_PATH:-/usr/lib64/llvm21/lib64}"
# iGPU idle-cost workaround. Set to 0 explicitly to opt out.
export WEBKIT_DISABLE_DMABUF_RENDERER="${WEBKIT_DISABLE_DMABUF_RENDERER:-1}"
if [ "${XDG_SESSION_TYPE:-}" = "wayland" ]; then
export GDK_BACKEND="${GDK_BACKEND:-x11}"
export WINIT_UNIX_BACKEND="${WINIT_UNIX_BACKEND:-x11}"
fi
;;
esac
printf 'Starting Vite dev server...\n' >&2 printf 'Starting Vite dev server...\n' >&2
npm run dev:frontend & npm run dev:frontend &
VITE_PID=$! VITE_PID=$!
until curl -sf http://localhost:1420 > /dev/null 2>&1; do sleep 0.5; done
printf 'Vite ready. Launching Tauri...\n' >&2
cleanup() { cleanup() {
kill "$VITE_PID" 2>/dev/null || true kill "$VITE_PID" 2>/dev/null || true
} }
trap cleanup EXIT INT TERM trap cleanup EXIT INT TERM
npx tauri dev --config '{"build":{"beforeDevCommand":""}}' # Wait up to 60s for Vite, bailing if it exits early.
for _ in $(seq 1 120); do
if curl -sf http://localhost:1420 > /dev/null 2>&1; then
break
fi
if ! kill -0 "$VITE_PID" 2>/dev/null; then
printf 'Vite dev server exited before becoming ready.\n' >&2
wait "$VITE_PID"
exit 1
fi
sleep 0.5
done
if ! curl -sf http://localhost:1420 > /dev/null 2>&1; then
printf 'Timed out waiting for Vite on http://localhost:1420\n' >&2
exit 1
fi
printf 'Vite ready. Launching Tauri...\n' >&2
npx tauri dev --config '{"build":{"beforeDevCommand":""}}' "$@"
``` ```
### Why this exists ### Why this exists
The default `npm run tauri dev` invokes `tauri dev`, which honours `beforeDevCommand` from `tauri.conf.json` and spawns its own Vite. If Vite is already running (eg during interactive iteration), you get two instances racing for port 1420. The launcher solves it by starting Vite explicitly and disabling Tauri's spawn via `--config '{"build":{"beforeDevCommand":""}}'`. The default `tauri dev` path honours `beforeDevCommand` from `tauri.conf.json` and spawns Vite itself. That is convenient for the simple path, but during interactive iteration it can create a second Vite instance racing for port 1420. The launcher solves this by starting Vite explicitly and disabling Tauri's spawn via `--config '{"build":{"beforeDevCommand":""}}'`.
The launcher is also the dev-time owner of Linux rendering defaults. Rust startup no longer calls `std::env::set_var`; it only warns when the launcher contract was missed.
### Steps ### Steps
1. `set -euo pipefail` — early exit on any command failure or unset variable. 1. `set -euo pipefail` — early exit on any command failure or unset variable.
2. `cd "$(dirname "$0")"` — anchor to the repo root. 2. `cd "$(dirname "$0")"` — anchor to the repo root.
3. **Hard-codes `LIBCLANG_PATH=/usr/lib64/llvm21/lib64`** — Fedora 41 default LLVM 21 path. **Breaks on Debian / Ubuntu / macOS.** Worth softening to `LIBCLANG_PATH=${LIBCLANG_PATH:-/usr/lib64/llvm21/lib64}` so a developer-set value wins. 3. On Linux, default `LIBCLANG_PATH` to Fedora's LLVM path only if the user has not set it already.
4. Spawn `npm run dev:frontend &`; capture PID in `VITE_PID`. 4. On Linux, default `WEBKIT_DISABLE_DMABUF_RENDERER=1`; users can opt out with `WEBKIT_DISABLE_DMABUF_RENDERER=0 ./run.sh`.
5. Poll `curl -sf http://localhost:1420` every 500 ms until 200 OK. 5. On Wayland sessions, default `GDK_BACKEND=x11` and `WINIT_UNIX_BACKEND=x11`; user-set values win.
6. Trap `EXIT INT TERM` to kill the Vite process group on script exit. 6. Spawn `npm run dev:frontend &`; capture its PID.
7. `npx tauri dev` with `beforeDevCommand` disabled. 7. Install a trap that kills the spawned `npm run dev:frontend` process on launcher exit/interrupt.
8. Poll `curl -sf http://localhost:1420` every 500 ms for up to 60 seconds; if Vite exits early or never becomes ready, fail instead of hanging forever.
9. Run `npx tauri dev` with `beforeDevCommand` disabled and forward any extra args (`./run.sh --release`).
### Linux rendering env-var contract
`src-tauri/src/lib.rs::warn_if_x11_env_unset_on_wayland` is a safety net. It warns when the process starts without env vars that must be set before WebKitGTK/WINIT initialise:
- `WEBKIT_DISABLE_DMABUF_RENDERER=1` — Linux-wide iGPU idle-cost workaround. Set `WEBKIT_DISABLE_DMABUF_RENDERER=0` before launching to opt out.
- `GDK_BACKEND=x11` and `WINIT_UNIX_BACKEND=x11` — only expected when `XDG_SESSION_TYPE=wayland`.
Development uses `run.sh` for that contract. Packaged Linux builds need an equivalent wrapper or `.desktop` `Exec=env` policy; see the residual packaging plan.
### `LIBCLANG_PATH` rationale ### `LIBCLANG_PATH` rationale
Bindgen (pulled by `whisper-rs-sys` and `llama-cpp-sys-2`) needs a `libclang.so` at build time. On Fedora's LLVM 21 packaging, the canonical location is `/usr/lib64/llvm21/lib64`. On Debian-family the path is `/usr/lib/llvm-N/lib`; on macOS Homebrew it's `$(brew --prefix llvm)/lib`. The hard-coded value means a clean `git clone` on a non-Fedora machine fails the first build until the developer sets `LIBCLANG_PATH` themselves. Bindgen (pulled by `whisper-rs-sys` and `llama-cpp-sys-2`) needs a `libclang.so` at build time. On Fedora's LLVM 21 packaging, the canonical location is `/usr/lib64/llvm21/lib64`. On Debian-family the path is `/usr/lib/llvm-N/lib`; on macOS Homebrew it's `$(brew --prefix llvm)/lib`. `run.sh` now uses `LIBCLANG_PATH=${LIBCLANG_PATH:-/usr/lib64/llvm21/lib64}` on Linux so Fedora works out of the box while developer-set values win.
## `package.json` — npm scripts and deps ## `package.json` — npm scripts and deps
@@ -70,6 +112,7 @@ Bindgen (pulled by `whisper-rs-sys` and `llama-cpp-sys-2`) needs a `libclang.so`
"scripts": { "scripts": {
"dev": "npm run dev:frontend", "dev": "npm run dev:frontend",
"dev:frontend": "svelte-kit sync && vite dev", "dev:frontend": "svelte-kit sync && vite dev",
"dev:tauri": "./run.sh",
"build": "vite build", "build": "vite build",
"preview": "vite preview", "preview": "vite preview",
"check": "svelte-kit sync && svelte-check --tsconfig ./jsconfig.json", "check": "svelte-kit sync && svelte-check --tsconfig ./jsconfig.json",
@@ -79,9 +122,10 @@ Bindgen (pulled by `whisper-rs-sys` and `llama-cpp-sys-2`) needs a `libclang.so`
``` ```
- `npm run dev` — frontend only (no Tauri). Useful for pure-UI iteration. - `npm run dev` — frontend only (no Tauri). Useful for pure-UI iteration.
- `npm run dev:tauri` — canonical full-stack dev launcher; calls `./run.sh`.
- `npm run build` — Vite production build. CI gate. - `npm run build` — Vite production build. CI gate.
- `npm run check``svelte-check` against `jsconfig.json`. CI gate. - `npm run check``svelte-check` against `jsconfig.json`. CI gate.
- `npm run tauri` — passthrough. `npm run tauri dev`, `npm run tauri build`, etc. - `npm run tauri` — passthrough for non-dev Tauri commands such as `npm run tauri build`.
### Runtime dependencies ### Runtime dependencies
@@ -104,8 +148,7 @@ Bindgen (pulled by `whisper-rs-sys` and `llama-cpp-sys-2`) needs a `libclang.so`
## Watch-outs ## Watch-outs
- **`run.sh` `LIBCLANG_PATH` is Fedora-specific.** A new developer on Ubuntu / macOS hits a build failure on first run and has to either edit `run.sh` or pre-set the env var. Worth the conditional default. - **The trap kills the spawned npm process, not a full process group.** If Vite-orphan leaks become a real dev annoyance, switch to starting a process group (`setsid`) and killing `-- -$VITE_PID` on Linux.
- **No `dev:fullstack` script.** The Tauri dev path goes through `npx tauri dev` (or `run.sh`), not through an npm script. A `npm run dev:fullstack` shortcut would make discovery easier.
- **Tailwind v4 is in flight.** Major version, no PostCSS config. Cross-link to slice 1 for the consumed surface. - **Tailwind v4 is in flight.** Major version, no PostCSS config. Cross-link to slice 1 for the consumed surface.
- **`@tauri-apps/api` is pinned to `2.10.1` (no caret).** Intentional because Tauri's JS bridge can shift behaviour subtly between minors. Pinned ensures we test what we ship. - **`@tauri-apps/api` is pinned to `2.10.1` (no caret).** Intentional because Tauri's JS bridge can shift behaviour subtly between minors. Pinned ensures we test what we ship.
- **Plugin versions are caret-ranged.** Compatible-change updates land transparently. `npm audit` (CI) catches advisories. - **Plugin versions are caret-ranged.** Compatible-change updates land transparently. `npm audit` (CI) catches advisories.

View File

@@ -56,11 +56,11 @@ Single-row select.
### `create_profile(pool, id, name, initial_prompt) -> Result<ProfileRow>` — `crates/storage/src/database.rs:968` ### `create_profile(pool, id, name, initial_prompt) -> Result<ProfileRow>` — `crates/storage/src/database.rs:968`
UUID is generated by the caller. `name` has a `UNIQUE` constraint at the column level (migration v6); a duplicate name returns a friendly `MagnotiaError::StorageError("Profile name already exists: ...")`. UUID is generated by the caller. `name` has a `UNIQUE` constraint at the column level (migration v6); a duplicate name returns `magnotia_storage::Error::Query { operation: "create_profile", source }` carrying the sqlx UNIQUE-constraint error.
### `update_profile(pool, id, name, initial_prompt)` — `crates/storage/src/database.rs:995` ### `update_profile(pool, id, name, initial_prompt)` — `crates/storage/src/database.rs:995`
Renames the profile and / or rewrites the initial prompt. **Updating the default profile's name** raises `MagnotiaError::StorageError` because the `trg_protect_default_profile_rename` trigger (migration v6) calls `RAISE(ABORT, 'cannot rename the default profile')` on any `UPDATE OF id, name` where `OLD.id = DEFAULT_PROFILE_ID`. Updating only `initial_prompt` is allowed. Renames the profile and / or rewrites the initial prompt. **Updating the default profile's name** is short-circuited in Rust before hitting sqlite, raising `magnotia_storage::Error::InvalidReference { entity: Entity::Profile, reason: "Default profile cannot be renamed" }`. The `trg_protect_default_profile_rename` trigger (migration v6) is the structural backstop. Updating only `initial_prompt` is allowed.
### `delete_profile(pool, id) -> Result<()>` — `crates/storage/src/database.rs:1024` ### `delete_profile(pool, id) -> Result<()>` — `crates/storage/src/database.rs:1024`

View File

@@ -81,7 +81,7 @@ pub struct TranscriptRow {
### `insert_transcript(pool, &params) -> Result<()>` — `crates/storage/src/database.rs:80` ### `insert_transcript(pool, &params) -> Result<()>` — `crates/storage/src/database.rs:80`
1. **Pre-flight FK check.** `profile_exists(pool, params.profile_id)` (private, `database.rs:1094`) runs `SELECT 1 FROM profiles WHERE id = ?`. If the profile is not present, returns a friendly `MagnotiaError::StorageError("Insert transcript failed: unknown profile id '...'")`. Without this, sqlite would raise `FOREIGN KEY constraint failed` which the frontend cannot easily disambiguate. 1. **Pre-flight FK check.** `profile_exists(pool, params.profile_id)` (private, `database.rs:1094`) runs `SELECT 1 FROM profiles WHERE id = ?`. If the profile is not present, returns a typed `magnotia_storage::Error::InvalidReference { entity: Entity::Profile, reason: "unknown profile id '...'" }`. Without this, sqlite would raise `FOREIGN KEY constraint failed` which the frontend cannot easily disambiguate.
2. Single `INSERT INTO transcripts (...) VALUES (...)` with all 16 fields. 2. Single `INSERT INTO transcripts (...) VALUES (...)` with all 16 fields.
### `get_transcript(pool, id) -> Result<Option<TranscriptRow>>` — `crates/storage/src/database.rs:117` ### `get_transcript(pool, id) -> Result<Option<TranscriptRow>>` — `crates/storage/src/database.rs:117`

View File

@@ -82,7 +82,7 @@ Per-table CRUD is split across the per-page docs in this slice. See:
## Watch-outs ## Watch-outs
- **`PRAGMA foreign_keys = ON` is per-connection, not per-database.** The pool's `max_connections = 5` means we run the pragma once at init on the first connection. SQLite re-applies the pragma on each new pool connection because we set it via the connect options... but actually we don't, we set it after `connect_with`. **This is a latent issue worth verifying:** if a second pool connection opens later, foreign keys may not be enforced on it. Audit candidate. - **`PRAGMA foreign_keys = ON` is per-connection, not per-database.** The pool's `max_connections = 5` means we run the pragma once at init on the first connection. SQLite re-applies the pragma on each new pool connection because we set it via the connect options... but actually we don't, we set it after `connect_with`. **This is a latent issue worth verifying:** if a second pool connection opens later, foreign keys may not be enforced on it. Audit candidate.
- **No connection-level retry on locked DB.** `SQLITE_BUSY` propagates as `MagnotiaError::StorageError(...)`. With WAL mode + 5 max connections this is rare, but a long-running write under a slow filesystem could trigger it. - **No connection-level retry on locked DB.** `SQLITE_BUSY` propagates as `magnotia_storage::Error::Query { ... }` (flattened to `MagnotiaError::Storage { kind: Query, ... }` at the boundary). With WAL mode + 5 max connections this is rare, but a long-running write under a slow filesystem could trigger it.
- **Custom migration runner.** sqlx's bundled `migrate!` macro is not used. The custom runner is documented in [`storage-schema-and-migrations.md`](storage-schema-and-migrations.md) and was the subject of the C3 critical-issue write-up at `docs/issues/c3-migrations-atomicity.md`. - **Custom migration runner.** sqlx's bundled `migrate!` macro is not used. The custom runner is documented in [`storage-schema-and-migrations.md`](storage-schema-and-migrations.md) and was the subject of the C3 critical-issue write-up at `docs/issues/c3-migrations-atomicity.md`.
## Existing in-repo docs ## Existing in-repo docs

View File

@@ -78,5 +78,5 @@ The `panic = "abort"` setting is a deliberate trade-off:
## See also ## See also
- [CI pipeline](ci-pipeline.md) — uses this profile for release builds. - [CI pipeline](ci-pipeline.md) — uses this profile for release builds.
- [Dev launcher and scripts](dev-launcher-and-scripts.md) — `npm run tauri dev` uses the default debug profile. - [Dev launcher and scripts](dev-launcher-and-scripts.md) — `npm run dev:tauri` uses the default debug profile via the canonical `run.sh` launcher.
- [Storage Cargo configuration](storage-overview.md) — the per-crate sqlx feature stripping. - [Storage Cargo configuration](storage-overview.md) — the per-crate sqlx feature stripping.

View File

@@ -7,7 +7,7 @@ description: Authoritative build dependencies and launch instructions for Magnot
# Magnotia — Developer Setup # Magnotia — Developer Setup
Last updated: 2026/04/18. Primary dev target: Fedora 43, x86_64, KDE Wayland, NVIDIA RTX 4070. Last updated: 2026/05/12. Primary dev target: Fedora Linux, x86_64, KDE Wayland, NVIDIA RTX 4070.
--- ---
@@ -24,16 +24,18 @@ sudo dnf install cmake clang-devel
| `cmake` | whisper-rs-sys build system | | `cmake` | whisper-rs-sys build system |
| `clang-devel` | bindgen header generation for whisper-rs-sys | | `clang-devel` | bindgen header generation for whisper-rs-sys |
**Fedora-specific:** `libclang.so` lives in `/usr/lib64/llvm21/lib64/`, not on the standard search path. Set permanently: **Fedora-specific:** `libclang.so` lives in `/usr/lib64/llvm21/lib64/`, not on the standard search path. The dev launcher defaults `LIBCLANG_PATH` to that path on Linux, but a value you set in your shell wins.
To set it permanently in fish:
```bash ```bash
set -Ux LIBCLANG_PATH /usr/lib64/llvm21/lib64 set -Ux LIBCLANG_PATH /usr/lib64/llvm21/lib64
``` ```
Or prefix every build command: Or prefix one command if you are bypassing the launcher:
```bash ```bash
LIBCLANG_PATH=/usr/lib64/llvm21/lib64 npm run tauri dev LIBCLANG_PATH=/usr/lib64/llvm21/lib64 cargo check -p magnotia
``` ```
### Required (Vulkan GPU build) ### Required (Vulkan GPU build)
@@ -66,15 +68,34 @@ Rust toolchain managed by `rustup`. No extra steps needed beyond what Tauri requ
### CPU build (default) ### CPU build (default)
Canonical dev launch:
```bash ```bash
cd /home/jake/Documents/CORBEL-Projects/magnotia cd /home/jake/Documents/CORBEL-Projects/transcription-app
LIBCLANG_PATH=/usr/lib64/llvm21/lib64 npm run tauri dev npm run dev:tauri
``` ```
Once `set -Ux LIBCLANG_PATH` is in fish config, this becomes: Direct shell equivalent:
```bash ```bash
npm run tauri dev ./run.sh
```
`run.sh` starts Vite, waits for port 1420, then launches Tauri with its `beforeDevCommand` disabled so a second Vite instance does not race the first one.
On Linux, `run.sh` also owns the rendering environment contract expected by `src-tauri/src/lib.rs::warn_if_x11_env_unset_on_wayland`:
- `WEBKIT_DISABLE_DMABUF_RENDERER=1` by default on Linux. Set `WEBKIT_DISABLE_DMABUF_RENDERER=0` explicitly to opt out.
- `GDK_BACKEND=x11` and `WINIT_UNIX_BACKEND=x11` by default only when `XDG_SESSION_TYPE=wayland`.
- Any value already set in your shell wins because the launcher uses shell defaults (`${VAR:-default}`).
If you prefer shell-rc setup, these manual exports are equivalent on a Wayland session:
```bash
export WEBKIT_DISABLE_DMABUF_RENDERER="${WEBKIT_DISABLE_DMABUF_RENDERER:-1}"
export GDK_BACKEND="${GDK_BACKEND:-x11}"
export WINIT_UNIX_BACKEND="${WINIT_UNIX_BACKEND:-x11}"
npm run dev:tauri
``` ```
### Vulkan GPU build ### Vulkan GPU build
@@ -97,16 +118,21 @@ whisper_backend_init_gpu: device 0: CPU (type: 0) ← no GPU
## Startup log reference ## Startup log reference
Normal startup sequence: Normal Rust startup logs now come through `tracing` rather than raw `eprintln!`, so the exact timestamp/format depends on `RUST_LOG` and the subscriber formatter. Typical messages include:
``` ```
[startup] Wayland workaround: GDK_BACKEND=x11 INFO magnotia_startup: DB init complete elapsed_ms=4
[startup] DB init: ~4ms INFO magnotia_startup: preferences load complete elapsed_ms=0
[startup] Preferences load: ~200µs WARN magnotia_startup: Linux WebKitGTK microphone permission requests are auto-granted for audio-only capture; other permission classes remain denied
[startup] Whisper model pre-warmed successfully
``` ```
The Wayland workarounds are injected automatically by `ensure_x11_on_wayland()` in `src-tauri/src/lib.rs` — no manual env-var prefix needed. When launched through `npm run dev:tauri` / `./run.sh`, you should not see `magnotia_startup` warnings containing:
```
Linux rendering workaround env var is not set
```
Those warnings mean the launcher contract was bypassed or broken.
--- ---
@@ -114,7 +140,7 @@ The Wayland workarounds are injected automatically by `ensure_x11_on_wayland()`
| Issue | Cause | Fix | | Issue | Cause | Fix |
|---|---|---| |---|---|---|
| `Unable to find libclang` | Fedora puts clang libs in versioned path | `set -Ux LIBCLANG_PATH /usr/lib64/llvm21/lib64` | | `Unable to find libclang` | Fedora puts clang libs in versioned path | Use `npm run dev:tauri` / `./run.sh`, or set `LIBCLANG_PATH=/usr/lib64/llvm21/lib64` |
| `Could NOT find Vulkan (missing: glslc)` | Shader compiler not installed | `sudo dnf install vulkan-headers vulkan-loader-devel glslc` | | `Could NOT find Vulkan (missing: glslc)` | Shader compiler not installed | `sudo dnf install vulkan-headers vulkan-loader-devel glslc` |
| `there is no reactor running` | `tokio::spawn` called before runtime starts in `setup()` | Use `tauri::async_runtime::spawn` instead | | `there is no reactor running` | `tokio::spawn` called before runtime starts in `setup()` | Use `tauri::async_runtime::spawn` instead |
| `effect_update_depth_exceeded` | Svelte 5 `$state` object reassigned instead of mutated | Use `Object.assign(state, updates)` — never spread-replace module-level state | | `effect_update_depth_exceeded` | Svelte 5 `$state` object reassigned instead of mutated | Use `Object.assign(state, updates)` — never spread-replace module-level state |

View File

@@ -43,7 +43,7 @@ Metal / CUDA counterparts slot in when those backends grow in Magnotia. Today Ma
- New `SettingsState.gpuTuning: { disableCoopmat: boolean, forceFp32: boolean, disableF16: boolean, disableIntegerDotProduct: boolean, enableValidation: boolean }` in [src/lib/types/app.ts](../../src/lib/types/app.ts) - New `SettingsState.gpuTuning: { disableCoopmat: boolean, forceFp32: boolean, disableF16: boolean, disableIntegerDotProduct: boolean, enableValidation: boolean }` in [src/lib/types/app.ts](../../src/lib/types/app.ts)
- All defaults `false` in [src/lib/stores/page.svelte.ts](../../src/lib/stores/page.svelte.ts) - All defaults `false` in [src/lib/stores/page.svelte.ts](../../src/lib/stores/page.svelte.ts)
- Persistence uses the existing `save_preferences` → SQLite `magnotia_preferences` path - Persistence uses the existing `save_preferences` → SQLite `magnotia_preferences` path
- Backend reads preferences at the **very top** of `run()` in [src-tauri/src/lib.rs](../../src-tauri/src/lib.rs) — before `tauri::Builder::default()` spawns threads — and writes via `unsafe { std::env::set_var(...) }`. Matches the existing `ensure_x11_on_wayland` pattern - Backend process env must be established before GPU backend initialisation. **Superseded note (2026-05-12):** runtime `std::env::set_var` mutation and the old `ensure_x11_on_wayland` pattern were removed from `src-tauri/src/lib.rs`; launcher/wrapper processes now own env-var setup. Preserve this startup-order requirement when revisiting GPU tuning, but do not reintroduce in-process env mutation.
- Settings UI shows a sticky "Restart required for changes to take effect" banner when any toggle has drifted from its launch-time value - Settings UI shows a sticky "Restart required for changes to take effect" banner when any toggle has drifted from its launch-time value
- A "Reset to defaults" button zeroes all toggles - A "Reset to defaults" button zeroes all toggles

View File

@@ -0,0 +1,65 @@
---
name: Wyrdnote PKM phase — embeddings, reranking, extraction tooling shortlist
description: Bookmarked candidates to evaluate when the Wyrdnote PKM phase lands. Embedding model server + reranker + entity extractor for semantic search across notes. Not load-bearing for the dictation app; consumed by the PKM workbench layer that scales the product post-public-beta.
type: roadmap
tags: [wyrdnote, pkm, embeddings, reranker, tooling, shortlist, deferred]
created: 2026/05/10
status: deferred
author: Wren on behalf of Jake Sames
related:
- outputs/wyrdnote/2026-05-10-engine-architecture-spec.md (CORBEL-Main)
---
# Wyrdnote PKM phase: tooling shortlist
The Wyrdnote engine architecture spec frames the product as a voice-first dictation app today, scaling to a local-first semantic PKM workbench tomorrow. The PKM scaling needs a serving stack for embeddings, reranking, and entity extraction. This note bookmarks the candidates worth evaluating when that phase lands.
Not in scope for the current roadmap (v0.1 dictation completeness, then the engine-architecture phases A through G in `outputs/wyrdnote/2026-05-10-engine-architecture-spec.md`). Pull this note off the shelf when PKM work begins.
## Why deferred
- PKM phase sits post-public-beta; pulling tooling decisions forward burns evaluation time on infrastructure that earns its keep months later.
- The candidates below evolve fast; a 2026/05/10 bake-off would not generalise to a 2026/Q4 decision.
- Wyrdnote's local-first stance means the chosen stack must run on a workstation with a single consumer GPU. Cloud-default tools are out, but BYO-cloud (user supplies endpoint) is acceptable as a configurable backend.
## Primary candidate (the trigger for this note)
**SIE — Superlinked Inference Engine.** `https://github.com/superlinked/sie`. Apache-2.0. Open-source inference server bundling embeddings, reranking, and entity extraction behind three API functions (`encode`, `score`, `extract`). 85+ models hot-swappable, MTEB-verified in CI. OpenAI-compatible `/v1/embeddings` endpoint. Auto-detects CUDA / Apple Silicon / CPU. Ships a production stack (KEDA autoscaling, Grafana, Terraform for GKE/EKS, Helm chart) but the single-binary path is what matters for Wyrdnote.
Status as of bookmark: 25 commits, 1.7k stars, COMPATIBILITY.md acknowledging known rough edges. Worth re-checking maturity before selection.
Telemetry on by default; disable with `SIE_TELEMETRY_DISABLED=1` or `DO_NOT_TRACK=1`. Wyrdnote's stewardship posture means this would be off in any embedded deployment.
## Adjacent candidates to evaluate alongside SIE
When the PKM phase begins, run a head-to-head on a Wyrdnote-shaped corpus (real user note-lengths, mixed dictation + manual edits, British English vocabulary). Candidates worth pitting against SIE:
- **Ollama + a chosen embedding model** (e.g. `mxbai-embed-large`, `nomic-embed-text-v2`, `bge-m3`). Already in Jake's wider plan for Smart Connections backend. Same single-binary character; no rerank or extract bundled.
- **text-embeddings-inference** (HuggingFace, official). Apache-2.0. Embeddings + rerank only, no extract. Production-mature.
- **Infinity** (`michaelfeil/infinity`). MIT. Embeddings + rerank + classification. Active community, strong perf benchmarks.
- **llama.cpp embeddings mode**. The serving model is rougher but the inference engine is what Wyrdnote already uses for transcript cleanup; consolidating onto one inference runtime has real merit.
- **Voyage AI / Cohere embed + rerank** as cloud BYO-API-key fallback for users who do not want to run a local model. Commercial; user-supplied key only.
- **Sentence-transformers** as the lowest-rung baseline for the bake-off. Pure Python, well understood, runs anywhere. Sets the floor.
## Decision dimensions for the future bake-off
When this note is pulled off the shelf, score each candidate on:
1. **Self-host single-binary footprint.** Disk + RAM + VRAM at idle with the chosen embedding model loaded.
2. **Cold-start latency.** First-encode-after-launch on the user's hardware. Pairs with the warmup coordinator from Phase D of the engine spec.
3. **Throughput on Wyrdnote-shape corpus.** Tokens / second, batch-size 32, 512-token sequences.
4. **Quality on a Wyrdnote eval set.** Build a small (~200 query) eval set from real notes; measure NDCG@10 and MRR.
5. **Reranker availability.** SIE bundles it; Ollama does not. The reranker matters more than the embedding model past a certain corpus size.
6. **Extract / NER capability.** Useful for auto-tagging notes, surfacing "people mentioned this week" views.
7. **OEM-licensability.** AGPL-3.0 + dual-licence OEM exception (≥£2k/yr) means we cannot wrap a GPL-only inference server. Apache-2.0 and MIT are clean. SIE is Apache-2.0; check the others.
8. **Telemetry posture.** Off by default or one-flag-off-able. Stewardship voice rules out anything that phones home with content.
## Re-evaluation trigger
When ANY of:
- Wyrdnote engine architecture spec Phases A through G are merged AND public beta has shipped.
- A user-facing PKM feature lands on the roadmap (semantic search, "related notes" pane, auto-tag, knowledge-graph view).
- The Ollama + Smart Connections route in Jake's wider stack hits a quality ceiling that SIE-class tooling could lift.
Until then: bookmark only.

View File

@@ -0,0 +1,44 @@
---
name: Phase 10a manual dogfood notes
description: Runtime dogfood findings captured during the 2026/05/10 Phase 10a walkthrough.
type: audit
tags: [phase10a, dogfood, manual, runtime, qc]
created: 2026/05/10
status: in-progress
author: Hermes Agent with Jake Sames
---
# Phase 10a — Manual Dogfood Notes
## Environment
- Launched via `./run.sh` from `/home/jake/Documents/CORBEL-Projects/transcription-app`.
- Dev server: Vite on `localhost:1420`.
- Tauri binary: `/home/jake/Documents/CORBEL-Projects/transcription-app/target/debug/magnotia`.
- Launch visible to Jake after restarting under PTY.
## Results
| Check | Result | Notes |
|---|---|---|
| App launches visibly | PASS | Tauri app visible after PTY launch. |
| History row export | PASS | Row-level History export opens the native save dialog and works as intended. |
| File Transcription multi-format export | PASS after fix | Initially failed across all export types. Fixed by routing desktop exports through Tauri native save dialog + `write_text_file_cmd`; Jake retested and confirmed it works as intended. |
| Bulk History export | PASS | Selected 3 history rows, bulk toolbar appeared, “Export selected” wrote one `.md` per row and behaved as expected. |
| Tag one History row | BLOCKED / CRASH | Initially showed “LLM not loaded. Download an AI model in Settings.” After LLM load attempt, dev process exited during llama.cpp load with `GGML_ASSERT(strncmp(n->name, LLAMA_TENSOR_NAME_FGDN_AR "-", prefix_len) == 0) failed` while loading `Qwen3.5-4B-Q4_K_M.gguf`. Treat as release-blocking LLM compatibility/setup issue before LLM-dependent tagging can pass. |
## 2026/05/12 — re-validation pass after engine slop residuals + Area A
Context: residuals B (`184214b`), hotkey tracing finishing (`48c4838`), and Area A storage-error typing (`52565ea` + `a36ae7e`) landed today. Launched magnotia under `RUST_LOG=info,magnotia=debug,magnotia_lib::commands::live=debug` to validate that the new tracing + storage-error semantics held up in a real session.
| Check | Result | Notes |
|---|---|---|
| Native window visible on Dictation | PASS | Confirmed by Jake. |
| Settings opens in native window | PASS | Confirmed by Jake. |
| Record start/stop or clear prerequisite/error | PASS | Confirmed by Jake. |
| No obvious WARN/ERROR toast or crash on smoke | PASS | Confirmed by Jake. |
| Longer-form dogfood (transcribe + LLM cleanup + tagging) | NOT RUN | Hermes session crashed before the longer pass could start. The LLM tagging crash from the 2026/05/10 entry above remains the outstanding release-blocker. |
Observability check — tracing pipeline confirmed working under the new default filter (`magnotia=info,magnotia_lib=info,magnotia_audio=info,magnotia_startup=info,magnotia_hotkey=info,magnotia_ai_formatting=info`). Per-chunk speech-gate skip + dispatch events show under `magnotia_lib::commands::live=debug` as intended.
Storage-error semantics: no opportunity to exercise typed storage failures this session (no FK violation, NotFound, or migration replay path was hit). Re-validation deferred to next dogfood pass when a path that produces a real storage error can be triggered deliberately.

View File

@@ -0,0 +1,91 @@
---
name: Phase 10a stabilization gate after post-handover commits
description: Current-head stabilization checkpoint before resuming Phase 10a QC/dogfood. Covers the two commits ahead of origin/main, verification results, and scope decision for dormant Phase A orchestrator work.
type: audit
tags: [phase10a, stabilization, release, orchestrator, verification]
created: 2026/05/10
status: green-with-manual-qc-remaining
author: Hermes Agent on behalf of Jake Sames
---
# Phase 10a — Stabilization Gate
## Scope
This checkpoint updates the 2026/04/25 handover and static Phase 10a audit for the current local `main`, which is two commits ahead of `origin/main`.
Current local-only commits inspected:
- `c95a5da``docs(roadmap): bookmark SIE + adjacent embeddings/rerank/extract servers for PKM phase`
- `6bc8acc``feat(transcription): Phase A — engine protocol + registry + orchestrator (clean-room)`
## Findings
### PKM roadmap bookmark
Low release risk. The SIE / embeddings / rerank / extraction note is documentation-only and explicitly marked deferred. It should not affect v0.1 runtime or QA scope.
### Phase A transcription orchestrator
Medium architectural scope, low immediate runtime risk.
The commit adds:
- `crates/cloud-providers/src/provider.rs`
- `crates/transcription/src/registry.rs`
- `crates/transcription/src/orchestrator.rs`
- wiring exports/dependencies plus `KNOWN-ISSUES.md` KI-06
The implementation is deliberately dormant for current app paths. Existing `transcribe_file`, live transcription, and meeting flows still call the existing local engine path directly. KI-06 documents the required follow-up: build the registry at app boot, inject `Arc<Orchestrator>` into `AppState`, and migrate command call sites through the orchestrator with mock-provider coverage.
Decision for v0.1 release flow: keep the Phase A commit only if the dormant boundary remains green; do not expand scope into Phase A.1 before Phase 10a unless a direct release blocker requires it.
## Verification run
Command run from repo root:
```bash
cargo fmt --check && \
cargo clippy --workspace --all-targets -- -D warnings && \
cargo test --workspace && \
npm run check && \
npm run build
```
Final result: PASS.
Notes:
- Initial run failed `cargo fmt --check`; `cargo fmt` was applied.
- Current Rust/Clippy toolchain also surfaced a few warning-as-error issues unrelated to the new orchestrator surface:
- `manual_range_contains` in `crates/core/src/tuning.rs`
- `assertions_on_constants` in `crates/core/src/tuning.rs`
- `println_empty_string` in transcription benchmark tests
- `trim_split_whitespace` in transcription benchmark helper
- Those were fixed minimally to restore `-D warnings` cleanliness.
## Files changed by stabilization
Rust formatting touched multiple existing files. Semantic clippy fixes were limited to:
- `crates/core/src/tuning.rs`
- `crates/transcription/tests/jfk_bench.rs`
- `crates/transcription/tests/thread_sweep.rs`
This audit file records why those changes exist.
## Recommendation
Resume the original Phase 10a plan now, with this added gate satisfied:
1. Current HEAD is green.
2. Post-handover commits are inspected.
3. Phase A orchestrator work is classified as dormant / post-v0.1 follow-up, not a reason to expand release scope.
Next work remains the manual/runtime Phase 10a checklist:
- Dogfood walkthrough from `HANDOVER.md`
- Runtime keyboard / focus / contrast / reduced-motion checks
- RB-08 macOS power-assertion verification by Rachmann
- Clean-install test once release artefacts are available
- Manual GitHub Actions build workflow before tagging v0.1.0

View File

@@ -0,0 +1,392 @@
---
name: Area A — storage error inventory and proposed taxonomy
type: survey
tags: [engine, residuals, area-a, storage, errors, survey]
description: Pre-migration survey for engine-slop residuals Area A. Catalogues every MagnotiaError::StorageError construction site in the storage crate, groups by failure mode, proposes a typed magnotia_storage::Error enum with a serializable MagnotiaError::Storage variant on top, and flags ambiguities + migration risks. No code changes yet.
status: draft
---
## TL;DR
There is no `StorageError` enum to extend. The storage crate has zero local error type and produces failures directly as `MagnotiaError::StorageError(String)` via 78 `format!()` sites across `database.rs` (72) and `migrations.rs` (6). The residuals plan's framing of "Storage-layer typed errors (~25 sites)" undercounted — actual count is 3× higher — because the plan came from a residuals pass that did not survey the storage crate.
The Tauri command layer stringifies every error before crossing into the frontend (`Result<T, String>` everywhere via `.map_err(|e| e.to_string())?`), so `MagnotiaError`'s `Serialize` impl is presently unused at the FE/BE boundary. Area E is where that ossifies; Area A's job is to give **backend** code something to branch on — not to fix the frontend.
Proposed: introduce a new `magnotia_storage::Error` enum in the storage crate, replace the `String` tail of `MagnotiaError::StorageError(String)` with a structured `MagnotiaError::Storage { kind, operation, detail }` variant, and wire the conversion through a `From<storage::Error> for MagnotiaError` impl that flattens the structured error into a serde-friendly shape. Backend code can pattern-match on `storage::Error`; the frontend keeps receiving (slightly nicer) strings until Area E.
## Inventory
### Headline numbers
| File | Sites | What they wrap |
|---|---|---|
| `crates/storage/src/database.rs` | 72 | sqlx CRUD operations, pre-condition checks, post-update invariant checks |
| `crates/storage/src/migrations.rs` | 6 | Schema version table creation/query, per-migration tx/exec/record/commit |
| `crates/storage/src/file_storage.rs` | 0 | Uses `From<io::Error>` via `?` — already typed |
| `crates/storage/src/lib.rs` | 0 | Re-exports only |
| **Total** | **78** | |
### Storage crate context
- Uses `magnotia_core::error::{MagnotiaError, Result}` directly — no local error type.
- No `From<sqlx::Error>` impl exists, which is why every sqlx call has an explicit `.map_err(...)`.
- `From<std::io::Error> for MagnotiaError` does exist (in `crates/core/src/error.rs`), so directory-creation paths use `?` cleanly.
### Grouped by failure mode, not by file
#### Bucket 1 — Database connection / init / pragma (3 sites)
| Site | Current message prefix |
|---|---|
| `database.rs:22` | `Database connect failed: {e}` |
| `database.rs:27` | `foreign_keys pragma failed: {e}` |
| `database.rs:50` | `Read-only connect failed: {e}` |
Source: `sqlx::Error`. **Backend interest:** could plausibly want to distinguish read-only fallback failure from primary connect failure for telemetry, but no code currently branches on this.
#### Bucket 2 — Migration framework (6 sites, all in migrations.rs)
| Site | Step | Has version? |
|---|---|---|
| `migrations.rs:557` | `schema_version_table_create` | no |
| `migrations.rs:563` | `schema_version_query` | no |
| `migrations.rs:570` | `tx_begin` | yes |
| `migrations.rs:578` | `apply` | yes |
| `migrations.rs:588` | `record_version` | yes |
| `migrations.rs:592` | `commit` | yes |
Source: `sqlx::Error`. **Backend interest:** the migration retry-poison test (`migrations.rs:1082`) wants to assert these specifically, which already implies the type-system benefit of a dedicated variant.
#### Bucket 3 — Query execution (sqlx wrap) (~64 sites)
Every CRUD operation — INSERT, SELECT, UPDATE, DELETE — wrapped with `.map_err(|e| MagnotiaError::StorageError(format!("<Operation name> failed: {e}")))`.
A non-exhaustive sample (the full 64 share the same shape):
- `database.rs:113` — Insert transcript
- `database.rs:124` — Get transcript
- `database.rs:147` — List transcripts
- `database.rs:157` — Count transcripts
- `database.rs:186/197/208` — Update transcript (three branches in one function)
- `database.rs:256` — update_transcript_meta
- `database.rs:268` — Delete transcript
- `database.rs:292` — FTS search
- `database.rs:330/342/355` — task CRUD
- `database.rs:393/414` — task update / energy
- `database.rs:433/446/463/470/479/494` — subtask CRUD + auto-complete-parent transaction sub-steps
- `database.rs:506-553` — complete_task / uncomplete_task transaction sub-steps
- `database.rs:610/621/633` — analytics queries
- `database.rs:666/683/700/720/745/761` — implementation_rule CRUD
- `database.rs:774/783` — setting get/set
- `database.rs:969-1122` — profile + profile_term CRUD
- `database.rs:1156/1188/1200` — error_log
- `database.rs:1305/1342` — feedback_log
Source: `sqlx::Error`. **Backend interest:** programmatic branching on sqlx error kind (e.g., `RowNotFound`, `Database` with constraint name, connection drops) is meaningful at this layer. The operation label is purely descriptive — not a useful axis for an enum variant.
#### Bucket 4 — Invariant violation (post-update rows-affected check) (3 sites)
| Site | Entity | Condition |
|---|---|---|
| `database.rs:259` | transcript | `update_transcript_meta` UPDATE affected 0 rows |
| `database.rs:396` | task | `update_task` UPDATE affected 0 rows |
| `database.rs:417` | task | `set_task_energy` UPDATE affected 0 rows |
These are **logically distinct from query failure** — the SQL succeeded; the row simply isn't there. Today they're conflated with sqlx errors in `StorageError(String)`. Worth typing because (a) callers might want to surface a different message for "not found" vs "DB error", and (b) it removes ambiguity in logs.
**Subtlety:** there's no guard against the race "row existed at check time, was deleted before update" — these errors fire correctly in that race but the caller has no way to distinguish that from a stale `id` passed in by the user.
#### Bucket 5 — Pre-condition validation (1 site)
| Site | Reason |
|---|---|
| `database.rs:85` | `insert_transcript` called with a `profile_id` that doesn't exist in `profiles` table |
Today: `return Err(MagnotiaError::StorageError(format!("Insert transcript failed: unknown profile id '{}'", params.profile_id)))`.
This is **not a sqlx failure**. It's an integrity check we perform manually before issuing the INSERT. Properly speaking, this is "invalid reference" — distinct from "query failed" and from "row not found".
### Out-of-scope (already typed at MagnotiaError level)
- `std::fs::create_dir_all(parent)?` at `database.rs:11` — propagates through `From<std::io::Error> for MagnotiaError` to `MagnotiaError::Io { kind, message, raw_os_error }`. Already structured.
## Proposed taxonomy
### Where it lives
**Option A (recommended): new `magnotia_storage::Error` enum in the storage crate.**
```rust
// crates/storage/src/error.rs
use std::borrow::Cow;
use thiserror::Error;
#[derive(Debug, Error)]
pub enum Error {
#[error("database open failed during {operation}: {source}")]
DatabaseOpen {
operation: OpenOp,
#[source]
source: sqlx::Error,
},
#[error("migration step {step} (version {version:?}) failed: {source}")]
Migration {
version: Option<i64>,
step: MigrationStep,
#[source]
source: sqlx::Error,
},
#[error("query failed during {operation}: {source}")]
Query {
operation: Cow<'static, str>,
#[source]
source: sqlx::Error,
},
#[error("{entity} not found: '{key}'")]
NotFound {
entity: Entity,
key: String,
},
#[error("invalid {entity} reference: {reason}")]
InvalidReference {
entity: Entity,
reason: Cow<'static, str>,
},
}
#[derive(Debug, Clone, Copy)]
pub enum OpenOp { Connect, ReadOnlyConnect, ForeignKeysPragma }
#[derive(Debug, Clone, Copy)]
pub enum MigrationStep {
SchemaVersionTableCreate,
SchemaVersionQuery,
TxBegin,
Apply,
RecordVersion,
Commit,
}
#[derive(Debug, Clone, Copy)]
pub enum Entity {
Transcript,
Task,
Subtask,
Profile,
ProfileTerm,
ImplementationRule,
Setting,
ErrorLogRow,
}
pub type Result<T> = std::result::Result<T, Error>;
```
**Why option A:**
- The taxonomy lives where the failures originate. Storage owns its own error vocabulary.
- The storage crate can be tested in isolation without `magnotia_core` understanding its internals.
- `From<storage::Error> for MagnotiaError` keeps propagation automatic via `?`.
- Sets up Area E to selectively expose storage-error kinds to the frontend without a second migration of call sites.
**Option B (rejected): structured variant inside `MagnotiaError` directly.**
Mentioned for completeness:
```rust
// In crates/core/src/error.rs
pub enum MagnotiaError {
// ...
Storage {
kind: StorageKind,
operation: String,
detail: String,
},
// ...
}
```
- Less ceremony, one file changes.
- But couples the storage taxonomy into `magnotia_core`, which is a leaky abstraction.
- Harder to extend later when, say, we add a vocabulary crate (per D1 in the architecture spec) that also wants typed errors — every crate ends up dumping its enum into `MagnotiaError`.
### How it crosses the MagnotiaError boundary
`sqlx::Error` does not implement `Serialize`. `MagnotiaError` does — that contract has to be preserved for any future Tauri serialisation path (Area E).
Proposed: flatten on the boundary.
```rust
// In crates/core/src/error.rs
#[derive(Debug, thiserror::Error, Serialize)]
pub enum MagnotiaError {
// ... existing variants unchanged ...
#[error("storage error: {detail}")]
Storage {
kind: StorageKind, // serializable enum: Open | Migration | Query | NotFound | InvalidReference
operation: String, // human-readable, e.g. "insert_transcript" or "migration_apply_v7"
detail: String, // current Display output of storage::Error
},
}
#[derive(Debug, Clone, Copy, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum StorageKind {
DatabaseOpen,
Migration,
Query,
NotFound,
InvalidReference,
}
impl From<magnotia_storage::Error> for MagnotiaError {
fn from(e: magnotia_storage::Error) -> Self {
let kind = match &e {
magnotia_storage::Error::DatabaseOpen { .. } => StorageKind::DatabaseOpen,
magnotia_storage::Error::Migration { .. } => StorageKind::Migration,
magnotia_storage::Error::Query { .. } => StorageKind::Query,
magnotia_storage::Error::NotFound { .. } => StorageKind::NotFound,
magnotia_storage::Error::InvalidReference { .. } => StorageKind::InvalidReference,
};
let operation = e.operation_label().into_owned();
let detail = e.to_string();
MagnotiaError::Storage { kind, operation, detail }
}
}
```
`operation_label()` is a helper on `storage::Error` returning a `Cow<'static, str>` from the inner enum data. Backend code that wants programmatic recovery downcasts (or rather, the storage call signatures return `Result<_, magnotia_storage::Error>` directly — only public Tauri-facing wrappers eat the `From` conversion).
### Function-signature decision
The storage crate has 70+ public functions returning `magnotia_core::Result<...>`. Two options:
1. **Change all to `magnotia_storage::Result<...>`** and let `?` in callers do the From conversion. Pros: backend code that wants typed errors gets them. Cons: ~70 signatures to touch, and any backend code that wanted the structured error has to import `magnotia_storage::Error`.
2. **Keep public signatures as `magnotia_core::Result<...>`, do the conversion at the storage-crate boundary** via an internal `Result<T> = std::result::Result<T, magnotia_storage::Error>` for internal helpers. Public functions then end with `? `(or `.map_err(Into::into))`. Pros: caller signatures unchanged. Cons: callers wanting to pattern-match on storage failure type have nothing to match against (back to square one).
**Recommended: option 1.** It's the more honest signature. ~70 public functions get changed, but it's mechanical: `Result<T>``magnotia_storage::Result<T>` and the body's `MagnotiaError::StorageError(format!(...))` is what we're replacing anyway. Callers (Tauri commands) that currently `.map_err(|e| e.to_string())?` keep working because the storage error From-converts into MagnotiaError, which is what Display'd into the string they're already producing.
This also means **Area E later only has to delete `.map_err(|e| e.to_string())?` and change return types** to receive structured errors — no second pass over the storage call sites.
## Sites → proposed variant
For the migration pass itself. Format: `<file>:<line>` → variant + fields.
### → `Error::DatabaseOpen`
- `database.rs:22``DatabaseOpen { operation: OpenOp::Connect, source }`
- `database.rs:27``DatabaseOpen { operation: OpenOp::ForeignKeysPragma, source }`
- `database.rs:50``DatabaseOpen { operation: OpenOp::ReadOnlyConnect, source }`
### → `Error::Migration`
- `migrations.rs:557``Migration { version: None, step: SchemaVersionTableCreate, source }`
- `migrations.rs:563``Migration { version: None, step: SchemaVersionQuery, source }`
- `migrations.rs:570``Migration { version: Some(v), step: TxBegin, source }`
- `migrations.rs:578``Migration { version: Some(v), step: Apply, source }`
- `migrations.rs:588``Migration { version: Some(v), step: RecordVersion, source }`
- `migrations.rs:592``Migration { version: Some(v), step: Commit, source }`
### → `Error::Query`
The 64 sites in Bucket 3. Operation labels match the current message stem in snake_case:
- `Insert transcript failed``Query { operation: "insert_transcript".into(), source }`
- `Get transcript failed``Query { operation: "get_transcript".into(), source }`
- `List transcripts failed``Query { operation: "list_transcripts".into(), source }`
- ... (mechanical for the rest)
### → `Error::NotFound`
- `database.rs:259``NotFound { entity: Entity::Transcript, key: id.to_string() }`
- `database.rs:396``NotFound { entity: Entity::Task, key: id.to_string() }`
- `database.rs:417``NotFound { entity: Entity::Task, key: id.to_string() }`
### → `Error::InvalidReference`
- `database.rs:85``InvalidReference { entity: Entity::Profile, reason: format!("unknown profile id '{}'", params.profile_id).into() }`
## Ambiguous cases / decisions needed
1. **Migration "Schema version query" — Migration step or Query?** Currently classified as `MigrationStep::SchemaVersionQuery` (within `Error::Migration`) because it's logically part of the migration framework even though it executes a SELECT. Alternative: treat it as a `Query { operation: "schema_version_query" }`. **Recommendation:** keep under Migration. The migration test (`migrations.rs:1082`) asserts on migration-level failures and would benefit from the unified variant. Decision needed before implementation.
2. **Sub-step transactions inside CRUD operations.** `complete_subtask_and_check_parent` (database.rs:453) issues four sqlx calls inside one transaction. Each currently has its own `.map_err(...)` ("Begin transaction failed", "Complete subtask failed", "Get parent_task_id failed", "Count pending subtasks failed", "Auto-complete parent failed", "Commit transaction failed"). Two ways to type:
- One `Query` variant per call, six unique operation labels — preserves current granularity.
- One `Query { operation: "complete_subtask_and_check_parent::<step>" }` style, with step in the label — slightly more readable from the FE but loses some debuggability.
**Recommendation:** preserve current granularity (option a). Cheap and matches existing logs.
3. **`Entity` enum cardinality.** Proposal includes 8 entities. Realistic check: do we need all 8? Going by NotFound + InvalidReference sites, only `Transcript`, `Task`, and `Profile` actually appear. The other 5 (Subtask, ProfileTerm, ImplementationRule, Setting, ErrorLogRow) are speculative. **Recommendation:** start with the 3 we need and add more only when a `NotFound` for that entity actually materialises. Avoid taxonomy theatre.
4. **`Cow<'static, str>` vs `&'static str` vs `String` for `Query::operation`.** With ~64 unique labels, we have a choice:
- All literal `&'static str` — most efficient, but requires every call site to use a string literal (which it would anyway).
- `Cow<'static, str>` — accommodates future dynamic operation names without a heap allocation for the common case.
- `String` — flexible, one alloc per error.
**Recommendation:** `Cow<'static, str>`. The common case is a literal; the escape hatch is free.
5. **Serialize implementation strategy.** Three options for `storage::Error`:
- Derive Serialize and `#[serde(skip)]` the `sqlx::Error` source. Loses source detail in serialised form.
- Custom Serialize impl that stringifies the source.
- Don't derive Serialize — flatten only at the `MagnotiaError` boundary as proposed above.
**Recommendation:** the third. Keeps the storage crate's API surface backend-only; serialisation concerns live in `magnotia_core`. Already reflected in the proposal.
6. **Public re-exports from storage crate.** Need to decide: do we re-export `Error`, `Result`, `OpenOp`, `MigrationStep`, `Entity`, `StorageKind` (or its storage-side equivalent) from `crates/storage/src/lib.rs`? Yes — backend code (Tauri commands, future test code) will want to pattern-match. Suggest a `pub mod error;` + `pub use error::{Error, Result, OpenOp, MigrationStep, Entity};` block.
## Migration risk notes
### Visible behaviour changes
- **Display messages change.** Current "Insert transcript failed: unique constraint violated" becomes "query failed during insert_transcript: unique constraint violated". Functionally equivalent but slightly different. Logs and the Tauri error toast strings will reflect the new format. Worth eyeballing on the Settings → Profiles page, Files page, and Tasks page once landed.
- **Tauri command return shape unchanged.** All `Result<T, String>` signatures stay the same. `.map_err(|e| e.to_string())?` keeps working because `From<storage::Error> for MagnotiaError` produces a Display-able `MagnotiaError`.
### Compile-time gotchas
- **`?` operator semantics depend on which `Result` is in scope.** If function signature returns `magnotia_core::Result<T>` and body uses `magnotia_storage::Result<T>` internally, every helper boundary needs an explicit `Into::into` or a `?` cascade with `From` plumbed. Doable but easy to miss — recommend going function-by-function, not file-by-file.
- **Test ergonomics.** Storage crate tests at `crates/storage/src/migrations.rs:1027+` currently match on `MagnotiaError::StorageError` patterns. They'll need updating to match on `storage::Error::Migration { step: MigrationStep::Apply, .. }` style. Roughly 510 test assertions, all bounded to that file.
### Scope creep guards
- **Do not retitle error messages.** Goal is to preserve the current Display output verbatim where possible, with the operation label carrying the same information as the current message stem. Wording changes belong in a separate pass.
- **Do not change `MagnotiaError::StorageError(String)``MagnotiaError::Storage { ... }` in one PR alongside the storage-crate work.** Two commits:
1. Add `magnotia_storage::Error`, `From<storage::Error> for MagnotiaError`, keep the old `StorageError(String)` variant temporarily as a no-op (compile-only).
2. Remove the old `StorageError(String)` variant; all consumers now go through `Storage { ... }`.
Two commits means we can verify the type-system migration is sound before deleting the safety net.
- **Resist adding `Other(String)`.** The whole point. If a case doesn't fit, surface the ambiguity and decide explicitly.
## Verification plan
Once the migration lands:
```
cargo fmt --all -- --check
cargo check -p magnotia-storage
cargo check -p magnotia-core
cargo check --workspace --all-targets
cargo test -p magnotia-storage
cargo test --workspace --lib
rg 'StorageError\(' crates/ src-tauri/src/ # must be zero
rg 'Other\(String\)' crates/ src-tauri/src/ # must remain zero
rg 'format!\("[A-Z][^"]+failed' crates/storage/src/ # must be zero (catches missed map_errs)
```
The third grep is the structural assertion: there should be no string-formatted "Whatever failed" messages emerging from the storage crate post-migration — every one becomes structured.
## Out of scope (explicit deferrals)
- **Area E** — frontend/backend error boundary cleanup. The Tauri commands stay on `Result<T, String>` for now. Once Area A lands, Area E becomes mechanical: change return types, delete `.map_err(|e| e.to_string())?`, plumb `MagnotiaError::Storage { kind, operation, detail }` into typed frontend handlers.
- **Wording rewrites of user-visible error toasts.** The current strings will read slightly differently post-migration. Not changing copy as part of this; copy review is its own task.
- **`From<sqlx::Error> for storage::Error`.** Tempting because it would eliminate the `.map_err(...)` boilerplate, but it would either lose the operation label (single `From` impl can't know which operation it's wrapping) or require a thin helper macro. Leaving the explicit `.map_err(..., op: "insert_transcript")` per site for now. Macro investigation can be a tiny follow-up.
- **`Other(String)` introduction.** Already absent. Stays absent.

View File

@@ -0,0 +1,241 @@
---
name: Engine slop residuals (post-prognosis pass)
type: plan
tags: [engine, slop-cleanup, plan, deferred]
description: Roadmap for the deferred code-quality items that did not fit in the 2026-05-12 prognosis-fix pass. Each area is scoped for its own focused plan when ready to execute.
---
# Engine slop residuals — post-prognosis pass
> **For agentic workers:** This is a meta-plan. It catalogues independent residual work areas, each of which should get its own detailed TDD plan when scheduled. Do **not** treat the sections below as a single execution checklist.
## Status update — 2026-05-13
Completed:
- B. eprintln → tracing sweep — landed in `184214b`.
- Tracing finishing pass — hotkey `log::` migrated to `tracing::` in `48c4838`.
- A. Storage-layer typed errors — survey in `fdab777`, migration in `52565ea`, legacy `StorageError(String)` removal in `a36ae7e`.
Next recommended:
- Phase 10a dogfood before Area E, to validate the new observability and storage-error semantics in real app use.
Still open:
- D. Property-based DSP testing.
- E. FE/BE error boundary cleanup.
- C. Actor-model refactor.
- F. Packaged-binary Linux launcher contract.
## Context
On 2026-05-12 an external code review rated the codebase 4/10 and laid out a four-bucket de-sloppifying plan. That pass shipped (uncommitted in working tree at time of writing). It hit the *prognosis-level* items only:
- DSP: naive decimation removed, `StreamingResampler`/rubato in place, regression tests at 12 kHz (40 dB) + 9 kHz (26 dB).
- Core errors: `Other(String)` removed; `Io` is structured; `FileNotFound` quotes paths; `ProviderNotRegistered` introduced.
- Core types: `Cow<'static, str>` for `ModelId`/`EngineName`, `NonZeroU32` for `AudioSamples::sample_rate`, `Megabytes::from_gb` takes `u64`.
- Capture cleanup: regex-based `/proc/asound/cards` parser (test covers product names with embedded colons), constants documented, RMS sum idiomatic, Codex-review comments removed.
- Tauri startup: `unsafe std::env::set_var` removed (logs warning instead); function renamed `warn_if_x11_env_unset_on_wayland`; setup collapsed to single `block_on`; JS injection rewritten (`var p = {...}` direct, no `JSON.parse`); WebKitGTK mic auto-grant logs warning at startup.
- Counter: `RECORDING_COUNTER` uses `SeqCst`.
- Tracing: subscriber initialised at top of `run()` with sensible default filter, honors `RUST_LOG`, writes to stderr.
The code-proposal and macro-architecture captures from the same review session went further (10/10 demands and workspace-level critique). Those items are the residuals below.
## Residual areas
Each area is independent. They can be tackled in any order, but the suggested order minimises rework.
### A. Storage-layer typed errors
**Status:** identified, not started.
**Scope:** ~25 `StorageError(String)` sites in `crates/storage/src/{database.rs, migrations.rs}`.
**Why it matters:** The frontend cannot distinguish "schema migration failed" from "FTS search failed" from "transcript not found" — all surface as one stringly-typed variant. This is the same class of bug that motivated removing `Other(String)`.
**Approach:** Split `StorageError` into a `StorageError` enum (sub-error in its own right) with variants like `Connect { path, source }`, `MigrationFailed { version, source }`, `TransactionFailed { phase, source }`, `QueryFailed { kind, source }`, `NotFound { table, id }`. Wrap `sqlx::Error` (or whatever `rusqlite::Error` is in use) as `#[source]`.
**Decisions needed:**
- Is `sqlx::Error`/`rusqlite::Error` serializable across the Tauri boundary? If not, what is preserved (kind + display)?
- Do we keep one flat error type for the crate or nest by domain (`MigrationError`, `TranscriptError`, `TaskError`)?
- Backwards-compat for any existing error-handling code in frontend.
**Files in scope:**
- `crates/core/src/error.rs` — add `StorageError` variant if changing top-level shape
- `crates/storage/src/error.rs` — new file (likely)
- `crates/storage/src/database.rs` — 19 call sites
- `crates/storage/src/migrations.rs` — 6 call sites
**Acceptance:** All `StorageError(format!(...))` calls replaced with typed variants. Frontend can `match` on storage-error kind. `cargo test --workspace` green.
### B. eprintln → tracing sweep (rest of repo)
**Status:** identified, not started.
**Scope:** ~30 `eprintln!` sites in production code paths (excluding tests and tooling binaries — see below).
**Why it matters:** Now that the subscriber is wired, every `eprintln!` is a missed observability signal. The startup/capture paths are clean; the runtime hot paths are not.
**Approach:** Mechanical migration, one file at a time, with appropriate target and structured fields.
**Files in scope (production):**
- `src-tauri/src/commands/live.rs` — 8 sites (highest priority — session lifecycle + inference errors)
- `src-tauri/src/commands/models.rs` — 5 sites (Whisper warmup)
- `src-tauri/src/commands/power.rs` — 4 sites (macOS App Nap)
- `src-tauri/src/commands/transcription.rs` — 1 site
- `src-tauri/src/commands/diagnostics.rs` — 1 site
- `src-tauri/src/commands/tasks.rs` — 1 site
- `crates/hotkey/src/linux.rs` — 2 sites
**Files explicitly out of scope:**
- `crates/mcp/src/main.rs` — separate binary, its own stdio protocol; keep eprintln as-is unless that binary gets its own subscriber.
- `crates/*/tests/*.rs` — test diagnostic output, `--nocapture` semantics depend on stdio.
**Decisions needed:**
- New tracing target names for `commands::live`, `commands::models`, etc. — extend the default filter in `src-tauri/src/lib.rs::init_tracing`.
**Acceptance:** No `eprintln!` in `src-tauri/src/commands/` or `crates/hotkey/src/linux.rs`. Default filter shows live-session events at INFO. `cargo test --workspace` green.
### C. Actor-model refactor for capture + inference
**Status:** identified, not started. **Largest scope.**
**Scope:** Replace `Arc<Mutex<Vec<f32>>>` shared-state pattern in `CaptureWorker` (and analogous patterns in live inference) with isolated actor loops + `mpsc` channels.
**Why it matters:** Reviewer's core architectural critique. Shared mutable state across async boundaries with `std::sync::Mutex` risks executor stalls under contention. Architecturally also makes it impossible to add features like multi-source capture, parallel inference, or recording-while-streaming without lock contention.
**Approach:** Spawn dedicated tokio tasks for the capture worker and the inference worker. Communicate exclusively via bounded `mpsc` channels. Tauri command handlers hold only channel senders.
**Decisions needed (before plan):**
- Bounded vs unbounded mpsc? Recommend bounded for backpressure visibility.
- Channel capacity? (current ad-hoc cap is `AUDIO_CHANNEL_CAPACITY = 32`)
- Backpressure policy when capture outpaces inference: drop oldest, drop newest, await consumer?
- Worker shutdown protocol: drop-channel-and-await, explicit stop message, or cancellation token?
- How are runtime errors (already typed via `CaptureRuntimeError`) propagated through the actor boundary?
- Migration plan: parallel implementation behind a feature flag, or in-place replacement?
**Risk:** This is a substantial refactor. Likely worth a brainstorming session before plan-writing.
**Files in scope:**
- `crates/audio/src/capture.rs` — worker lifecycle rewrite
- `src-tauri/src/commands/audio.rs` — command handler refactor
- `src-tauri/src/commands/live.rs` — inference worker integration
- Likely new files: `crates/audio/src/capture_actor.rs`, `crates/transcription/src/inference_actor.rs`
**Acceptance:** No `Arc<Mutex<T>>` across `.await` points in capture or inference code paths. Capture and inference are independent tokio tasks. End-to-end recording flow works in dev mode. `cargo test --workspace` green. Manual dogfood pass.
### D. Property-based DSP testing
**Status:** identified, not started.
**Scope:** Add `proptest` (or similar) generators for audio inputs and assert invariants of the resampling and VAD pipelines.
**Why it matters:** The current DSP tests are point checks at 12 kHz and 9 kHz. The reviewer's 10/10 demand is property-based testing that covers the input space (any frequency, any chunk size, any noise floor) and asserts invariants (output is bounded, energy is bounded, duration is approximately preserved).
**Approach:** Add `proptest` dev-dependency. Write generators for `(sample_rate, signal_frequency, amplitude, chunk_size, total_secs)` tuples. Assert:
- Output samples are finite (no NaN/Inf).
- Output peak ≤ input peak + small tolerance (no amplification).
- Output duration within ±5% of expected.
- Output energy at frequencies above Nyquist of target rate is at least 30 dB below input energy at those frequencies.
**Decisions needed:**
- `proptest` vs `quickcheck`? Recommend `proptest` (more flexible shrinking).
- Run as part of standard `cargo test` or as a separate `cargo test --features slow-tests`?
**Files in scope:**
- `crates/audio/Cargo.toml` — add `proptest` to `[dev-dependencies]`
- `crates/audio/src/streaming_resample.rs` — extend `#[cfg(test)]` module
- Likely also `crates/transcription/src/streaming/rms_vad.rs` — VAD has equally testable invariants
**Acceptance:** `cargo test -p magnotia-audio` includes property tests. Property tests have run with at least 1000 cases per invariant.
### E. Frontend/backend error boundary cleanup
**Status:** partially complete (orchestrator's `ProviderNotRegistered`).
**Scope:** Audit every Tauri command's return type and ensure errors propagate with full type information across the IPC boundary. Currently many commands return `Result<T, String>` instead of `Result<T, MagnotiaError>`.
**Why it matters:** Reviewer's frontend-backend boundary critique. The frontend can't `switch` on error kind if the backend serialises errors as strings.
**Approach:** Inventory all `#[tauri::command]` functions, identify those returning `Result<T, String>`, convert to `Result<T, MagnotiaError>` (or a command-specific error enum that serializes cleanly). Update frontend handlers to use the typed shape.
**Decisions needed:**
- One `MagnotiaError` for all commands, or per-command enums?
- How do we surface the new shape to the Svelte side without manually maintaining TypeScript types? (specta? hand-rolled?)
**Files in scope:**
- All files in `src-tauri/src/commands/`
- All call sites in `src/lib/` (Svelte)
**Acceptance:** No `Result<T, String>` in `src-tauri/src/commands/`. Frontend has typed error access. `cargo test --workspace` green; frontend builds.
### F. Packaged-binary launcher contract (Linux)
**Status:** identified, not started. Direct follow-on from the 2026-05-12 dev-launcher fix; same contract, distribution-time scope.
**Scope:** Set the rendering env-vars at app launch time for distributed Linux builds.
**Env-var contract per distribution path:**
- `WEBKIT_DISABLE_DMABUF_RENDERER=1` — always set on Linux (iGPU idle-cost workaround, applies on X11 and Wayland alike).
- `GDK_BACKEND=x11`, `WINIT_UNIX_BACKEND=x11` — set only when `XDG_SESSION_TYPE=wayland`. Static `.desktop` `Exec=env` cannot do this conditional; either ship a wrapper or accept always-on X11 backend.
**Preferred — wrapper script:**
A wrapper preserves user-set values via shell defaults:
```sh
#!/usr/bin/env bash
export WEBKIT_DISABLE_DMABUF_RENDERER="${WEBKIT_DISABLE_DMABUF_RENDERER:-1}"
if [ "${XDG_SESSION_TYPE:-}" = "wayland" ]; then
export GDK_BACKEND="${GDK_BACKEND:-x11}"
export WINIT_UNIX_BACKEND="${WINIT_UNIX_BACKEND:-x11}"
fi
exec /usr/lib/magnotia/magnotia-bin "$@"
```
Per-format wrapper locations:
- `.deb` / `.rpm`: ship the wrapper as `/usr/bin/magnotia`; binary lives elsewhere.
- AppImage: `AppRun` is the wrapper.
- Flatpak: wrapper under `/app/bin/`; the manifest's `command` points at it.
**Fallback — static `.desktop` `Exec`:**
```desktop
Exec=env WEBKIT_DISABLE_DMABUF_RENDERER=1 GDK_BACKEND=x11 WINIT_UNIX_BACKEND=x11 magnotia %U
```
User-set values do not win through this path (the `env` invocation hardcodes them in the child env). Document the terminal-launch override path in user-facing docs.
**Decisions needed:**
- Wrapper vs hardcoded-`Exec` per format. Default to wrapper.
- For Flatpak: finish-args env defaults vs wrapper — both work; pick consistency.
**Acceptance:**
- Each Linux distribution path sets the contract on launch.
- Where the packaging format uses a wrapper, user-set values still win.
- Where the `.desktop` `Exec` hardcodes env directly, the terminal-launch override path is documented in user docs.
- `lib.rs::warn_if_x11_env_unset_on_wayland` continues to surface missed contracts during dev/diagnostics.
## Suggested sequencing
1. **B (eprintln sweep)** first — mechanical, low-risk, immediate observability win.
2. **A (storage typed errors)** next — well-scoped, no architectural change, unblocks E.
3. **D (property-based DSP)** in parallel with A — independent surface, can be done by anyone any time.
4. **E (FE/BE error boundary)** after A — depends on storage error shape.
5. **C (actor model)** last — biggest surface, deserves its own brainstorm + plan + likely its own branch.
6. **F (packaged launcher contract)** when packaging resumes — independent of AE but required before distributed Linux builds are treated as covered.
## Cross-cutting deferrals (Phase 10a-blocking)
Two things are explicitly **not** in these residuals because they belong to higher-level workstreams:
- **Lumotia rebrand cascade** (Wyrdnote → Lumotia in code, paths, GitHub repo, bundle ID). Hold until AB done so rename diff doesn't hide regressions.
- **Phase 10a QC dogfood** — `docs/superpowers/audits/2026-05-10-phase10a-dogfood-notes.md` is queued. Best done after B at minimum so live-session logs are visible.
## What this plan does not do
- No fix recipes for bugs not yet found. Each area's plan should start by reading the current state and writing failing tests against the current behaviour.
- No timeline commitments. Sequencing is suggested, not mandatory.
- No architectural decisions deferred to "Phase B" earlier are pre-committed here. Each area writes its own brainstorm + plan when scheduled.

View File

@@ -6,6 +6,7 @@
"scripts": { "scripts": {
"dev": "npm run dev:frontend", "dev": "npm run dev:frontend",
"dev:frontend": "svelte-kit sync && vite dev", "dev:frontend": "svelte-kit sync && vite dev",
"dev:tauri": "./run.sh",
"build": "vite build", "build": "vite build",
"preview": "vite preview", "preview": "vite preview",
"check": "svelte-kit sync && svelte-check --tsconfig ./jsconfig.json", "check": "svelte-kit sync && svelte-check --tsconfig ./jsconfig.json",

39
run.sh
View File

@@ -1,22 +1,51 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# Magnotia dev launcher. Starts Vite first, waits for it to bind port 1420, # Magnotia dev launcher. Starts Vite first, waits for it to bind port 1420,
# then runs Tauri without triggering a second Vite instance. # then runs Tauri without triggering a second Vite instance.
# Sets the Linux rendering env vars that warn_if_x11_env_unset_on_wayland
# (src-tauri/src/lib.rs) expects the launcher to own.
# For performance testing use: npm run tauri build # For performance testing use: npm run tauri build
set -euo pipefail set -euo pipefail
cd "$(dirname "$0")" cd "$(dirname "$0")"
export LIBCLANG_PATH=/usr/lib64/llvm21/lib64 case "$(uname -s)" in
Linux)
# Bindgen libclang path. Fedora 41/LLVM 21 default; user-set wins.
export LIBCLANG_PATH="${LIBCLANG_PATH:-/usr/lib64/llvm21/lib64}"
# iGPU idle-cost workaround. Set to 0 explicitly to opt out.
export WEBKIT_DISABLE_DMABUF_RENDERER="${WEBKIT_DISABLE_DMABUF_RENDERER:-1}"
if [ "${XDG_SESSION_TYPE:-}" = "wayland" ]; then
export GDK_BACKEND="${GDK_BACKEND:-x11}"
export WINIT_UNIX_BACKEND="${WINIT_UNIX_BACKEND:-x11}"
fi
;;
esac
printf 'Starting Vite dev server...\n' >&2 printf 'Starting Vite dev server...\n' >&2
npm run dev:frontend & npm run dev:frontend &
VITE_PID=$! VITE_PID=$!
until curl -sf http://localhost:1420 > /dev/null 2>&1; do sleep 0.5; done
printf 'Vite ready. Launching Tauri...\n' >&2
cleanup() { cleanup() {
kill "$VITE_PID" 2>/dev/null || true kill "$VITE_PID" 2>/dev/null || true
} }
trap cleanup EXIT INT TERM trap cleanup EXIT INT TERM
npx tauri dev --config '{"build":{"beforeDevCommand":""}}' # Wait up to 60s for Vite, bailing if it exits early.
for _ in $(seq 1 120); do
if curl -sf http://localhost:1420 > /dev/null 2>&1; then
break
fi
if ! kill -0 "$VITE_PID" 2>/dev/null; then
printf 'Vite dev server exited before becoming ready.\n' >&2
wait "$VITE_PID"
exit 1
fi
sleep 0.5
done
if ! curl -sf http://localhost:1420 > /dev/null 2>&1; then
printf 'Timed out waiting for Vite on http://localhost:1420\n' >&2
exit 1
fi
printf 'Vite ready. Launching Tauri...\n' >&2
npx tauri dev --config '{"build":{"beforeDevCommand":""}}' "$@"

View File

@@ -41,7 +41,7 @@ magnotia-llm = { path = "../crates/llm" }
# autostart plugins are desktop-only — gated below under # autostart plugins are desktop-only — gated below under
# cfg(not(target_os = "android")). The dialog, opener, and notification # cfg(not(target_os = "android")). The dialog, opener, and notification
# plugins all support Android natively so live in the unconditional list. # plugins all support Android natively so live in the unconditional list.
tauri = { version = "2" } tauri = { version = "2", features = [] }
tauri-plugin-opener = "2" tauri-plugin-opener = "2"
tauri-plugin-dialog = "2" tauri-plugin-dialog = "2"
# Phase 6 nudges: OS-native notifications via the frontend-owned nudge # Phase 6 nudges: OS-native notifications via the frontend-owned nudge
@@ -52,6 +52,8 @@ tauri-plugin-notification = "2"
# Serialisation # Serialisation
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
serde_json = "1" serde_json = "1"
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
# Async runtime (spawn_blocking for inference) # Async runtime (spawn_blocking for inference)
tokio = { version = "1", features = ["rt", "sync"] } tokio = { version = "1", features = ["rt", "sync"] }

View File

@@ -60,7 +60,7 @@ fn recording_filename() -> String {
.unwrap_or_default(); .unwrap_or_default();
let secs = duration.as_secs(); let secs = duration.as_secs();
let nanos = duration.subsec_nanos(); let nanos = duration.subsec_nanos();
let counter = RECORDING_COUNTER.fetch_add(1, Ordering::Relaxed); let counter = RECORDING_COUNTER.fetch_add(1, Ordering::SeqCst);
format!("magnotia-{secs}-{nanos:09}-{counter:04}.wav") format!("magnotia-{secs}-{nanos:09}-{counter:04}.wav")
} }

View File

@@ -33,7 +33,7 @@ const MAGNOTIA_VERSION: &str = env!("CARGO_PKG_VERSION");
/// Called once at startup before tauri::Builder. /// Called once at startup before tauri::Builder.
pub fn install_panic_hook() { pub fn install_panic_hook() {
if let Err(e) = fs::create_dir_all(crashes_dir()) { if let Err(e) = fs::create_dir_all(crashes_dir()) {
eprintln!("[diagnostics] could not create crashes dir: {e}"); tracing::warn!(error = %e, "could not create crashes dir; panic dumps disabled");
return; return;
} }

View File

@@ -59,4 +59,3 @@ pub async fn record_feedback(
.await .await
.map_err(|e| e.to_string()) .map_err(|e| e.to_string())
} }

View File

@@ -529,9 +529,12 @@ pub async fn start_live_transcription_session(
.model_id .model_id
.clone() .clone()
.unwrap_or_else(|| default_model_id_for_engine(&config.engine).to_string()); .unwrap_or_else(|| default_model_id_for_engine(&config.engine).to_string());
eprintln!( tracing::info!(
"[live] starting session: engine={}, model={}, language={:?}, save_audio={}", engine = %config.engine,
config.engine, model_id, config.language, config.save_audio model = %model_id,
language = ?config.language,
save_audio = config.save_audio,
"starting live session"
); );
// None: live-transcription model loads don't enforce sequential-GPU // None: live-transcription model loads don't enforce sequential-GPU
// mode. The Settings-level load flow owns that guard. // mode. The Settings-level load flow owns that guard.
@@ -788,14 +791,16 @@ fn maybe_dispatch_chunk(
"insufficient_speech" => "insufficient speech energy", "insufficient_speech" => "insufficient speech energy",
other => other, other => other,
}; };
eprintln!( tracing::debug!(
"[live] session {session_id}: skipped {skipped_ms}ms chunk as {gate_reason} \ session_id,
(peak_rms={:.6}, peak={:.6}, speech_windows={}/{}, max_consecutive={})", skipped_ms,
speech_gate.peak_rms, gate_reason,
speech_gate.peak_amplitude, peak_rms = speech_gate.peak_rms,
speech_gate.speech_window_count, peak_amplitude = speech_gate.peak_amplitude,
speech_gate.window_count, speech_window_count = speech_gate.speech_window_count,
speech_gate.max_consecutive_speech_windows, window_count = speech_gate.window_count,
max_consecutive_speech_windows = speech_gate.max_consecutive_speech_windows,
"skipped chunk on speech gate"
); );
let _ = status_channel.send(LiveStatusMessage::Warning { let _ = status_channel.send(LiveStatusMessage::Warning {
session_id, session_id,
@@ -818,10 +823,12 @@ fn maybe_dispatch_chunk(
let chunk_start_sample = *buffer_start_sample; let chunk_start_sample = *buffer_start_sample;
let duration_secs = target_len as f64 / WHISPER_SAMPLE_RATE as f64; let duration_secs = target_len as f64 / WHISPER_SAMPLE_RATE as f64;
let chunk_samples = capture_buffer[..target_len].to_vec(); let chunk_samples = capture_buffer[..target_len].to_vec();
eprintln!( tracing::debug!(
"[live] session {session_id}: dispatching chunk {} ({duration_secs:.2}s, {} samples)", session_id,
current_chunk_id, chunk_id = current_chunk_id,
chunk_samples.len() duration_secs,
samples = chunk_samples.len(),
"dispatching chunk"
); );
let advance_by = if stopping || target_len < CHUNK_SAMPLES { let advance_by = if stopping || target_len < CHUNK_SAMPLES {
target_len target_len
@@ -925,28 +932,21 @@ fn poll_inference(
&result_message, &result_message,
); );
remember_recent_segments(recent_segments, &delivered_segments, chunk_start_secs); remember_recent_segments(recent_segments, &delivered_segments, chunk_start_secs);
eprintln!( tracing::info!(
"[live] session {session_id}: {} chunk {} with {} segments in {}ms{}", session_id,
if delivered { chunk_id = task.chunk_id,
"delivered" segments = segment_count,
} else { inference_ms = timed.inference_ms,
"processed without listener for" delivered,
}, skipped_duplicates,
task.chunk_id, "processed live chunk"
segment_count,
timed.inference_ms,
if skipped_duplicates > 0 {
format!(" (skipped {skipped_duplicates} duplicate boundary segment(s))")
} else {
String::new()
}
); );
*inflight = None; *inflight = None;
Ok(Some(true)) Ok(Some(true))
} }
Ok(Err(err)) => { Ok(Err(err)) => {
eprintln!("[live] session {session_id}: inference error: {err}"); tracing::error!(session_id, error = %err, "inference error");
*inflight = None; *inflight = None;
let _ = status_channel.send(LiveStatusMessage::Error { let _ = status_channel.send(LiveStatusMessage::Error {
session_id, session_id,
@@ -958,7 +958,7 @@ fn poll_inference(
Err(std::sync::mpsc::TryRecvError::Disconnected) => { Err(std::sync::mpsc::TryRecvError::Disconnected) => {
*inflight = None; *inflight = None;
let message = "Inference worker disconnected unexpectedly".to_string(); let message = "Inference worker disconnected unexpectedly".to_string();
eprintln!("[live] session {session_id}: {message}"); tracing::error!(session_id, "{message}");
let _ = status_channel.send(LiveStatusMessage::Error { let _ = status_channel.send(LiveStatusMessage::Error {
session_id, session_id,
message: message.clone(), message: message.clone(),
@@ -983,9 +983,11 @@ fn emit_live_result(
Ok(()) => true, Ok(()) => true,
Err(err) => { Err(err) => {
*result_listener_lost = true; *result_listener_lost = true;
eprintln!( tracing::warn!(
"[live] session {}: result listener unavailable on chunk {}: {}; continuing without live updates", session_id = result_message.session_id,
result_message.session_id, result_message.chunk_id, err chunk_id = result_message.chunk_id,
error = %err,
"result listener unavailable; continuing without live updates"
); );
// If the warning send also fails, the entire frontend channel // If the warning send also fails, the entire frontend channel
// pair is dead — almost certainly the user closed the app // pair is dead — almost certainly the user closed the app
@@ -999,10 +1001,9 @@ fn emit_live_result(
message: "Live preview disconnected; transcription will continue in the background until you stop the session.".into(), message: "Live preview disconnected; transcription will continue in the background until you stop the session.".into(),
}); });
if warn_send.is_err() { if warn_send.is_err() {
eprintln!( tracing::error!(
"[live] session {}: status channel also unavailable; \ session_id = result_message.session_id,
self-asserting stop_flag so the worker exits", "status channel also unavailable; self-asserting stop_flag so the worker exits"
result_message.session_id
); );
stop_flag.store(true, Ordering::Relaxed); stop_flag.store(true, Ordering::Relaxed);
} }
@@ -1677,7 +1678,10 @@ mod tests {
&message, &message,
); );
assert!(!delivered, "send must report not delivered when result_channel errors"); assert!(
!delivered,
"send must report not delivered when result_channel errors"
);
assert!(result_listener_lost, "result_listener_lost must be set"); assert!(result_listener_lost, "result_listener_lost must be set");
assert!( assert!(
stop_flag.load(Ordering::Relaxed), stop_flag.load(Ordering::Relaxed),

View File

@@ -25,14 +25,14 @@ fn whisper_model_id(size: &str) -> ModelId {
"distil-large" | "distil-large-v3" | "distillarge" => { "distil-large" | "distil-large-v3" | "distillarge" => {
ModelId::new("whisper-distil-large-v3") ModelId::new("whisper-distil-large-v3")
} }
other => ModelId::new(other), other => ModelId::new(other.to_string()),
} }
} }
fn parakeet_model_id(name: &str) -> ModelId { fn parakeet_model_id(name: &str) -> ModelId {
match name { match name {
"ctc-int8" => ModelId::new("parakeet-ctc-0.6b-int8"), "ctc-int8" => ModelId::new("parakeet-ctc-0.6b-int8"),
other => ModelId::new(other), other => ModelId::new(other.to_string()),
} }
} }
@@ -121,7 +121,7 @@ pub async fn ensure_model_loaded(
model_id: &str, model_id: &str,
concurrent: Option<bool>, concurrent: Option<bool>,
) -> Result<(), String> { ) -> Result<(), String> {
let model_id = ModelId::new(model_id); let model_id = ModelId::new(model_id.to_string());
let entry = model_registry::find_model(&model_id) let entry = model_registry::find_model(&model_id)
.ok_or_else(|| format!("Unknown model: {model_id}"))?; .ok_or_else(|| format!("Unknown model: {model_id}"))?;
@@ -207,17 +207,35 @@ pub fn prewarm_default_model(whisper_engine: Arc<LocalEngine>) {
let silence = AudioSamples::mono_16khz(vec![0.0_f32; WHISPER_SAMPLE_RATE as usize]); let silence = AudioSamples::mono_16khz(vec![0.0_f32; WHISPER_SAMPLE_RATE as usize]);
let options = TranscriptionOptions::default(); let options = TranscriptionOptions::default();
match whisper_engine.transcribe_sync(&silence, &options) { match whisper_engine.transcribe_sync(&silence, &options) {
Ok(_) => eprintln!("[startup] Whisper warm-up inference complete"), Ok(_) => tracing::info!(
Err(e) => eprintln!("[startup] Whisper warm-up inference failed: {e}"), target: "magnotia_startup",
"Whisper warm-up inference complete"
),
Err(e) => tracing::warn!(
target: "magnotia_startup",
error = %e,
"Whisper warm-up inference failed"
),
} }
}) })
}) })
.await; .await;
match result { match result {
Ok(Ok(())) => eprintln!("[startup] Whisper model pre-warmed successfully"), Ok(Ok(())) => tracing::info!(
Ok(Err(e)) => eprintln!("[startup] Whisper pre-warm failed: {e}"), target: "magnotia_startup",
Err(e) => eprintln!("[startup] Whisper pre-warm task panicked: {e}"), "Whisper model pre-warmed successfully"
),
Ok(Err(e)) => tracing::warn!(
target: "magnotia_startup",
error = %e,
"Whisper pre-warm failed"
),
Err(e) => tracing::error!(
target: "magnotia_startup",
error = %e,
"Whisper pre-warm task panicked"
),
} }
}); });
} }
@@ -319,7 +337,6 @@ pub struct LanguageSupportInfo {
pub language_count: u16, pub language_count: u16,
} }
/// Compile-time target signalling used by `compose_accelerators`. /// Compile-time target signalling used by `compose_accelerators`.
/// Split out so the pure-function behaviour is testable without `cfg!` /// Split out so the pure-function behaviour is testable without `cfg!`
/// appearing in the test matrix. /// appearing in the test matrix.

View File

@@ -91,9 +91,9 @@ impl PowerAssertion {
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
if acquired { if acquired {
eprintln!("[power] began macOS App Nap guard #{id} for reason '{reason}'"); tracing::info!(assertion_id = id, reason, "began macOS App Nap guard");
} else { } else {
eprintln!("[power] macOS App Nap guard could not begin activity for reason '{reason}'"); tracing::warn!(reason, "macOS App Nap guard could not begin activity");
} }
#[cfg(not(target_os = "macos"))] #[cfg(not(target_os = "macos"))]
@@ -135,9 +135,10 @@ impl Drop for PowerAssertion {
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
if let Some(handle) = self.activity.take() { if let Some(handle) = self.activity.take() {
objc_bridge::end_activity(handle); objc_bridge::end_activity(handle);
eprintln!( tracing::info!(
"[power] ended macOS App Nap guard #{} for reason '{}'", assertion_id = self.id,
self.id, self.reason reason = self.reason,
"ended macOS App Nap guard"
); );
} }
assertion_registry().lock().unwrap().remove(&self.id); assertion_registry().lock().unwrap().remove(&self.id);

View File

@@ -227,9 +227,11 @@ fn to_llm_examples(rows: Vec<FeedbackRow>) -> Vec<LlmFeedbackExample> {
let ctx: serde_json::Value = match serde_json::from_str(raw) { let ctx: serde_json::Value = match serde_json::from_str(raw) {
Ok(v) => v, Ok(v) => v,
Err(e) => { Err(e) => {
eprintln!( tracing::warn!(
"[feedback] skipping row id={} with malformed context_json: {e}", target: "magnotia_lib::feedback",
r.id row_id = r.id,
error = %e,
"skipping feedback row with malformed context_json"
); );
return None; return None;
} }

View File

@@ -88,8 +88,11 @@ fn transcribe_samples_sync(
.saturating_sub(strategy.overlap_samples) .saturating_sub(strategy.overlap_samples)
.max(1); .max(1);
let chunk_count = ((samples.len().saturating_sub(1)) / stride) + 1; let chunk_count = ((samples.len().saturating_sub(1)) / stride) + 1;
eprintln!( tracing::info!(
"[transcription] chunking {total_duration_secs:.2}s of {engine_name} audio into {chunk_count} chunk(s)" engine = engine_name,
duration_secs = total_duration_secs,
chunk_count,
"chunking audio for transcription"
); );
let mut all_segments = Vec::new(); let mut all_segments = Vec::new();
@@ -249,4 +252,3 @@ pub async fn transcribe_file(
"raw_text": raw_text, "raw_text": raw_text,
})) }))
} }

View File

@@ -7,4 +7,3 @@ pub async fn check_for_update(window: tauri::WebviewWindow) -> Result<Option<Str
ensure_main_window(&window)?; ensure_main_window(&window)?;
Ok(None) Ok(None)
} }

View File

@@ -5,16 +5,16 @@ mod commands;
mod tray; mod tray;
use std::sync::Arc; use std::sync::Arc;
use std::sync::Once;
use std::time::Instant; use std::time::Instant;
use sqlx::SqlitePool; use sqlx::SqlitePool;
use tauri::Manager; use tauri::Manager;
use tracing_subscriber::EnvFilter;
use magnotia_core::types::EngineName; use magnotia_core::types::EngineName;
use magnotia_llm::LlmEngine; use magnotia_llm::LlmEngine;
use magnotia_storage::{ use magnotia_storage::{database_path, get_setting, init as init_db, prune_error_log, set_setting};
database_path, get_setting, init as init_db, prune_error_log, set_setting,
};
/// How long to retain `error_log` rows. Pruned once on startup. /// How long to retain `error_log` rows. Pruned once on startup.
/// 90 days is long enough to triage "this happened a few weeks ago" /// 90 days is long enough to triage "this happened a few weeks ago"
@@ -22,6 +22,8 @@ use magnotia_storage::{
const ERROR_LOG_RETENTION_DAYS: i64 = 90; const ERROR_LOG_RETENTION_DAYS: i64 = 90;
use magnotia_transcription::LocalEngine; use magnotia_transcription::LocalEngine;
static TRACING_INIT: Once = Once::new();
/// Shared app state holding the transcription engines and database pool. /// Shared app state holding the transcription engines and database pool.
pub struct AppState { pub struct AppState {
pub whisper_engine: Arc<LocalEngine>, pub whisper_engine: Arc<LocalEngine>,
@@ -40,12 +42,15 @@ fn build_preferences_script(prefs_json: Option<String>) -> String {
if json.is_empty() { if json.is_empty() {
return String::new(); return String::new();
} }
// Serialise the JSON string as a JS string literal for safe embedding let js_value: serde_json::Value = match serde_json::from_str(&json) {
let js_str = serde_json::to_string(&json).unwrap_or_else(|_| "\"\"".to_string()); Ok(value) => value,
Err(_) => return String::new(),
};
let js_obj = serde_json::to_string(&js_value).unwrap_or_else(|_| "{}".to_string());
format!( format!(
r#"(function() {{ r#"(function() {{
try {{ try {{
var p = JSON.parse({js_str}); var p = {js_obj};
var el = document.documentElement; var el = document.documentElement;
if (p.theme === 'system') {{ if (p.theme === 'system') {{
el.dataset.theme = window.matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark'; el.dataset.theme = window.matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark';
@@ -69,6 +74,23 @@ fn build_preferences_script(prefs_json: Option<String>) -> String {
) )
} }
#[cfg(test)]
mod tests {
use super::build_preferences_script;
#[test]
fn preferences_script_injects_object_without_redundant_json_parse() {
let script = build_preferences_script(Some(r#"{"theme":"dark"}"#.to_string()));
assert!(script.contains("var p = {\"theme\":\"dark\"};"));
assert!(!script.contains("JSON.parse"));
}
#[test]
fn preferences_script_rejects_malformed_json() {
assert!(build_preferences_script(Some("not json".to_string())).is_empty());
}
}
/// Save preferences JSON to the SQLite settings table. /// Save preferences JSON to the SQLite settings table.
#[tauri::command] #[tauri::command]
async fn save_preferences( async fn save_preferences(
@@ -81,31 +103,32 @@ async fn save_preferences(
} }
/// On Linux Wayland sessions (KDE, GNOME) WebKitGTK + DMABUF rendering hits /// On Linux Wayland sessions (KDE, GNOME) WebKitGTK + DMABUF rendering hits
/// known crashes that the HANDOVER documents working around with a manual /// known crashes that the dev launcher works around by setting rendering
/// env-var prefix: /// env vars before WebKitGTK/WINIT initialise. In development, launch via:
/// ///
/// ```sh /// ```sh
/// env GDK_BACKEND=x11 WINIT_UNIX_BACKEND=x11 \ /// npm run dev:tauri
/// WEBKIT_DISABLE_DMABUF_RENDERER=1 npm run tauri dev
/// ``` /// ```
/// ///
/// Detect the Wayland session at startup and apply the env vars before /// Detect the Wayland session at startup and warn when the process was not
/// anything else loads, so users do not need to remember the prefix and /// launched with the safe Linux rendering env vars. Rust 2024 marks runtime
/// the dev launcher / packaged app both work out of the box. /// environment mutation unsafe in multi-threaded programs, so the app must be
/// launched with these values by `run.sh`, the desktop file, or a package
/// wrapper rather than patching them here.
/// ///
/// Inspired by Open-Whispr's similar Chromium self-relaunch (with /// Inspired by Open-Whispr's similar Chromium self-relaunch (with
/// --ozone-platform=x11). Day 6 of the upgrade plan. /// --ozone-platform=x11). Day 6 of the upgrade plan.
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
fn ensure_x11_on_wayland() { fn warn_if_x11_env_unset_on_wayland() {
let set_if_unset = |key: &str, value: &str, why: &str| { let warn_if_unset = |key: &str, value: &str, why: &str| {
if std::env::var_os(key).is_none() { if std::env::var_os(key).is_none() {
// SAFETY: setting env vars before any threads spawn (we are tracing::warn!(
// pre-Tauri-Builder here). This block is the only place these target: "magnotia_startup",
// are written. key,
unsafe { expected = value,
std::env::set_var(key, value); why,
} "Linux rendering workaround env var is not set; configure the launcher instead of mutating process env at runtime"
eprintln!("[startup] Linux workaround: {key}={value} ({why})"); );
} }
}; };
@@ -115,9 +138,13 @@ fn ensure_x11_on_wayland() {
// renderer for no visible quality difference. Empirically (env-var // renderer for no visible quality difference. Empirically (env-var
// matrix on Ryzen 5 4650U / Vega 6, May 2026) toggling this dropped // matrix on Ryzen 5 4650U / Vega 6, May 2026) toggling this dropped
// magnotia idle CPU 12.30% → 2.80% of one core and idle GPU 17% → 10%. // magnotia idle CPU 12.30% → 2.80% of one core and idle GPU 17% → 10%.
// Apples to both X11 and Wayland sessions; users can opt back in by // Applies to both X11 and Wayland sessions; users can opt back in by
// setting WEBKIT_DISABLE_DMABUF_RENDERER=0 explicitly. // setting WEBKIT_DISABLE_DMABUF_RENDERER=0 explicitly.
set_if_unset("WEBKIT_DISABLE_DMABUF_RENDERER", "1", "iGPU idle-cost workaround"); warn_if_unset(
"WEBKIT_DISABLE_DMABUF_RENDERER",
"1",
"iGPU idle-cost workaround",
);
// If the user is on a Wayland session, also force WebKitGTK + GDK // If the user is on a Wayland session, also force WebKitGTK + GDK
// onto X11 via XWayland. Idempotent: if a value is already set, keep // onto X11 via XWayland. Idempotent: if a value is already set, keep
@@ -126,15 +153,32 @@ fn ensure_x11_on_wayland() {
// path. // path.
let session_type = std::env::var("XDG_SESSION_TYPE").unwrap_or_default(); let session_type = std::env::var("XDG_SESSION_TYPE").unwrap_or_default();
if session_type.eq_ignore_ascii_case("wayland") { if session_type.eq_ignore_ascii_case("wayland") {
set_if_unset("GDK_BACKEND", "x11", "Wayland XWayland fallback"); warn_if_unset("GDK_BACKEND", "x11", "Wayland XWayland fallback");
set_if_unset("WINIT_UNIX_BACKEND", "x11", "Wayland XWayland fallback"); warn_if_unset("WINIT_UNIX_BACKEND", "x11", "Wayland XWayland fallback");
} }
} }
fn init_tracing() {
TRACING_INIT.call_once(|| {
let env_filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| {
EnvFilter::new(
"warn,magnotia=info,magnotia_lib=info,magnotia_audio=info,magnotia_startup=info,magnotia_hotkey=info,magnotia_ai_formatting=info",
)
});
let _ = tracing_subscriber::fmt()
.with_env_filter(env_filter)
.with_writer(std::io::stderr)
.try_init();
});
}
#[cfg_attr(mobile, tauri::mobile_entry_point)] #[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() { pub fn run() {
init_tracing();
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
ensure_x11_on_wayland(); warn_if_x11_env_unset_on_wayland();
// Capture Rust panics to disk so the diagnostic-report bundler in // Capture Rust panics to disk so the diagnostic-report bundler in
// Settings → About can attach them. Local only; nothing transmitted. // Settings → About can attach them. Local only; nothing transmitted.
@@ -176,36 +220,39 @@ pub fn run() {
builder builder
.setup(|app| { .setup(|app| {
// Initialise database (blocking in setup — runs once at startup) // Initialise database and startup settings in one runtime entry.
let db_path = database_path(); let db_path = database_path();
let t0 = Instant::now(); let (db, init_script) = tauri::async_runtime::block_on(async {
let db = tauri::async_runtime::block_on(async { init_db(&db_path).await }) let t0 = Instant::now();
.map_err(|e| Box::new(e) as Box<dyn std::error::Error>)?; let db = init_db(&db_path)
eprintln!("[startup] DB init: {:?}", t0.elapsed()); .await
.map_err(|e| Box::new(e) as Box<dyn std::error::Error>)?;
tracing::info!(target: "magnotia_startup", elapsed_ms = t0.elapsed().as_millis(), "DB init complete");
// Prune old `error_log` rows so the table doesn't grow unbounded // Prune old `error_log` rows so the table doesn't grow unbounded
// across months of dogfooding. Best-effort — a prune failure is // across months of dogfooding. Best-effort — a prune failure is
// not worth blocking startup over. // not worth blocking startup over.
let t_prune = Instant::now(); let t_prune = Instant::now();
let pruned = tauri::async_runtime::block_on(async { match prune_error_log(&db, ERROR_LOG_RETENTION_DAYS).await {
prune_error_log(&db, ERROR_LOG_RETENTION_DAYS).await Ok(n) if n > 0 => tracing::info!(
}); target: "magnotia_startup",
match pruned { rows_removed = n,
Ok(n) if n > 0 => eprintln!( retention_days = ERROR_LOG_RETENTION_DAYS,
"[startup] Error log prune: {n} rows removed (>{ERROR_LOG_RETENTION_DAYS}d) in {:?}", elapsed_ms = t_prune.elapsed().as_millis(),
t_prune.elapsed() "error log prune complete"
), ),
Ok(_) => {} Ok(_) => {}
Err(e) => eprintln!("[startup] Error log prune failed: {e}"), Err(e) => tracing::warn!(target: "magnotia_startup", error = %e, "error log prune failed"),
} }
// Load saved preferences for webview injection // Load saved preferences for webview injection.
let t1 = Instant::now(); let t1 = Instant::now();
let prefs_json = tauri::async_runtime::block_on(async { let prefs_json = get_setting(&db, "magnotia_preferences").await.unwrap_or(None);
get_setting(&db, "magnotia_preferences").await.unwrap_or(None) tracing::info!(target: "magnotia_startup", elapsed_ms = t1.elapsed().as_millis(), "preferences load complete");
}); let init_script = build_preferences_script(prefs_json);
eprintln!("[startup] Preferences load: {:?}", t1.elapsed());
let init_script = build_preferences_script(prefs_json); Ok::<_, Box<dyn std::error::Error>>((db, init_script))
})?;
// Apply preferences to the main window (defined in tauri.conf.json) // Apply preferences to the main window (defined in tauri.conf.json)
if let Some(main_window) = app.get_webview_window("main") { if let Some(main_window) = app.get_webview_window("main") {
@@ -238,6 +285,11 @@ pub fn run() {
settings.set_enable_media_capabilities(true); settings.set_enable_media_capabilities(true);
} }
tracing::warn!(
target: "magnotia_startup",
"Linux WebKitGTK microphone permission requests are auto-granted for audio-only capture; other permission classes remain denied"
);
// Auto-grant microphone capture only. Other WebKitGTK // Auto-grant microphone capture only. Other WebKitGTK
// permission requests are denied so future surfaces do // permission requests are denied so future surfaces do
// not inherit camera/geolocation/pointer-lock access. // not inherit camera/geolocation/pointer-lock access.
@@ -268,8 +320,10 @@ pub fn run() {
// Falling back means getUserMedia() prompts (or // Falling back means getUserMedia() prompts (or
// silently denies) instead of auto-granting, // silently denies) instead of auto-granting,
// which is degraded but recoverable. // which is degraded but recoverable.
eprintln!( tracing::warn!(
"[startup] failed to configure webview media permissions: {e}", target: "magnotia_startup",
error = %e,
"failed to configure webview media permissions"
); );
}); });
} }
@@ -312,7 +366,7 @@ pub fn run() {
#[cfg(not(target_os = "android"))] #[cfg(not(target_os = "android"))]
if let Err(e) = tray::setup(app) { if let Err(e) = tray::setup(app) {
eprintln!("Failed to setup tray: {e}"); tracing::warn!(target: "magnotia_startup", error = %e, "failed to setup tray");
} }
Ok(()) Ok(())

View File

@@ -637,17 +637,38 @@
activeTemplate = ""; activeTemplate = "";
} }
function handleExport(format) { async function handleExport(format) {
if (!transcript) return; if (!transcript) return;
showExportMenu = false; showExportMenu = false;
const content = exportTranscript(transcript, segments, format); const content = exportTranscript(transcript, segments, format);
const extMap = { vtt: "vtt", srt: "srt", md: "md", csv: "csv", html: "html" }; const extMap = { txt: "txt", vtt: "vtt", srt: "srt", md: "md", csv: "csv", html: "html" };
const ext = extMap[format] || "txt"; const ext = extMap[format] || "txt";
const defaultPath = `magnotia-${new Date().toISOString().slice(0, 10)}.${ext}`;
if (hasTauriRuntime()) {
try {
const { save } = await import("@tauri-apps/plugin-dialog");
const path = await save({
title: `Export ${format.toUpperCase()} transcript`,
defaultPath,
filters: [{ name: format.toUpperCase(), extensions: [ext] }],
});
if (!path) return;
await invoke("write_text_file_cmd", { path, contents: content });
toasts.success(`Exported ${format.toUpperCase()} transcript`);
return;
} catch (err) {
console.error("dictation export failed", err);
error = `Export failed: ${typeof err === "string" ? err : err.message || err}`;
return;
}
}
const blob = new Blob([content], { type: "text/plain" }); const blob = new Blob([content], { type: "text/plain" });
const url = URL.createObjectURL(blob); const url = URL.createObjectURL(blob);
const a = document.createElement("a"); const a = document.createElement("a");
a.href = url; a.href = url;
a.download = `magnotia-${new Date().toISOString().slice(0, 10)}.${ext}`; a.download = defaultPath;
a.click(); a.click();
URL.revokeObjectURL(url); URL.revokeObjectURL(url);
} }

View File

@@ -8,6 +8,7 @@
import Card from "$lib/components/Card.svelte"; import Card from "$lib/components/Card.svelte";
import EmptyState from "$lib/components/EmptyState.svelte"; import EmptyState from "$lib/components/EmptyState.svelte";
import { exportTranscript } from "$lib/utils/export.js"; import { exportTranscript } from "$lib/utils/export.js";
import { hasTauriRuntime } from "$lib/utils/runtime";
import { Upload } from 'lucide-svelte'; import { Upload } from 'lucide-svelte';
let fileTranscript = $state(""); let fileTranscript = $state("");
@@ -132,17 +133,37 @@
} }
} }
function handleExport(format) { async function handleExport(format) {
if (!fileTranscript) return; if (!fileTranscript) return;
showExportMenu = false; showExportMenu = false;
const content = exportTranscript(fileTranscript, segments, format); const content = exportTranscript(fileTranscript, segments, format);
const extMap = { vtt: "vtt", srt: "srt", md: "md", csv: "csv", html: "html" }; const extMap = { txt: "txt", vtt: "vtt", srt: "srt", md: "md", csv: "csv", html: "html" };
const ext = extMap[format] || "txt"; const ext = extMap[format] || "txt";
const defaultPath = `magnotia-file-${new Date().toISOString().slice(0, 10)}.${ext}`;
if (hasTauriRuntime()) {
try {
const { save } = await import("@tauri-apps/plugin-dialog");
const path = await save({
title: `Export ${format.toUpperCase()} transcript`,
defaultPath,
filters: [{ name: format.toUpperCase(), extensions: [ext] }],
});
if (!path) return;
await invoke("write_text_file_cmd", { path, contents: content });
return;
} catch (err) {
console.error("file transcript export failed", err);
error = `Export failed: ${typeof err === "string" ? err : err.message || err}`;
return;
}
}
const blob = new Blob([content], { type: "text/plain" }); const blob = new Blob([content], { type: "text/plain" });
const url = URL.createObjectURL(blob); const url = URL.createObjectURL(blob);
const a = document.createElement("a"); const a = document.createElement("a");
a.href = url; a.href = url;
a.download = `magnotia-file-${new Date().toISOString().slice(0, 10)}.${ext}`; a.download = defaultPath;
a.click(); a.click();
URL.revokeObjectURL(url); URL.revokeObjectURL(url);
} }

View File

@@ -33,14 +33,24 @@ export function suggestedFilename(item: TranscriptEntry): string {
export async function saveTranscriptAsMarkdown( export async function saveTranscriptAsMarkdown(
item: TranscriptEntry, item: TranscriptEntry,
): Promise<string | null> { ): Promise<string | null> {
if (!hasTauriRuntime()) return null; if (!hasTauriRuntime()) {
toasts.error("Export unavailable", "Native save dialogs are only available in the desktop app.");
return null;
}
const md = buildMarkdown(item); const md = buildMarkdown(item);
const defaultPath = suggestedFilename(item); const defaultPath = suggestedFilename(item);
const path = await save({ let path: string | null = null;
title: "Save transcript as Markdown", try {
defaultPath, path = await save({
filters: [{ name: "Markdown", extensions: ["md"] }], title: "Save transcript as Markdown",
}); defaultPath,
filters: [{ name: "Markdown", extensions: ["md"] }],
});
} catch (err) {
console.error("saveTranscriptAsMarkdown dialog failed", err);
toasts.error("Couldn't open save dialog", String(err));
return null;
}
if (!path) return null; if (!path) return null;
try { try {
await invoke("write_text_file_cmd", { path, contents: md }); await invoke("write_text_file_cmd", { path, contents: md });
@@ -64,13 +74,24 @@ export async function saveTranscriptAsMarkdown(
export async function exportTranscriptsToDir( export async function exportTranscriptsToDir(
items: TranscriptEntry[], items: TranscriptEntry[],
): Promise<number> { ): Promise<number> {
if (!hasTauriRuntime() || items.length === 0) return 0; if (items.length === 0) return 0;
if (!hasTauriRuntime()) {
toasts.error("Export unavailable", "Native folder dialogs are only available in the desktop app.");
return 0;
}
const dir = await open({ let dir: string | string[] | null = null;
directory: true, try {
multiple: false, dir = await open({
title: "Choose export folder", directory: true,
}); multiple: false,
title: "Choose export folder",
});
} catch (err) {
console.error("exportTranscriptsToDir dialog failed", err);
toasts.error("Couldn't open export folder dialog", String(err));
return 0;
}
if (!dir || typeof dir !== "string") return 0; if (!dir || typeof dir !== "string") return 0;
const used = new Set<string>(); const used = new Set<string>();