1use std::cell::Cell;
14use std::sync::Arc;
15
16use forge_dictate::catalogue::{CatalogueEntry, CatalogueSource, Download, EntryKind};
17use forge_dictate::{ModelFacts, ModelSpec, Progress};
18use serde::{Deserialize, Serialize};
19
20use crate::bench::BenchState;
21use crate::catalogue::PREFERRED_DOWNLOADS;
22use crate::dictate::{DictateRole, DictateSettings};
23use crate::{DispatchError, SessionUpdate, Workspace};
24
25#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
27#[serde(tag = "state", rename_all = "snake_case")]
28pub enum InstallState {
29 #[default]
30 Idle,
31 Downloading {
34 file: String,
35 got: u64,
36 total: u64,
37 },
38 Failed {
39 file: String,
40 reason: String,
41 },
42}
43
44#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
46#[serde(tag = "state", rename_all = "snake_case")]
47pub enum ActivateState {
48 #[default]
49 Idle,
50 Activating { role: DictateRole, file: String },
53 Failed { role: DictateRole, file: String, reason: String },
56}
57
58#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
60pub struct InstalledModel {
61 pub variant: String,
63 pub file: String,
64 pub url: String,
66 pub size: u64,
67 pub facts: ModelFacts,
70 pub at: String,
72}
73
74#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
76#[serde(tag = "from", rename_all = "snake_case")]
77pub enum ActiveFrom {
78 Config { key: String, variant: String },
81 Installed { variant: String },
83 Pin,
85}
86
87#[derive(Debug, Clone, PartialEq)]
92pub struct ActiveModel {
93 pub role: DictateRole,
94 pub spec: ModelSpec,
95 pub from: ActiveFrom,
96 pub at: Option<String>,
98}
99
100#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
103pub struct ActiveChoice {
104 pub variant: String,
105 pub at: String,
107}
108
109fn section_for(role: DictateRole) -> &'static str {
111 match role {
112 DictateRole::Speaking => "tts",
113 DictateRole::Transcribing | DictateRole::Normalization => "dictate",
114 }
115}
116
117fn key_for_role(role: DictateRole) -> Option<&'static str> {
119 match role {
120 DictateRole::Transcribing => Some("transcribe_model"),
121 DictateRole::Normalization => Some("cleanup_model"),
122 DictateRole::Speaking => Some("speaking_model"),
123 }
124}
125
126fn spec_from_record(installed: &InstalledModel) -> ModelSpec {
129 ModelSpec {
130 file: installed.file.clone(),
131 url: installed.url.clone(),
132 size: installed.size,
133 sha256: None,
134 facts: installed.facts.clone(),
135 }
136}
137
138pub(crate) enum RoleResolution {
141 Chosen(ActiveModel),
142 NeedsFeed {
145 key: String,
146 variant: String,
147 },
148}
149
150pub(crate) fn resolve_role(
157 role: DictateRole,
158 pin: ModelSpec,
159 configured: Option<&str>,
160 installed: &[InstalledModel],
161 pick: Option<ActiveChoice>,
162) -> RoleResolution {
163 if let Some(variant) = configured {
166 let Some(key) = key_for_role(role).map(str::to_owned) else {
167 return RoleResolution::Chosen(ActiveModel {
168 role,
169 spec: pin,
170 from: ActiveFrom::Pin,
171 at: None,
172 });
173 };
174 return match installed.iter().find(|model| model.variant == variant) {
175 Some(record) => RoleResolution::Chosen(ActiveModel {
176 role,
177 spec: spec_from_record(record),
178 from: ActiveFrom::Config { key, variant: variant.to_owned() },
179 at: None,
180 }),
181 None => RoleResolution::NeedsFeed { key, variant: variant.to_owned() },
182 };
183 }
184 if let Some(choice) = pick {
185 let Some(record) = installed.iter().find(|model| model.variant == choice.variant) else {
186 tracing::warn!(
191 event_name = "dictate_pick_without_a_record",
192 role = crate::store::dictate_models::role_key(role),
193 variant = %choice.variant,
194 "the runtime pick names a model no record carries; the compiled pin runs this role"
195 );
196 return RoleResolution::Chosen(ActiveModel {
197 role,
198 spec: pin,
199 from: ActiveFrom::Pin,
200 at: None,
201 });
202 };
203 return RoleResolution::Chosen(ActiveModel {
204 role,
205 spec: spec_from_record(record),
206 from: ActiveFrom::Installed { variant: choice.variant },
207 at: Some(choice.at),
208 });
209 }
210 RoleResolution::Chosen(ActiveModel { role, spec: pin, from: ActiveFrom::Pin, at: None })
211}
212
213impl Workspace {
214 pub fn dictate_install(&self) -> InstallState {
216 self.dictate_install.lock().clone()
217 }
218
219 pub fn installed_models(&self) -> Vec<InstalledModel> {
221 let db = self.db.lock();
222 let Some(db) = db.as_ref() else { return Vec::new() };
223 match crate::store::dictate_models::installed(db) {
224 Ok(rows) => rows,
225 Err(error) => {
228 tracing::warn!(
229 event_name = "dictate_installed_read_failed",
230 %error,
231 "the installed models could not be read; the page shows none"
232 );
233 Vec::new()
234 }
235 }
236 }
237
238 pub(crate) fn uninstall_model(&self, file: &str) -> Result<(), DispatchError> {
251 let Some(record) = self.installed_models().into_iter().find(|model| model.file == file)
252 else {
253 return Err(DispatchError::UninstallRefused {
254 reason: "no model this machine downloaded carries that file".to_owned(),
255 });
256 };
257 if let Some((role, _)) =
258 self.active_models().iter().find(|(_, model)| model.spec.file == file)
259 {
260 return Err(DispatchError::ModelInUse { role: *role });
261 }
262 if self.bench_running()
266 && matches!(self.dictate_bench(), BenchState::Running { target, .. } if target.file == file)
267 {
268 return Err(DispatchError::UninstallRefused {
269 reason: "the bench is scoring it right now".to_owned(),
270 });
271 }
272 if let InstallState::Downloading { file: writing, .. } = self.dictate_install.lock().clone()
273 && writing == file
274 {
275 return Err(DispatchError::UninstallRefused {
276 reason: "the file is being downloaded right now".to_owned(),
277 });
278 }
279 if let Some(dir) = self.config_arc().dictate.models_dir() {
280 let path = dir.join(file);
281 if let Err(error) = std::fs::remove_file(&path)
282 && error.kind() != std::io::ErrorKind::NotFound
283 {
284 tracing::warn!(%error, file, "the model file was not removed");
285 return Err(DispatchError::UninstallRefused { reason: error.to_string() });
286 }
287 }
288 let db = self.db.lock();
289 if let Some(db) = db.as_ref() {
290 if let Err(error) = crate::store::dictate_models::remove_installed(db, file) {
291 tracing::warn!(
292 event_name = "dictate_uninstall_record_failed",
293 %error,
294 file,
295 "the model was removed from disk but its record was not; the page still lists it"
296 );
297 }
298 for role in [DictateRole::Transcribing, DictateRole::Normalization] {
299 let picked = crate::store::dictate_models::active(db, role)
300 .ok()
301 .flatten()
302 .is_some_and(|choice| choice.variant == record.variant);
303 if picked && let Err(error) = crate::store::dictate_models::clear_active(db, role) {
304 tracing::warn!(
305 event_name = "dictate_uninstall_pick_clear_failed",
306 %error,
307 role = crate::store::dictate_models::role_key(role),
308 "the removal left a runtime pick naming the model that is gone"
309 );
310 }
311 }
312 }
313 Ok(())
314 }
315
316 pub(crate) fn start_install(self: &Arc<Self>, variant: String) -> Result<(), DispatchError> {
322 let speaking = crate::tts::SpeakingVariant::named(&variant);
323 let config = self.config_arc();
324 if speaking.is_some() {
325 if !config.tts.enabled {
326 return Err(DispatchError::VoiceOff {
327 reason: "[tts] enabled is false".to_owned(),
328 });
329 }
330 } else if !config.dictate.enabled {
331 return Err(DispatchError::DictateOff);
332 }
333 if self.bench_running() {
334 return Err(DispatchError::BenchRunning);
335 }
336 {
337 let mut state = self.dictate_install.lock();
338 if matches!(*state, InstallState::Downloading { .. }) {
339 return Err(DispatchError::Installing);
340 }
341 *state = InstallState::Downloading { file: String::new(), got: 0, total: 0 };
342 }
343 let this = Arc::clone(self);
344 tokio::spawn(async move {
345 let runner = Arc::clone(&this);
346 runner.run_install(variant).await;
347 this.push_models();
348 });
349 Ok(())
350 }
351
352 fn fail_install(&self, file: String, reason: String) {
354 *self.dictate_install.lock() = InstallState::Failed { file, reason };
355 }
356
357 fn note_download(&self, file: &str, got: u64, total: u64) {
358 *self.dictate_install.lock() =
359 InstallState::Downloading { file: file.to_owned(), got, total };
360 }
361
362 fn set_install_file(&self, file: &str, total: u64) {
365 self.note_download(file, 0, total);
366 }
367
368 fn record_installed(&self, model: &InstalledModel) -> bool {
375 let db = self.db.lock();
376 let Some(db) = db.as_ref() else {
377 tracing::warn!(
378 event_name = "dictate_install_record_failed",
379 file = %model.file,
380 "no store is open; the download was not recorded"
381 );
382 return false;
383 };
384 match crate::store::dictate_models::record_installed(db, model) {
385 Ok(()) => true,
386 Err(error) => {
387 tracing::warn!(
388 event_name = "dictate_install_record_failed",
389 %error,
390 file = %model.file,
391 "the download was not recorded; the page cannot list or remove it"
392 );
393 false
394 }
395 }
396 }
397
398 pub(crate) fn push_models(&self) {
400 let models = self.dictate_models();
401 let _ = self.update_sender().send(SessionUpdate::DictateModelsChanged { models });
402 }
403
404 fn variant_for_install(&self, variant: &str) -> Option<(CatalogueSource, CatalogueEntry)> {
406 let source = self.catalogue_source();
407 let state = self.dictate_catalogue.lock();
408 let entry = state
409 .catalogue
410 .as_ref()?
411 .entries
412 .iter()
413 .find(|entry| entry.variant == variant)?
414 .clone();
415 Some((source, entry))
416 }
417
418 async fn run_install(self: Arc<Self>, variant: String) {
422 if let Some(speaking) = crate::tts::SpeakingVariant::named(&variant) {
423 return self.run_speaking_install(speaking).await;
424 }
425 let Some((source, entry)) = self.variant_for_install(&variant) else {
426 return self.fail_install(String::new(), format!("no catalogue entry names {variant}"));
427 };
428 let Some(download) = preferred_download(&entry).cloned() else {
429 return self.fail_install(
430 String::new(),
431 format!("{variant} ships no quantisation this machine would run"),
432 );
433 };
434 let spec = match spec_for_entry(source, &entry, &download).await {
435 Ok(spec) => spec,
436 Err(reason) => return self.fail_install(download.filename, reason),
437 };
438
439 let Some(dir) = self.config_arc().dictate.models_dir() else {
443 return self
444 .fail_install(download.filename, "no models directory is configured".to_owned());
445 };
446 let mut cfg = self.config_arc().dictate.to_config();
447 cfg.models_dir = Some(dir);
448 cfg.asr_model = spec.clone();
449 cfg.normalizer = None;
450
451 self.set_install_file(&download.filename, download.size_bytes);
452 self.push_models();
455 let this = Arc::clone(&self);
456 let file = download.filename.clone();
457 let prepared = tokio::task::spawn_blocking(move || {
458 let last = Cell::new(u64::MAX);
465 forge_dictate::prepare(&cfg, |progress| {
466 if let Progress::Downloading { downloaded, total, .. } = progress {
467 let percent = downloaded.saturating_mul(100).checked_div(total).unwrap_or(0);
468 if percent != last.get() {
469 last.set(percent);
470 this.note_download(&file, downloaded, total);
471 this.push_models();
472 }
473 }
474 std::ops::ControlFlow::Continue(())
475 })
476 })
477 .await;
478
479 match prepared {
480 Ok(Ok(())) => {
481 let recorded = self.record_installed(&InstalledModel {
482 variant,
483 file: spec.file.clone(),
484 url: spec.url,
485 size: spec.size,
486 facts: spec.facts,
487 at: rfc3339_now(),
488 });
489 if !recorded {
494 if let Some(dir) = self.config_arc().dictate.models_dir() {
495 let _ = std::fs::remove_file(dir.join(&spec.file));
496 }
497 self.fail_install(spec.file, "the download could not be recorded".to_owned());
498 return;
499 }
500 *self.dictate_install.lock() = InstallState::Idle;
501 }
502 Ok(Err(error)) => self.fail_install(download.filename, error.to_string()),
503 Err(join) => self.fail_install(download.filename, join.to_string()),
504 }
505 }
506
507 async fn run_speaking_install(self: Arc<Self>, speaking: crate::tts::SpeakingVariant) {
511 use crate::hub::{self, Hub};
512 use crate::tts::{HUB_REPO, SpeakingInstall, VoiceFile};
513
514 let Some(dir) = self.config_arc().tts.models_dir() else {
515 return self.fail_install(
516 speaking.file.to_owned(),
517 "no models directory is configured".to_owned(),
518 );
519 };
520 let token = self.config_arc().huggingface.token.clone();
521 let hub = match Hub::new(token) {
522 Ok(hub) => hub,
523 Err(reason) => return self.fail_install(speaking.file.to_owned(), reason),
524 };
525 let revision = match hub.revision(HUB_REPO).await {
526 Ok(revision) => revision,
527 Err(reason) => return self.fail_install(speaking.file.to_owned(), reason),
528 };
529 let blobs = match hub.blobs(HUB_REPO, &revision).await {
530 Ok(blobs) => blobs,
531 Err(reason) => return self.fail_install(speaking.file.to_owned(), reason),
532 };
533 let files = match hub::speaking_files(&blobs, speaking.path) {
534 Ok(files) => files,
535 Err(reason) => return self.fail_install(speaking.file.to_owned(), reason),
536 };
537 if let Err(reason) = hub::verify_release(&speaking, &files.model) {
538 return self.fail_install(speaking.file.to_owned(), reason);
539 }
540 let Some(model_sha256) = files.model.sha256.clone() else {
541 return self.fail_install(
542 speaking.file.to_owned(),
543 format!("{} publishes no digest", files.model.path),
544 );
545 };
546 let total = files.model.size + files.voices.iter().map(|voice| voice.size).sum::<u64>();
547 self.set_install_file(speaking.file, total);
548 self.push_models();
549
550 let mut written: Vec<(std::path::PathBuf, bool)> = Vec::new();
554
555 let model = dir.join(speaking.file);
556 let existed = model.exists();
557 if let Err(reason) =
558 download_blob(&self, &hub, &revision, &files.model, &model, 0, total).await
559 {
560 remove_written(&written);
561 return self.fail_install(speaking.file.to_owned(), reason);
562 }
563 written.push((model, existed));
564
565 let mut done = files.model.size;
566 let mut voices = Vec::with_capacity(files.voices.len());
567 for blob in &files.voices {
568 let Some(name) = hub::voice_id(&blob.path) else {
569 continue;
570 };
571 let Some(sha256) = blob.sha256.clone() else {
572 remove_written(&written);
573 return self.fail_install(
574 speaking.file.to_owned(),
575 format!("{} publishes no digest", blob.path),
576 );
577 };
578 let dest = dir.join("voices").join(format!("{name}.bin"));
579 let existed = dest.exists();
580 if let Err(reason) =
581 download_blob(&self, &hub, &revision, blob, &dest, done, total).await
582 {
583 remove_written(&written);
584 return self.fail_install(speaking.file.to_owned(), reason);
585 }
586 written.push((dest, existed));
587 done += blob.size;
588 voices.push(VoiceFile { name: name.to_owned(), size: blob.size, sha256 });
589 }
590
591 let install = SpeakingInstall {
592 variant: speaking.variant.to_owned(),
593 revision,
594 model: speaking.file.to_owned(),
595 model_size: files.model.size,
596 model_sha256,
597 voices,
598 at: rfc3339_now(),
599 };
600 if !self.record_speaking_install(&install) {
601 remove_written(&written);
602 self.fail_install(
603 speaking.file.to_owned(),
604 "the download could not be recorded".to_owned(),
605 );
606 return;
607 }
608 *self.dictate_install.lock() = InstallState::Idle;
609 }
610
611 fn record_speaking_install(&self, install: &crate::tts::SpeakingInstall) -> bool {
613 let db = self.db.lock();
614 let Some(db) = db.as_ref() else {
615 tracing::warn!(
616 event_name = "speaking_install_record_failed",
617 variant = %install.variant,
618 "no store is open; the download was not recorded"
619 );
620 return false;
621 };
622 match crate::store::speaking_models::record_installed(db, install) {
623 Ok(()) => true,
624 Err(error) => {
625 tracing::warn!(
626 event_name = "speaking_install_record_failed",
627 %error,
628 variant = %install.variant,
629 "the download was not recorded; the page cannot list or remove it"
630 );
631 false
632 }
633 }
634 }
635}
636
637fn remove_written(written: &[(std::path::PathBuf, bool)]) {
640 for (path, existed) in written {
641 if !existed {
642 let _ = std::fs::remove_file(path);
643 }
644 }
645}
646
647async fn download_blob(
649 workspace: &Arc<Workspace>,
650 hub: &crate::hub::Hub,
651 revision: &str,
652 blob: &crate::hub::HubBlob,
653 dest: &std::path::Path,
654 base: u64,
655 total: u64,
656) -> Result<(), String> {
657 use std::sync::atomic::{AtomicU64, Ordering};
658
659 let file =
660 dest.file_name().map_or_else(String::new, |name| name.to_string_lossy().into_owned());
661 let last = AtomicU64::new(u64::MAX);
662 hub.download(crate::tts::HUB_REPO, revision, blob, dest, |written| {
663 let got = base + written;
664 let percent = got.saturating_mul(100).checked_div(total).unwrap_or(0);
665 if last.swap(percent, Ordering::Relaxed) != percent {
666 workspace.note_download(&file, got, total);
667 workspace.push_models();
668 }
669 })
670 .await
671}
672
673async fn build_engine(cfg: forge_dictate::Config) -> Result<Arc<forge_dictate::Engine>, String> {
679 tokio::task::spawn_blocking(move || {
680 let engine = forge_dictate::Engine::new(cfg)?;
681 engine.wait_ready()?;
682 Ok::<Arc<forge_dictate::Engine>, forge_dictate::Error>(engine)
683 })
684 .await
685 .map_err(|join| join.to_string())?
686 .map_err(|error| error.to_string())
687}
688
689async fn spec_for_entry(
698 source: CatalogueSource,
699 entry: &CatalogueEntry,
700 download: &Download,
701) -> Result<ModelSpec, String> {
702 let url = if let Some(url) = download.url.clone() {
703 url
704 } else {
705 let variant = entry.variant.clone();
706 let repo = entry.published_repo.clone();
707 let links = tokio::task::spawn_blocking(move || {
708 forge_dictate::catalogue::download_links(&source, &variant, repo.as_deref())
709 })
710 .await
711 .map_err(|join| join.to_string())?
712 .map_err(|error| error.to_string())?;
713 match links.into_iter().find(|(file, _)| file == &download.filename) {
714 Some((_, url)) => url,
715 None => {
716 return Err(format!(
717 "the feed's documents carry no download for {}",
718 download.filename
719 ));
720 }
721 }
722 };
723 Ok(forge_dictate::spec_for_download(
724 &download.filename,
725 &url,
726 download.size_bytes,
727 download.sha256.clone(),
728 ModelFacts {
729 quant: Some(download.quant.clone()),
730 params: entry.params,
731 license: entry.license.as_ref().map(|license| license.display.clone()),
732 runtime: Some(match entry.kind {
736 EntryKind::Asr => "transcribe.cpp".to_owned(),
737 EntryKind::Normalizer => "llama.cpp".to_owned(),
738 }),
739 },
740 ))
741}
742
743fn preferred_download(entry: &CatalogueEntry) -> Option<&Download> {
745 PREFERRED_DOWNLOADS
746 .iter()
747 .find_map(|quant| entry.downloads.iter().find(|download| &download.quant == quant))
748}
749
750impl Workspace {
751 pub fn active_models(&self) -> Vec<(DictateRole, ActiveModel)> {
755 self.dictate.active.lock().clone()
756 }
757
758 pub(crate) async fn resolve_active(
767 &self,
768 settings: &DictateSettings,
769 ) -> Result<Vec<(DictateRole, ActiveModel)>, String> {
770 let mut resolved = Vec::new();
771 for (role, pin) in settings.model_specs() {
772 let configured = match role {
773 DictateRole::Transcribing => settings.transcribe_model.as_deref(),
774 DictateRole::Normalization => settings.cleanup_model.as_deref(),
775 DictateRole::Speaking => None,
777 };
778 let installed = self.installed_models();
779 let pick = if configured.is_none() { self.active_choice(role) } else { None };
782 match resolve_role(role, pin, configured, &installed, pick) {
783 RoleResolution::Chosen(model) => resolved.push((role, model)),
784 RoleResolution::NeedsFeed { key, variant } => {
785 let spec = self.resolve_variant(&key, &variant).await?;
786 resolved.push((
787 role,
788 ActiveModel {
789 role,
790 spec,
791 from: ActiveFrom::Config { key, variant },
792 at: None,
793 },
794 ));
795 }
796 }
797 }
798 Ok(resolved)
799 }
800
801 pub(crate) fn active_choice(&self, role: DictateRole) -> Option<ActiveChoice> {
807 let db = self.db.lock();
808 let db = db.as_ref()?;
809 match crate::store::dictate_models::active(db, role) {
810 Ok(choice) => choice,
811 Err(error) => {
812 tracing::warn!(
813 event_name = "dictate_active_read_failed",
814 %error,
815 role = crate::store::dictate_models::role_key(role),
816 "the runtime pick could not be read; the role falls to the compiled pin"
817 );
818 None
819 }
820 }
821 }
822
823 async fn resolve_variant(&self, key: &str, variant: &str) -> Result<ModelSpec, String> {
826 if let Some(installed) =
827 self.installed_models().into_iter().find(|model| model.variant == variant)
828 {
829 return Ok(spec_from_record(&installed));
830 }
831 let Some(entry) = self.catalogue_entry_for(variant).await else {
832 return Err(format!(
833 "[dictate] {key} names {variant}, which is neither an installed model nor a \
834 catalogue entry"
835 ));
836 };
837 let Some(download) = preferred_download(&entry).cloned() else {
838 return Err(format!(
839 "[dictate] {key} names {variant}, which ships no quantisation this machine would \
840 run"
841 ));
842 };
843 spec_for_entry(self.catalogue_source(), &entry, &download)
844 .await
845 .map_err(|reason| format!("[dictate] {key} names {variant}: {reason}"))
846 }
847
848 async fn catalogue_entry_for(&self, variant: &str) -> Option<CatalogueEntry> {
854 let loaded = {
855 let state = self.dictate_catalogue.lock();
856 state.catalogue.as_ref().and_then(|catalogue| {
857 catalogue.entries.iter().find(|entry| entry.variant == variant).cloned()
858 })
859 };
860 if loaded.is_some() {
861 return loaded;
862 }
863 let fetched = self.fetch_catalogue_once().await.ok()?;
864 fetched.entries.into_iter().find(|entry| entry.variant == variant)
865 }
866
867 pub(crate) fn record_resolved_installs(&self, resolved: &[(DictateRole, ActiveModel)]) {
874 for (_, model) in resolved {
875 let ActiveFrom::Config { variant, .. } = &model.from else { continue };
876 if self.installed_models().iter().any(|installed| &installed.variant == variant) {
877 continue;
878 }
879 self.record_installed(&InstalledModel {
880 variant: variant.clone(),
881 file: model.spec.file.clone(),
882 url: model.spec.url.clone(),
883 size: model.spec.size,
884 facts: model.spec.facts.clone(),
885 at: rfc3339_now(),
886 });
887 }
888 }
889}
890
891impl Workspace {
892 pub fn dictate_activate(&self) -> ActivateState {
894 self.dictate_activate.lock().clone()
895 }
896
897 pub(crate) fn start_activate(
905 self: &Arc<Self>,
906 role: DictateRole,
907 file: String,
908 ) -> Result<(), DispatchError> {
909 if role == DictateRole::Speaking {
910 if !self.config_arc().tts.enabled {
911 return Err(DispatchError::VoiceOff {
912 reason: "[tts] enabled is false".to_owned(),
913 });
914 }
915 } else if !self.config_arc().dictate.enabled {
916 return Err(DispatchError::DictateOff);
917 }
918 if let Some(key) = self.pinning_key(role) {
919 return Err(DispatchError::PinnedRole { section: section_for(role), key });
920 }
921 if let Some(holder) = self.dictate_runtime.lock().live_holder() {
922 return Err(DispatchError::TakeLive { holder });
923 }
924 if self.bench_running() {
925 return Err(DispatchError::BenchRunning);
926 }
927 {
928 let mut state = self.dictate_activate.lock();
929 if matches!(*state, ActivateState::Activating { .. }) {
930 return Err(DispatchError::Activating);
931 }
932 *state = ActivateState::Activating { role, file: file.clone() };
933 }
934 let this = Arc::clone(self);
935 tokio::spawn(async move {
936 let runner = Arc::clone(&this);
937 runner.run_activate(role, file).await;
938 this.push_models();
939 });
940 Ok(())
941 }
942
943 pub(crate) fn start_deactivate(
947 self: &Arc<Self>,
948 role: DictateRole,
949 ) -> Result<(), DispatchError> {
950 if role == DictateRole::Speaking {
951 if !self.config_arc().tts.enabled {
952 return Err(DispatchError::VoiceOff {
953 reason: "[tts] enabled is false".to_owned(),
954 });
955 }
956 } else if !self.config_arc().dictate.enabled {
957 return Err(DispatchError::DictateOff);
958 }
959 if let Some(key) = self.pinning_key(role) {
960 return Err(DispatchError::PinnedRole { section: section_for(role), key });
961 }
962 if let Some(holder) = self.dictate_runtime.lock().live_holder() {
963 return Err(DispatchError::TakeLive { holder });
964 }
965 if self.bench_running() {
966 return Err(DispatchError::BenchRunning);
967 }
968 let Some(pin) = self.pin_for(role) else {
969 return Err(DispatchError::DictateOff);
970 };
971 {
972 let mut state = self.dictate_activate.lock();
973 if matches!(*state, ActivateState::Activating { .. }) {
974 return Err(DispatchError::Activating);
975 }
976 *state = ActivateState::Activating { role, file: pin.file.clone() };
977 }
978 let this = Arc::clone(self);
979 tokio::spawn(async move {
980 let runner = Arc::clone(&this);
981 runner.run_deactivate(role, pin).await;
982 this.push_models();
983 });
984 Ok(())
985 }
986
987 fn pinning_key(&self, role: DictateRole) -> Option<String> {
989 let config = self.config_arc();
990 let pinned = match role {
991 DictateRole::Transcribing => config.dictate.transcribe_model.as_ref(),
992 DictateRole::Normalization => config.dictate.cleanup_model.as_ref(),
993 DictateRole::Speaking => config.tts.speaking_model.as_ref(),
994 };
995 pinned.and_then(|_| key_for_role(role).map(str::to_owned))
996 }
997
998 fn pin_for(&self, role: DictateRole) -> Option<ModelSpec> {
1001 let config = self.config_arc();
1002 if role == DictateRole::Speaking {
1003 return Some(config.tts.pin());
1004 }
1005 config
1006 .dictate
1007 .model_specs()
1008 .into_iter()
1009 .find(|(spec_role, _)| *spec_role == role)
1010 .map(|(_, spec)| spec)
1011 }
1012
1013 async fn run_activate(self: Arc<Self>, role: DictateRole, file: String) {
1015 if role == DictateRole::Speaking {
1016 let Some(install) = self.speaking_installs().into_iter().find(|row| row.model == file)
1017 else {
1018 return self.fail_activate(
1019 role,
1020 file,
1021 "no installed speaking model has that file".to_owned(),
1022 );
1023 };
1024 let variant = install.variant.clone();
1025 return match self.activate_speaking(&variant).await {
1026 Ok(at) => {
1027 self.record_pick(role, &variant, &at);
1028 *self.dictate_activate.lock() = ActivateState::Idle;
1029 }
1030 Err(reason) => self.fail_activate(role, file, reason),
1031 };
1032 }
1033 let Some(installed) = self.installed_models().into_iter().find(|model| model.file == file)
1034 else {
1035 return self.fail_activate(role, file, "no installed model has that file".to_owned());
1036 };
1037 let variant = installed.variant.clone();
1038 let spec = ModelSpec {
1039 file: installed.file,
1040 url: installed.url,
1041 size: installed.size,
1042 sha256: None,
1043 facts: installed.facts,
1044 };
1045 match self
1046 .swap_role_to(role, spec, ActiveFrom::Installed { variant: variant.clone() })
1047 .await
1048 {
1049 Ok(at) => {
1050 self.record_pick(role, &variant, &at);
1051 *self.dictate_activate.lock() = ActivateState::Idle;
1052 }
1053 Err(reason) => self.fail_activate(role, file, reason),
1054 }
1055 }
1056
1057 async fn activate_speaking(self: &Arc<Self>, variant: &str) -> Result<String, String> {
1060 let install = self
1061 .speaking_install(variant)
1062 .ok_or_else(|| format!("the speaking model {variant} is not installed"))?;
1063 let files = self
1064 .files_for_install(&install)
1065 .ok_or_else(|| "no models directory is configured".to_owned())?;
1066 let engine = tokio::task::spawn_blocking(move || forge_tts::KokoroTts::load(&files))
1067 .await
1068 .map_err(|join| join.to_string())?
1069 .map_err(|error| error.to_string())?;
1070 let engine: Arc<dyn forge_tts::Engine> = Arc::new(engine);
1071 self.voice_service().replace_engine(engine).await;
1072 Ok(rfc3339_now())
1073 }
1074
1075 async fn run_deactivate(self: Arc<Self>, role: DictateRole, pin: ModelSpec) {
1077 if role == DictateRole::Speaking {
1078 let pinned = crate::tts::SpeakingVariant::pinned();
1079 return match self.activate_speaking(pinned.variant).await {
1080 Ok(_) => {
1081 self.clear_pick(role);
1082 *self.dictate_activate.lock() = ActivateState::Idle;
1083 }
1084 Err(reason) => self.fail_activate(role, pin.file.clone(), reason),
1085 };
1086 }
1087 let file = pin.file.clone();
1088 match self.swap_role_to(role, pin, ActiveFrom::Pin).await {
1089 Ok(_) => {
1090 self.clear_pick(role);
1091 *self.dictate_activate.lock() = ActivateState::Idle;
1092 }
1093 Err(reason) => self.fail_activate(role, file, reason),
1094 }
1095 }
1096
1097 async fn swap_role_to(
1104 &self,
1105 role: DictateRole,
1106 spec: ModelSpec,
1107 from: ActiveFrom,
1108 ) -> Result<String, String> {
1109 if role == DictateRole::Speaking {
1110 return Err("the speaking engine is rebuilt by the voice service".to_owned());
1113 }
1114 let current = self.active_models();
1115 if current.iter().any(|(r, model)| *r == role && model.spec.file == spec.file) {
1116 return Ok(rfc3339_now());
1119 }
1120 let stamp = rfc3339_now();
1121 let at = match from {
1124 ActiveFrom::Installed { .. } => Some(stamp.clone()),
1125 ActiveFrom::Config { .. } | ActiveFrom::Pin => None,
1126 };
1127 let next: Vec<(DictateRole, ActiveModel)> = current
1128 .into_iter()
1129 .map(|(r, model)| {
1130 if r == role {
1131 (
1132 role,
1133 ActiveModel {
1134 role,
1135 spec: spec.clone(),
1136 from: from.clone(),
1137 at: at.clone(),
1138 },
1139 )
1140 } else {
1141 (r, model)
1142 }
1143 })
1144 .collect();
1145
1146 let cfg = crate::dictate::preflight_config(&self.config_arc().dictate, &next);
1147 let engine = build_engine(cfg).await?;
1148 let previous = self.dictate.engine.lock().replace(engine);
1149 drop(previous);
1150
1151 self.dictate.set_active(&next);
1152 self.dictate.mark_all(&crate::dictate::DictateModelState::Ready);
1153 Ok(stamp)
1154 }
1155
1156 fn fail_activate(&self, role: DictateRole, file: String, reason: String) {
1157 *self.dictate_activate.lock() = ActivateState::Failed { role, file, reason };
1158 }
1159
1160 fn record_pick(&self, role: DictateRole, variant: &str, at: &str) {
1162 let db = self.db.lock();
1163 if let Some(db) = db.as_ref()
1164 && let Err(error) = crate::store::dictate_models::record_active(
1165 db,
1166 role,
1167 &ActiveChoice { variant: variant.to_owned(), at: at.to_owned() },
1168 )
1169 {
1170 tracing::warn!(
1171 event_name = "dictate_active_record_failed",
1172 %error,
1173 %variant,
1174 "the runtime pick was not recorded; the next boot answers the previous model"
1175 );
1176 }
1177 }
1178
1179 fn clear_pick(&self, role: DictateRole) {
1182 let db = self.db.lock();
1183 if let Some(db) = db.as_ref()
1184 && let Err(error) = crate::store::dictate_models::clear_active(db, role)
1185 {
1186 tracing::warn!(
1187 event_name = "dictate_active_clear_failed",
1188 %error,
1189 "the runtime pick was not cleared; the next boot answers it again"
1190 );
1191 }
1192 }
1193}
1194
1195pub(crate) fn rfc3339_now() -> String {
1197 time::OffsetDateTime::now_utc()
1198 .format(&time::format_description::well_known::Rfc3339)
1199 .unwrap_or_default()
1200}
1201
1202#[cfg(test)]
1203mod tests_install {
1204 use super::*;
1205 use crate::bench::{BenchRole, BenchTarget};
1206 use crate::catalogue::DictateModelsSnapshot;
1207 use crate::catalogue::tests_catalogue_view::{
1208 enabled_stub, entry_under, serve, serve_with, source,
1209 };
1210 use crate::{Command, DispatchError, SessionUpdate, Workspace};
1211 use forge_dictate::catalogue::Catalogue;
1212
1213 fn catalogue_of(entries: Vec<forge_dictate::catalogue::CatalogueEntry>) -> Catalogue {
1216 Catalogue {
1217 fetched_at: "2026-10-06T00:00:00Z".to_owned(),
1218 release: None,
1219 entries,
1220 skipped: 0,
1221 }
1222 }
1223
1224 fn doc_body(base: &str, variant: &str, file: &str) -> Vec<u8> {
1227 format!(
1228 "# {variant}\n\n<!-- catalog:downloads -->\n\
1229 | Quantization | Download | Size |\n\
1230 | --- | --- | ---: |\n\
1231 | Q4_K_M | [{file}]({base}/weights/{file}) | 6 B |\n\
1232 <!-- /catalog -->\n"
1233 )
1234 .into_bytes()
1235 }
1236
1237 struct Fixture {
1242 ws: Arc<Workspace>,
1243 updates: tokio::sync::mpsc::UnboundedReceiver<crate::SeatUpdate>,
1244 _models: tempfile::TempDir,
1245 _store: tempfile::TempDir,
1246 }
1247
1248 fn fixture() -> Fixture {
1249 fixture_with(None, None)
1250 }
1251
1252 fn fixture_with(transcribe_model: Option<&str>, cleanup_model: Option<&str>) -> Fixture {
1254 fixture_built(|config| {
1255 config.dictate.transcribe_model = transcribe_model.map(str::to_owned);
1256 config.dictate.cleanup_model = cleanup_model.map(str::to_owned);
1257 })
1258 }
1259
1260 fn fixture_built(configure: impl FnOnce(&mut crate::config::LoadedConfig)) -> Fixture {
1263 let config_dir = tempfile::tempdir().expect("a config dir");
1264 let models = tempfile::tempdir().expect("a models dir");
1265 let store = tempfile::tempdir().expect("a store dir");
1266 let mut config = crate::config::LoadedConfig::empty_for_test();
1267 config.dictate.enabled = true;
1268 config.dictate.models_dir = Some(models.path().to_string_lossy().into_owned());
1269 configure(&mut config);
1270 let (ws, updates) =
1271 Workspace::testing_stub_with_config(config_dir.path().to_path_buf(), config)
1272 .expect("the stub builds");
1273 ws.install_db_for_test(
1274 crate::store::Db::open(&store.path().join("db.redb")).expect("the store opens"),
1275 );
1276 Fixture { ws, updates, _models: models, _store: store }
1277 }
1278
1279 fn settings_of(ws: &Workspace) -> DictateSettings {
1281 ws.config_arc().dictate.clone()
1282 }
1283
1284 fn record_installed_model(ws: &Workspace, variant: &str) {
1286 ws.record_installed(&InstalledModel {
1287 variant: variant.to_owned(),
1288 file: format!("{variant}-Q4_K_M.gguf"),
1289 url: format!("https://weights.invalid/{variant}-Q4_K_M.gguf"),
1290 size: 6,
1291 facts: ModelFacts { quant: Some("Q4_K_M".to_owned()), ..ModelFacts::default() },
1292 at: "2026-10-06T00:00:00Z".to_owned(),
1293 });
1294 }
1295
1296 #[tokio::test]
1301 async fn a_model_is_removed_by_file_and_refused_while_a_role_runs_it() {
1302 let Fixture { ws, mut updates, _models, .. } = fixture();
1303 record_installed_model(&ws, "spare");
1304 record_installed_model(&ws, "ruling");
1305
1306 let dir = ws.config_arc().dictate.models_dir().expect("the fixture sets one");
1308 std::fs::write(dir.join("spare-Q4_K_M.gguf"), b"six!!!").unwrap();
1309
1310 ws.dictate.set_active(&[(
1313 DictateRole::Normalization,
1314 crate::install::ActiveModel {
1315 role: DictateRole::Normalization,
1316 spec: forge_dictate::ModelSpec {
1317 file: "ruling-Q4_K_M.gguf".to_owned(),
1318 ..forge_dictate::ModelSpec::cohere_transcribe_q4_k_m()
1319 },
1320 from: crate::install::ActiveFrom::Pin,
1321 at: None,
1322 },
1323 )]);
1324 let err = ws
1325 .dispatch(Command::DictateUninstall { file: "ruling-Q4_K_M.gguf".to_owned() })
1326 .expect_err("a role is running it");
1327 assert!(
1328 matches!(&err, DispatchError::ModelInUse { role } if *role == DictateRole::Normalization),
1329 "got: {err:?}"
1330 );
1331
1332 ws.dispatch(Command::DictateUninstall { file: "spare-Q4_K_M.gguf".to_owned() })
1333 .expect("nothing runs it");
1334 assert!(!dir.join("spare-Q4_K_M.gguf").exists(), "the file went with the record");
1335 let rows = ws.installed_models();
1336 assert_eq!(rows.len(), 1, "the record went with the file");
1337 assert_eq!(rows[0].file, "ruling-Q4_K_M.gguf");
1338 let last = {
1339 let mut last = None;
1340 while let Ok(frame) = updates.try_recv() {
1341 if let SessionUpdate::DictateModelsChanged { models } =
1342 std::sync::Arc::unwrap_or_clone(frame.update)
1343 {
1344 last = Some(models);
1345 }
1346 }
1347 last
1348 };
1349 assert_eq!(
1350 last.map(|models| models.installed.len()),
1351 Some(1),
1352 "and the push carries the read without it"
1353 );
1354 }
1355
1356 #[tokio::test]
1361 async fn a_removal_names_a_recorded_file_or_is_refused() {
1362 let Fixture { ws, .. } = fixture();
1363 record_installed_model(&ws, "spare");
1364
1365 for invented in ["../../Documents/notes.md", "ruling-Q4_K_M.gguf"] {
1366 let err = ws
1367 .dispatch(Command::DictateUninstall { file: invented.to_owned() })
1368 .expect_err("nothing recorded carries these bytes");
1369 assert!(matches!(&err, DispatchError::UninstallRefused { .. }), "got: {err:?}");
1370 }
1371 assert_eq!(ws.installed_models().len(), 1, "and nothing went with the refusal");
1372 }
1373
1374 #[tokio::test]
1379 async fn a_removal_is_refused_while_the_file_is_in_flight() {
1380 let Fixture { ws, .. } = fixture();
1381 record_installed_model(&ws, "spare");
1382 let file = "spare-Q4_K_M.gguf";
1383
1384 *ws.dictate_bench.lock() = BenchState::Running {
1385 target: BenchTarget {
1386 file: file.to_owned(),
1387 role: BenchRole::Transcribing,
1388 pinned: false,
1389 },
1390 tier: forge_dictate::bench::Tier::Consensus,
1391 clip: 1,
1392 clips: 12,
1393 so_far: None,
1394 };
1395 let err = ws
1396 .dispatch(Command::DictateUninstall { file: file.to_owned() })
1397 .expect_err("the bench is scoring it right now");
1398 assert!(
1399 matches!(&err, DispatchError::UninstallRefused { reason } if reason.contains("scoring")),
1400 "got: {err:?}"
1401 );
1402 *ws.dictate_bench.lock() = BenchState::Idle;
1403
1404 *ws.dictate_install.lock() =
1405 InstallState::Downloading { file: file.to_owned(), got: 3, total: 6 };
1406 let err = ws
1407 .dispatch(Command::DictateUninstall { file: file.to_owned() })
1408 .expect_err("the download is writing it");
1409 assert!(
1410 matches!(&err, DispatchError::UninstallRefused { reason } if reason.contains("downloaded")),
1411 "got: {err:?}"
1412 );
1413 assert_eq!(ws.installed_models().len(), 1, "both refusals left the record alone");
1414 }
1415
1416 #[tokio::test]
1420 async fn a_pick_without_a_record_resolves_to_the_pin() {
1421 let Fixture { ws, .. } = fixture();
1422 record_active(&ws, DictateRole::Transcribing, "gone");
1423
1424 let settings = settings_of(&ws);
1425 let resolved = ws.resolve_active(&settings).await.expect("a stranded pick still boots");
1426 let (role, model) =
1427 resolved.iter().find(|(role, _)| *role == DictateRole::Transcribing).expect("resolved");
1428 assert_eq!(*role, DictateRole::Transcribing);
1429 assert!(matches!(model.from, crate::install::ActiveFrom::Pin), "got: {:?}", model.from);
1430 }
1431
1432 #[test]
1435 fn a_failed_install_takes_back_only_what_it_created() {
1436 let dir = tempfile::tempdir().expect("a temp dir");
1437 let created = dir.path().join("model_quantized.onnx");
1438 let kept = dir.path().join("af_heart.bin");
1439 std::fs::write(&created, b"new").expect("written");
1440 std::fs::write(&kept, b"old").expect("written");
1441
1442 remove_written(&[(created.clone(), false), (kept.clone(), true)]);
1443
1444 assert!(!created.exists(), "the model this run wrote goes");
1445 assert!(kept.exists(), "a voice an earlier install wrote stays");
1446 }
1447
1448 #[test]
1451 fn a_speaking_pick_beats_the_pin_and_clearing_it_returns_to_it() {
1452 let Fixture { ws, .. } = fixture_built(|config| config.tts.enabled = true);
1453 assert_eq!(ws.speaking_variant().0.variant, crate::tts::PINNED_VARIANT);
1454 assert!(matches!(ws.speaking_variant().1, crate::tts::SpeakingFrom::Pin));
1455
1456 record_active(&ws, DictateRole::Speaking, "kokoro-82m-fp16");
1457 assert_eq!(ws.speaking_variant().0.variant, "kokoro-82m-fp16");
1458 assert!(matches!(ws.speaking_variant().1, crate::tts::SpeakingFrom::Installed));
1459
1460 ws.clear_pick(DictateRole::Speaking);
1461 assert_eq!(ws.speaking_variant().0.variant, crate::tts::PINNED_VARIANT);
1462 }
1463
1464 #[test]
1468 fn an_installed_speaking_variant_resolves_to_its_files_with_every_digest() {
1469 let Fixture { ws, .. } = fixture_built(|config| config.tts.enabled = true);
1470 let install = crate::tts::SpeakingInstall {
1471 variant: crate::tts::PINNED_VARIANT.to_owned(),
1472 revision: "rev".to_owned(),
1473 model: "model_quantized.onnx".to_owned(),
1474 model_size: 7,
1475 model_sha256: "aa".repeat(32),
1476 voices: vec![
1477 crate::tts::VoiceFile {
1478 name: "af_heart".to_owned(),
1479 size: 5,
1480 sha256: "bb".repeat(32),
1481 },
1482 crate::tts::VoiceFile {
1483 name: "bf_emma".to_owned(),
1484 size: 5,
1485 sha256: "cc".repeat(32),
1486 },
1487 ],
1488 at: "2026-11-02T00:00:00Z".to_owned(),
1489 };
1490 assert!(ws.record_speaking_install(&install), "the fixture's store records");
1491
1492 let files = ws.speaking_files().expect("the installed variant resolves");
1493 assert_eq!(files.digests.len(), 3, "the model and both voices carry a digest");
1494 assert!(
1495 files.digests.iter().any(|(path, digest)| path.ends_with("voices/af_heart.bin")
1496 && digest == &"bb".repeat(32)),
1497 "a voice's own digest rides with its file",
1498 );
1499 }
1500
1501 #[tokio::test]
1506 async fn a_removal_clears_the_pick_that_named_it() {
1507 let Fixture { ws, .. } = fixture();
1508 record_installed_model(&ws, "spare");
1509 record_active(&ws, DictateRole::Normalization, "spare");
1510
1511 ws.dispatch(Command::DictateUninstall { file: "spare-Q4_K_M.gguf".to_owned() })
1512 .expect("nothing runs it");
1513
1514 let active = {
1515 let db = ws.db.lock();
1516 crate::store::dictate_models::active(
1517 db.as_ref().expect("the fixture installs a store"),
1518 DictateRole::Normalization,
1519 )
1520 .expect("the store answers")
1521 };
1522 assert!(active.is_none(), "the pick went with the record it named");
1523 }
1524
1525 fn record_active(ws: &Workspace, role: DictateRole, variant: &str) {
1527 let db = ws.db.lock();
1528 crate::store::dictate_models::record_active(
1529 db.as_ref().expect("the fixture installs a store"),
1530 role,
1531 &ActiveChoice { variant: variant.to_owned(), at: "2026-10-06T01:00:00Z".to_owned() },
1532 )
1533 .expect("the pick records");
1534 }
1535
1536 fn role_model(resolved: &[(DictateRole, ActiveModel)], role: DictateRole) -> &ActiveModel {
1538 &resolved.iter().find(|(resolved, _)| *resolved == role).expect("the role resolves").1
1539 }
1540
1541 #[tokio::test]
1544 async fn a_config_key_beats_the_record_and_the_pin() {
1545 let fixture = fixture_with(Some("keyed"), None);
1546 record_installed_model(&fixture.ws, "keyed");
1547 record_installed_model(&fixture.ws, "picked");
1548 record_active(&fixture.ws, DictateRole::Transcribing, "picked");
1549 let settings = settings_of(&fixture.ws);
1550
1551 let resolved = fixture.ws.resolve_active(&settings).await.expect("the key resolves");
1552
1553 let transcribing = role_model(&resolved, DictateRole::Transcribing);
1554 assert_eq!(transcribing.spec.file, "keyed-Q4_K_M.gguf", "the key's variant, not the pick");
1555 assert_eq!(
1556 transcribing.from,
1557 ActiveFrom::Config { key: "transcribe_model".to_owned(), variant: "keyed".to_owned() },
1558 "and the page can name the key that holds the role"
1559 );
1560 let normalizer = role_model(&resolved, DictateRole::Normalization);
1562 assert_eq!(
1563 normalizer.spec.file,
1564 forge_dictate::ModelSpec::s1_mini_f16().file,
1565 "the cleanup role still runs the compiled pin",
1566 );
1567 assert_eq!(normalizer.from, ActiveFrom::Pin);
1568 }
1569
1570 #[tokio::test]
1573 async fn without_a_key_the_record_beats_the_pin() {
1574 let fixture = fixture_with(None, None);
1575 record_installed_model(&fixture.ws, "picked");
1576 record_active(&fixture.ws, DictateRole::Transcribing, "picked");
1577 let settings = settings_of(&fixture.ws);
1578
1579 let resolved = fixture.ws.resolve_active(&settings).await.expect("the pick resolves");
1580
1581 let transcribing = role_model(&resolved, DictateRole::Transcribing);
1582 assert_eq!(transcribing.spec.file, "picked-Q4_K_M.gguf");
1583 assert_eq!(transcribing.spec.facts.quant.as_deref(), Some("Q4_K_M"));
1584 assert_eq!(transcribing.from, ActiveFrom::Installed { variant: "picked".to_owned() });
1585 assert_eq!(transcribing.at.as_deref(), Some("2026-10-06T01:00:00Z"));
1586 }
1587
1588 #[tokio::test]
1590 async fn without_a_key_or_a_record_the_pin_stands() {
1591 let fixture = fixture_with(None, None);
1592 let settings = settings_of(&fixture.ws);
1593
1594 let resolved = fixture.ws.resolve_active(&settings).await.expect("the pins resolve");
1595
1596 let transcribing = role_model(&resolved, DictateRole::Transcribing);
1597 assert_eq!(transcribing.spec.file, "cohere-transcribe-03-2026-Q4_K_M.gguf");
1598 assert_eq!(transcribing.from, ActiveFrom::Pin);
1599 assert_eq!(transcribing.at, None);
1600 assert_eq!(role_model(&resolved, DictateRole::Normalization).spec.file, "s1-mini-f16.gguf");
1601 }
1602
1603 #[tokio::test]
1606 async fn setting_a_key_leaves_the_record_alone_so_unpinning_returns_to_it() {
1607 let fixture = fixture_with(Some("keyed"), None);
1608 record_installed_model(&fixture.ws, "keyed");
1609 record_installed_model(&fixture.ws, "picked");
1610 record_active(&fixture.ws, DictateRole::Transcribing, "picked");
1611 let settings = settings_of(&fixture.ws);
1612
1613 let pinned = fixture.ws.resolve_active(&settings).await.expect("the key resolves");
1614 assert_eq!(role_model(&pinned, DictateRole::Transcribing).spec.file, "keyed-Q4_K_M.gguf");
1615
1616 let unpinned = DictateSettings { transcribe_model: None, ..settings };
1617 let resolved = fixture.ws.resolve_active(&unpinned).await.expect("the pick resolves");
1618
1619 let transcribing = role_model(&resolved, DictateRole::Transcribing);
1620 assert_eq!(
1621 transcribing.spec.file, "picked-Q4_K_M.gguf",
1622 "unpinning returns the role to the last runtime pick, not the pin"
1623 );
1624 assert_eq!(transcribing.from, ActiveFrom::Installed { variant: "picked".to_owned() });
1625 }
1626
1627 #[tokio::test]
1631 async fn a_key_naming_a_variant_that_resolves_to_nothing_fails_boot_by_name() {
1632 let fixture = fixture_with(Some("ghost"), None);
1633 let base = serve(vec![("/catalog", 200, b"[]".to_vec())]);
1634 *fixture.ws.test_catalogue_source.lock() = Some(source(&base));
1635 let settings = settings_of(&fixture.ws);
1636
1637 let err =
1638 fixture.ws.resolve_active(&settings).await.expect_err("nothing names the variant");
1639
1640 assert!(
1641 err.contains("[dictate] transcribe_model"),
1642 "the error names the key that cannot be answered, got: {err}"
1643 );
1644 assert!(err.contains("ghost"), "and the variant it names, got: {err}");
1645 }
1646
1647 #[tokio::test]
1651 async fn a_key_resolved_variant_is_recorded_as_installed_once() {
1652 let fixture = fixture_with(None, None);
1653 let resolved = vec![(
1654 DictateRole::Transcribing,
1655 ActiveModel {
1656 role: DictateRole::Transcribing,
1657 spec: forge_dictate::spec_for_download(
1658 "granite-Q4_K_M.gguf",
1659 "https://weights.invalid/granite-Q4_K_M.gguf",
1660 6,
1661 None,
1662 ModelFacts { quant: Some("Q4_K_M".to_owned()), ..ModelFacts::default() },
1663 ),
1664 from: ActiveFrom::Config {
1665 key: "transcribe_model".to_owned(),
1666 variant: "granite".to_owned(),
1667 },
1668 at: None,
1669 },
1670 )];
1671
1672 fixture.ws.record_resolved_installs(&resolved);
1673 fixture.ws.record_resolved_installs(&resolved);
1674
1675 let installed = fixture.ws.installed_models();
1676 assert_eq!(installed.len(), 1, "one record per variant, however many boots fetch it");
1677 assert_eq!(installed[0].variant, "granite");
1678 assert_eq!(installed[0].file, "granite-Q4_K_M.gguf");
1679 assert_eq!(installed[0].url, "https://weights.invalid/granite-Q4_K_M.gguf");
1680 assert_eq!(installed[0].facts.quant.as_deref(), Some("Q4_K_M"));
1681 }
1682
1683 #[tokio::test]
1687 async fn a_runtime_pick_is_not_recorded_again() {
1688 let fixture = fixture_with(None, None);
1689 record_installed_model(&fixture.ws, "picked");
1690 let resolved = vec![(
1691 DictateRole::Transcribing,
1692 ActiveModel {
1693 role: DictateRole::Transcribing,
1694 spec: forge_dictate::ModelSpec::cohere_transcribe_q4_k_m(),
1695 from: ActiveFrom::Installed { variant: "picked".to_owned() },
1696 at: Some("2026-10-06T01:00:00Z".to_owned()),
1697 },
1698 )];
1699
1700 fixture.ws.record_resolved_installs(&resolved);
1701
1702 let installed = fixture.ws.installed_models();
1703 assert_eq!(installed.len(), 1);
1704 assert_eq!(installed[0].file, "picked-Q4_K_M.gguf", "the record the pick came from");
1705 }
1706
1707 async fn await_models(
1712 updates: &mut tokio::sync::mpsc::UnboundedReceiver<crate::SeatUpdate>,
1713 ) -> DictateModelsSnapshot {
1714 tokio::time::timeout(std::time::Duration::from_secs(15), async {
1715 loop {
1716 match updates
1717 .recv()
1718 .await
1719 .map(|frame| std::sync::Arc::unwrap_or_clone(frame.update))
1720 {
1721 Some(SessionUpdate::DictateModelsChanged { models }) => {
1722 let running = matches!(models.install, InstallState::Downloading { .. })
1723 || matches!(models.activate, ActivateState::Activating { .. });
1724 if !running {
1725 break models;
1726 }
1727 }
1728 Some(_) => {}
1729 None => panic!("the subscription must stay attached"),
1730 }
1731 }
1732 })
1733 .await
1734 .expect("the install must land inside fifteen seconds")
1735 }
1736
1737 #[tokio::test]
1741 async fn an_install_lands_the_file_and_records_it() {
1742 let Fixture { ws, mut updates, .. } = fixture();
1743 ws.dictate_catalogue.lock().catalogue = Some(catalogue_of(vec![entry_under(
1744 "candidate",
1745 r#"["en"]"#,
1746 300.0,
1747 4.0,
1748 6,
1749 "mit",
1750 "MIT",
1751 )]));
1752 let file = "candidate-Q4_K_M.gguf";
1753 let base = serve_with(|base| {
1754 vec![
1755 ("/docs/candidate.md".to_owned(), 200, doc_body(base, "candidate", file)),
1756 (format!("/weights/{file}"), 200, b"111111".to_vec()),
1757 ]
1758 });
1759 *ws.test_catalogue_source.lock() = Some(source(&base));
1760
1761 ws.dispatch(Command::DictateInstall { variant: "candidate".to_owned() })
1762 .expect("the install dispatches");
1763
1764 let landed = await_models(&mut updates).await;
1765 assert!(
1766 matches!(landed.install, InstallState::Idle),
1767 "a clean install lands back at idle, got {:?}",
1768 landed.install
1769 );
1770 assert_eq!(landed.installed.len(), 1, "one file, one record");
1771 let record = &landed.installed[0];
1772 assert_eq!(record.variant, "candidate");
1773 assert_eq!(record.file, file);
1774 assert_eq!(record.url, format!("{base}/weights/{file}"), "the doc's own URL, kept");
1775 assert_eq!(record.size, 6);
1776 assert!(!record.at.is_empty(), "the record carries when it landed");
1777 assert_eq!(ws.installed_models().len(), 1, "and it reads back after the push");
1778 }
1779
1780 #[tokio::test]
1783 async fn an_install_whose_bytes_disagree_with_the_entry_lands_failed_and_records_nothing() {
1784 let Fixture { ws, mut updates, .. } = fixture();
1785 ws.dictate_catalogue.lock().catalogue = Some(catalogue_of(vec![entry_under(
1786 "candidate",
1787 r#"["en"]"#,
1788 300.0,
1789 4.0,
1790 6,
1791 "mit",
1792 "MIT",
1793 )]));
1794 let file = "candidate-Q4_K_M.gguf";
1795 let base = serve_with(|base| {
1796 vec![
1797 ("/docs/candidate.md".to_owned(), 200, doc_body(base, "candidate", file)),
1798 (format!("/weights/{file}"), 200, b"11111".to_vec()),
1799 ]
1800 });
1801 *ws.test_catalogue_source.lock() = Some(source(&base));
1802
1803 ws.dispatch(Command::DictateInstall { variant: "candidate".to_owned() })
1804 .expect("the install dispatches");
1805
1806 let landed = await_models(&mut updates).await;
1807 let InstallState::Failed { file: failed, reason } = &landed.install else {
1808 panic!("a size disagreement must land failed, got {:?}", landed.install);
1809 };
1810 assert_eq!(failed, file);
1811 assert!(
1812 reason.contains("5 bytes") && reason.contains("expected 6"),
1813 "the reason names both lengths, got: {reason}"
1814 );
1815 assert!(landed.installed.is_empty(), "nothing is recorded for a file that failed");
1816 assert!(ws.installed_models().is_empty());
1817 }
1818
1819 #[tokio::test]
1823 async fn an_install_whose_doc_names_no_download_lands_failed() {
1824 let Fixture { ws, mut updates, .. } = fixture();
1825 ws.dictate_catalogue.lock().catalogue = Some(catalogue_of(vec![entry_under(
1826 "candidate",
1827 r#"["en"]"#,
1828 300.0,
1829 4.0,
1830 6,
1831 "mit",
1832 "MIT",
1833 )]));
1834 let file = "candidate-Q4_K_M.gguf";
1835 let base = serve_with(|_| {
1836 vec![(String::from("/docs/candidate.md"), 200, b"# candidate\n\nprose only\n".to_vec())]
1837 });
1838 *ws.test_catalogue_source.lock() = Some(source(&base));
1839
1840 ws.dispatch(Command::DictateInstall { variant: "candidate".to_owned() })
1841 .expect("the install dispatches");
1842
1843 let landed = await_models(&mut updates).await;
1844 let InstallState::Failed { file: failed, reason } = &landed.install else {
1845 panic!("a doc without the file must land failed, got {:?}", landed.install);
1846 };
1847 assert_eq!(failed, file, "the file the entry names, so the page says which one stopped");
1848 assert!(
1849 reason.contains("no download"),
1850 "the reason names the step that stopped, got: {reason}"
1851 );
1852 assert!(ws.installed_models().is_empty());
1853 }
1854
1855 #[tokio::test]
1863 async fn an_install_reads_the_link_from_the_readme_when_the_doc_is_missing() {
1864 let Fixture { ws, mut updates, .. } = fixture();
1865 ws.dictate_catalogue.lock().catalogue = Some(catalogue_of(vec![
1866 forge_dictate::catalogue::parse_entry(
1867 r#"{"schema":"transcribe-catalog-v1","variant":"moonshine-base-ar",
1868 "published_repo":"handy-computer/moonshine-base-ar-gguf",
1869 "downloads":[{"quant":"Q8_0","filename":"moonshine-base-ar-Q8_0.gguf","size_bytes":6}]}"#,
1870 )
1871 .expect("the synthetic entry parses"),
1872 ]));
1873 let file = "moonshine-base-ar-Q8_0.gguf";
1874 let base = serve_with(|base| {
1875 vec |\n"
1884 )
1885 .into_bytes(),
1886 ),
1887 (format!("/weights/{file}"), 200, b"111111".to_vec()),
1888 ]
1889 });
1890 *ws.test_catalogue_source.lock() = Some(source(&base));
1891
1892 ws.dispatch(Command::DictateInstall { variant: "moonshine-base-ar".to_owned() })
1893 .expect("the install dispatches");
1894
1895 let landed = await_models(&mut updates).await;
1896 assert!(matches!(landed.install, InstallState::Idle), "got {:?}", landed.install);
1897 assert_eq!(landed.installed.len(), 1);
1898 assert_eq!(landed.installed[0].file, file);
1899 assert_eq!(landed.installed[0].url, format!("{base}/weights/{file}"));
1900 }
1901 #[tokio::test]
1907 async fn a_hub_entry_installs_by_its_url_and_verifies_its_digest() {
1908 let body = b"hubbytes".to_vec();
1909 let good = "0c6bee82a304781cb9fefa1dda4bfa5f5b8be07d9f6f77918f2d96854ce5165e";
1910 let base = serve(vec![("/weights/norm-a-Q4_K_M.gguf", 200, body)]);
1911 let entry = |digest: &str| {
1912 forge_dictate::catalogue::parse_entry(&format!(
1913 r#"{{"schema":"transcribe-catalog-v1","variant":"owner/norm-a",
1914 "kind":"normalizer",
1915 "downloads":[{{"quant":"Q4_K_M","filename":"norm-a-Q4_K_M.gguf",
1916 "size_bytes":8,"sha256":"{digest}",
1917 "url":"{base}/weights/norm-a-Q4_K_M.gguf"}}]}}"#
1918 ))
1919 .expect("the synthetic entry parses")
1920 };
1921
1922 let held = entry(good);
1924 assert_eq!(
1925 held.downloads[0].url.as_deref(),
1926 Some(format!("{base}/weights/norm-a-Q4_K_M.gguf").as_str()),
1927 "the entry's own url rides its download"
1928 );
1929
1930 let Fixture { ws, mut updates, .. } = fixture();
1932 ws.dictate_catalogue.lock().catalogue = Some(catalogue_of(vec![held]));
1933 ws.dispatch(Command::DictateInstall { variant: "owner/norm-a".to_owned() })
1934 .expect("the install dispatches");
1935 let landed = await_models(&mut updates).await;
1936 assert!(matches!(landed.install, InstallState::Idle), "got {:?}", landed.install);
1937 assert_eq!(landed.installed.len(), 1, "a matching digest lands the file");
1938
1939 let Fixture { ws, mut updates, .. } = fixture();
1941 ws.dictate_catalogue.lock().catalogue = Some(catalogue_of(vec![entry(
1942 "0000000000000000000000000000000000000000000000000000000000000000",
1943 )]));
1944 ws.dispatch(Command::DictateInstall { variant: "owner/norm-a".to_owned() })
1945 .expect("the install dispatches");
1946 let landed = await_models(&mut updates).await;
1947 let InstallState::Failed { reason, .. } = &landed.install else {
1948 panic!("a wrong digest must fail the install, got {:?}", landed.install)
1949 };
1950 assert!(
1951 reason.contains("hashes to") && reason.contains("expected"),
1952 "the refusal names what disagreed, got: {reason}"
1953 );
1954 assert!(landed.installed.is_empty(), "and nothing was recorded");
1955 }
1956
1957 #[tokio::test]
1962 async fn a_store_that_cannot_record_takes_the_downloaded_bytes_back() {
1963 let (ws, mut updates, models) = enabled_stub();
1965 let body = b"hubbytes".to_vec();
1966 let base = serve(vec![("/weights/norm-a-Q4_K_M.gguf", 200, body)]);
1967 let entry = forge_dictate::catalogue::parse_entry(&format!(
1968 r#"{{"schema":"transcribe-catalog-v1","variant":"owner/norm-a",
1969 "kind":"normalizer",
1970 "downloads":[{{"quant":"Q4_K_M","filename":"norm-a-Q4_K_M.gguf",
1971 "size_bytes":8,
1972 "sha256":"0c6bee82a304781cb9fefa1dda4bfa5f5b8be07d9f6f77918f2d96854ce5165e",
1973 "url":"{base}/weights/norm-a-Q4_K_M.gguf"}}]}}"#
1974 ))
1975 .expect("the synthetic entry parses");
1976 ws.dictate_catalogue.lock().catalogue = Some(catalogue_of(vec![entry]));
1977 ws.dispatch(Command::DictateInstall { variant: "owner/norm-a".to_owned() })
1978 .expect("the install dispatches");
1979
1980 let landed = await_models(&mut updates).await;
1981 let InstallState::Failed { file, reason } = &landed.install else {
1982 panic!("an unrecordable download must fail the install, got {:?}", landed.install)
1983 };
1984 assert_eq!(file, "norm-a-Q4_K_M.gguf", "the failure names the file");
1985 assert!(reason.contains("could not be recorded"), "got: {reason}");
1986 assert!(landed.installed.is_empty(), "nothing was recorded");
1987 assert!(
1988 !models.path().join("norm-a-Q4_K_M.gguf").exists(),
1989 "the unrecordable download left its bytes behind"
1990 );
1991 }
1992
1993 #[test]
1996 fn an_install_refuses_by_name_with_dictation_off() {
1997 let (ws, _updates) = Workspace::testing_stub();
1998
1999 let err = ws
2000 .dispatch(Command::DictateInstall { variant: "candidate".to_owned() })
2001 .expect_err("no dictation, no install");
2002
2003 assert!(matches!(err, DispatchError::DictateOff), "got: {err:?}");
2004 }
2005
2006 #[test]
2009 fn an_install_while_one_is_downloading_is_refused() {
2010 let (ws, _updates, _models) = enabled_stub();
2011 let in_flight =
2012 InstallState::Downloading { file: "half.gguf".to_owned(), got: 1, total: 6 };
2013 *ws.dictate_install.lock() = in_flight.clone();
2014
2015 let err = ws
2016 .dispatch(Command::DictateInstall { variant: "candidate".to_owned() })
2017 .expect_err("a second install must be refused");
2018
2019 assert!(matches!(err, DispatchError::Installing), "got: {err:?}");
2020 assert_eq!(ws.dictate_install(), in_flight, "the refusal leaves the install in flight");
2021 }
2022
2023 #[tokio::test]
2027 async fn reinstalling_the_same_file_replaces_its_record() {
2028 let Fixture { ws, mut updates, .. } = fixture();
2029 ws.dictate_catalogue.lock().catalogue = Some(catalogue_of(vec![entry_under(
2030 "candidate",
2031 r#"["en"]"#,
2032 300.0,
2033 4.0,
2034 6,
2035 "mit",
2036 "MIT",
2037 )]));
2038 let file = "candidate-Q4_K_M.gguf";
2039 let base = serve_with(|base| {
2040 vec![
2041 ("/docs/candidate.md".to_owned(), 200, doc_body(base, "candidate", file)),
2042 (format!("/weights/{file}"), 200, b"111111".to_vec()),
2043 ]
2044 });
2045 *ws.test_catalogue_source.lock() = Some(source(&base));
2046
2047 let install = || Command::DictateInstall { variant: "candidate".to_owned() };
2048
2049 ws.dispatch(install()).expect("the first install dispatches");
2050 let first = await_models(&mut updates).await;
2051 assert_eq!(first.installed.len(), 1);
2052
2053 ws.dispatch(install()).expect("the second install dispatches");
2054 let second = await_models(&mut updates).await;
2055
2056 assert_eq!(second.installed.len(), 1, "the record is replaced, not duplicated");
2057 assert_eq!(second.installed[0].file, file);
2058 }
2059
2060 #[tokio::test]
2065 async fn an_install_pushes_its_progress_while_it_downloads() {
2066 let mut fixture = fixture();
2067 fixture.ws.dictate_catalogue.lock().catalogue = Some(catalogue_of(vec![entry_under(
2068 "candidate",
2069 r#"["en"]"#,
2070 300.0,
2071 4.0,
2072 6,
2073 "mit",
2074 "MIT",
2075 )]));
2076 let file = "candidate-Q4_K_M.gguf";
2077 let base = serve_with(|base| {
2078 vec![
2079 ("/docs/candidate.md".to_owned(), 200, doc_body(base, "candidate", file)),
2080 (format!("/weights/{file}"), 200, b"111111".to_vec()),
2081 ]
2082 });
2083 *fixture.ws.test_catalogue_source.lock() = Some(source(&base));
2084
2085 fixture
2086 .ws
2087 .dispatch(Command::DictateInstall { variant: "candidate".to_owned() })
2088 .expect("the install dispatches");
2089
2090 let mut downloading = false;
2091 let mut named = 0;
2092 let mut moved = false;
2093 let mut finished = false;
2094 tokio::time::timeout(std::time::Duration::from_secs(15), async {
2095 while let Some(frame) = fixture.updates.recv().await {
2096 let SessionUpdate::DictateModelsChanged { models } =
2097 std::sync::Arc::unwrap_or_clone(frame.update)
2098 else {
2099 continue;
2100 };
2101 match &models.install {
2102 InstallState::Downloading { file: seen, got, .. } => {
2103 downloading = true;
2104 if *got > 0 {
2109 moved = true;
2110 }
2111 if seen == file {
2112 named += 1;
2113 }
2114 }
2115 InstallState::Idle => {
2116 finished = true;
2117 break;
2118 }
2119 InstallState::Failed { .. } => break,
2120 }
2121 }
2122 })
2123 .await
2124 .expect("the install must land inside fifteen seconds");
2125
2126 assert!(downloading, "no frame carried the download while it ran");
2127 assert!(named > 0, "the frames must name the file they are about");
2128 assert!(moved, "no frame carried bytes moving");
2129 assert!(finished, "the install must end in a frame");
2130 }
2131
2132 fn write_unloadable_model(ws: &Workspace, file: &str) {
2135 let path = ws.config_arc().dictate.models_dir().expect("the fixture sets one").join(file);
2136 std::fs::write(&path, b"these bytes are not a model")
2137 .unwrap_or_else(|error| panic!("write {path:?}: {error}"));
2138 }
2139
2140 #[tokio::test]
2143 async fn an_activation_that_cannot_load_leaves_the_previous_engine_in_place() {
2144 let mut fixture = fixture_built(|config| config.dictate.normalizer = false);
2150 record_installed_model(&fixture.ws, "broken");
2151 write_unloadable_model(&fixture.ws, "broken-Q4_K_M.gguf");
2152
2153 fixture
2154 .ws
2155 .dispatch(Command::DictateActivate {
2156 role: DictateRole::Transcribing,
2157 file: "broken-Q4_K_M.gguf".to_owned(),
2158 })
2159 .expect("the activation dispatches");
2160
2161 let landed = await_models(&mut fixture.updates).await;
2162 let ActivateState::Failed { role, file, reason } = &landed.activate else {
2163 panic!("an unloadable model must land failed, got {:?}", landed.activate);
2164 };
2165 assert_eq!(*role, DictateRole::Transcribing);
2166 assert_eq!(file, "broken-Q4_K_M.gguf");
2167 assert!(
2168 reason.contains("broken-Q4_K_M.gguf"),
2169 "the failure must be about the model that was activated, got: {reason}"
2170 );
2171 assert!(fixture.ws.dictate.engine.lock().is_none(), "nothing was swapped in");
2172 assert_eq!(
2173 role_model(&fixture.ws.active_models(), DictateRole::Transcribing).from,
2174 ActiveFrom::Pin,
2175 "and the role still answers what it did before"
2176 );
2177 }
2178
2179 #[tokio::test]
2182 async fn an_activation_refuses_while_a_take_is_live() {
2183 let fixture = fixture_with(None, None);
2184 record_installed_model(&fixture.ws, "candidate");
2185 let (stop, _stop_rx) = tokio::sync::mpsc::channel(1);
2186 fixture.ws.dictate_runtime.lock().recordings.insert(
2187 crate::SessionSlot::new("Busytools", "forge", "worker"),
2188 crate::dictate::LiveRecording { stop, sink: None, initiator: None },
2189 );
2190
2191 let err = fixture
2192 .ws
2193 .dispatch(Command::DictateActivate {
2194 role: DictateRole::Transcribing,
2195 file: "candidate-Q4_K_M.gguf".to_owned(),
2196 })
2197 .expect_err("a live take refuses the swap");
2198
2199 assert!(
2200 matches!(&err, DispatchError::TakeLive { holder } if holder == "Busytools/forge/worker"),
2201 "the refusal names the seat whose take is live, got: {err:?}"
2202 );
2203 assert_eq!(fixture.ws.dictate_activate(), ActivateState::Idle, "and nothing started");
2204 }
2205
2206 #[tokio::test]
2209 async fn a_failed_activation_leaves_the_record_and_the_running_model_alone() {
2210 let mut fixture = fixture_built(|config| config.dictate.normalizer = false);
2211 record_installed_model(&fixture.ws, "broken");
2212 record_installed_model(&fixture.ws, "picked");
2213 record_active(&fixture.ws, DictateRole::Transcribing, "picked");
2214 write_unloadable_model(&fixture.ws, "broken-Q4_K_M.gguf");
2215
2216 fixture
2217 .ws
2218 .dispatch(Command::DictateActivate {
2219 role: DictateRole::Transcribing,
2220 file: "broken-Q4_K_M.gguf".to_owned(),
2221 })
2222 .expect("the activation dispatches");
2223 let landed = await_models(&mut fixture.updates).await;
2224 assert!(
2225 matches!(landed.activate, ActivateState::Failed { .. }),
2226 "got {:?}",
2227 landed.activate
2228 );
2229
2230 let resolved =
2231 fixture.ws.resolve_active(&settings_of(&fixture.ws)).await.expect("the pick resolves");
2232 assert_eq!(
2233 role_model(&resolved, DictateRole::Transcribing).spec.file,
2234 "picked-Q4_K_M.gguf",
2235 "a restart still answers the model the machine was running"
2236 );
2237 }
2238
2239 #[tokio::test]
2242 async fn an_activation_on_a_pinned_role_is_refused_by_name() {
2243 let fixture = fixture_with(Some("keyed"), None);
2244 record_installed_model(&fixture.ws, "keyed");
2245
2246 let err = fixture
2247 .ws
2248 .dispatch(Command::DictateActivate {
2249 role: DictateRole::Transcribing,
2250 file: "keyed-Q4_K_M.gguf".to_owned(),
2251 })
2252 .expect_err("forge.toml pins the role");
2253
2254 assert!(
2255 matches!(&err, DispatchError::PinnedRole { key, .. } if key == "transcribe_model"),
2256 "the refusal names the [dictate] key, got: {err:?}"
2257 );
2258 }
2259
2260 #[tokio::test]
2264 async fn a_deactivation_whose_swap_fails_keeps_the_pick() {
2265 let mut fixture = fixture_built(|config| config.dictate.normalizer = false);
2266 record_installed_model(&fixture.ws, "picked");
2267 record_active(&fixture.ws, DictateRole::Transcribing, "picked");
2268 fixture.ws.dictate.set_active(&[(
2270 DictateRole::Transcribing,
2271 ActiveModel {
2272 role: DictateRole::Transcribing,
2273 spec: ModelSpec {
2274 file: "picked-Q4_K_M.gguf".to_owned(),
2275 url: "https://weights.invalid/picked-Q4_K_M.gguf".to_owned(),
2276 size: 6,
2277 sha256: None,
2278 facts: ModelFacts { quant: Some("Q4_K_M".to_owned()), ..ModelFacts::default() },
2279 },
2280 from: ActiveFrom::Installed { variant: "picked".to_owned() },
2281 at: Some("2026-10-06T01:00:00Z".to_owned()),
2282 },
2283 )]);
2284
2285 fixture
2286 .ws
2287 .dispatch(Command::DictateDeactivate { role: DictateRole::Transcribing })
2288 .expect("the deactivation dispatches");
2289
2290 let landed = await_models(&mut fixture.updates).await;
2291 assert!(
2292 matches!(landed.activate, ActivateState::Failed { .. }),
2293 "got {:?}",
2294 landed.activate
2295 );
2296 let db = fixture.ws.db.lock();
2297 let choice = crate::store::dictate_models::active(
2298 db.as_ref().expect("the fixture installs a store"),
2299 DictateRole::Transcribing,
2300 )
2301 .expect("the store reads");
2302 assert_eq!(
2303 choice.map(|choice| choice.variant),
2304 Some("picked".to_owned()),
2305 "the pick is dropped only once the swap lands"
2306 );
2307 }
2308}