Skip to main content

forge_workspace/
install.rs

1//! Installing a model from the feed, and resolving which model each role
2//! runs: the doc's own download link, a size-checked fetch into the models
3//! directory, the record of what arrived, and the config-key / runtime-pick
4//! / compiled-pin order an active model comes from.
5//!
6//! **A download is checked against whatever the entry declares: its byte
7//! length always, and its digest when it carries one.** The speech feed's
8//! docs publish no digest, so those files are checked by length and by
9//! whether the engine can load them; the Hub's blobs publish a sha256, and
10//! the fetch verifies it once, at install - the record does not keep it,
11//! and nothing here may call a file verified.
12
13use 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/// Where an install is, as the page draws it.
26#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
27#[serde(tag = "state", rename_all = "snake_case")]
28pub enum InstallState {
29    #[default]
30    Idle,
31    /// `file` is named as soon as the doc says which file this is, so the
32    /// page can say what is being fetched rather than a blank bar.
33    Downloading {
34        file: String,
35        got: u64,
36        total: u64,
37    },
38    Failed {
39        file: String,
40        reason: String,
41    },
42}
43
44/// Where an activation is, as the page draws it.
45#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
46#[serde(tag = "state", rename_all = "snake_case")]
47pub enum ActivateState {
48    #[default]
49    Idle,
50    /// A new engine for `file` is building; the old one still serves until
51    /// the swap.
52    Activating { role: DictateRole, file: String },
53    /// The new engine could not load. The previous one still serves, and
54    /// the active record still names it.
55    Failed { role: DictateRole, file: String, reason: String },
56}
57
58/// One model this machine has downloaded from the feed.
59#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
60pub struct InstalledModel {
61    /// The feed's own verb for the model, which is what a config key names.
62    pub variant: String,
63    pub file: String,
64    /// The doc's own URL, kept so a reader can see where the bytes came from.
65    pub url: String,
66    pub size: u64,
67    /// The facts the entry declares, kept so a resolution off this record
68    /// can build the same spec the install did without the feed.
69    pub facts: ModelFacts,
70    /// RFC 3339.
71    pub at: String,
72}
73
74/// Where a role's model came from.
75#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
76#[serde(tag = "from", rename_all = "snake_case")]
77pub enum ActiveFrom {
78    /// `forge.toml` names this role's model, so the runtime cannot change
79    /// it; `key` is the `[dictate]` key that does.
80    Config { key: String, variant: String },
81    /// The last pick made on this machine.
82    Installed { variant: String },
83    /// The compiled default for this binary.
84    Pin,
85}
86
87/// The model one role runs, and where that choice came from.
88///
89/// Workspace side only: the wire carries `from`/`at` on the page's IN USE
90/// rows, which already carry every other field a spec has.
91#[derive(Debug, Clone, PartialEq)]
92pub struct ActiveModel {
93    pub role: DictateRole,
94    pub spec: ModelSpec,
95    pub from: ActiveFrom,
96    /// RFC 3339, when a runtime pick chose it.
97    pub at: Option<String>,
98}
99
100/// The runtime pick as one role records it: the variant, resolved through
101/// the installed set, and when the pick was made.
102#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
103pub struct ActiveChoice {
104    pub variant: String,
105    /// RFC 3339.
106    pub at: String,
107}
108
109/// The table a role's pinning key lives in.
110fn section_for(role: DictateRole) -> &'static str {
111    match role {
112        DictateRole::Speaking => "tts",
113        DictateRole::Transcribing | DictateRole::Normalization => "dictate",
114    }
115}
116
117/// The config key that pins one role.
118fn 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
126/// The spec an installed record names. The record keeps no digest, so the
127/// size is the identity a loader can check.
128fn 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
138/// What resolving one role answers: a model, or the feed lookup a config key
139/// needs.
140pub(crate) enum RoleResolution {
141    Chosen(ActiveModel),
142    /// A config key names a variant no install on this machine recorded, so
143    /// only the feed can turn it into a spec.
144    NeedsFeed {
145        key: String,
146        variant: String,
147    },
148}
149
150/// One role's model, from the config key when set, else the runtime pick,
151/// else the compiled pin.
152///
153/// The rows are passed in rather than read here, because the boot reads them
154/// from the store this process owns and a bench reads a copy of one it does
155/// not; both then answer the role the same way.
156pub(crate) fn resolve_role(
157    role: DictateRole,
158    pin: ModelSpec,
159    configured: Option<&str>,
160    installed: &[InstalledModel],
161    pick: Option<ActiveChoice>,
162) -> RoleResolution {
163    // A config key wins over everything, and it never clears the record:
164    // removing the key returns the role to the last runtime pick.
165    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            // A pick whose record is gone falls to the pin rather than
187            // refusing the boot: the pick is forge's own bookkeeping, so a
188            // removal that stranded one must not take the launchpad with it.
189            // The removal clears the pick it strands.
190            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    /// The install's state, for the page's read.
215    pub fn dictate_install(&self) -> InstallState {
216        self.dictate_install.lock().clone()
217    }
218
219    /// Every model downloaded from the feed on this machine, oldest first.
220    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            // A store that cannot be read is a list that would silently read
226            // as empty, which looks the same as a machine with nothing on it.
227            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    /// Remove one model this machine downloaded: the file and the record.
239    ///
240    /// **The file name has to be one this machine recorded**, because it
241    /// crosses from a page and joins onto the models directory: a name no
242    /// record carries is refused rather than removed, so `../../` and
243    /// anything else a caller invents names nothing.
244    ///
245    /// Refused while a role RUNS the file - the engine holds it loaded - and
246    /// named by that role, because the swap that frees it is the caller's
247    /// next step rather than this one's. **The record's own runtime pick goes
248    /// with it**: a pick left naming a file that is gone refuses the next
249    /// boot, and the page that removed the file is the one that can clear it.
250    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        // The engine holds the file for a RUNNING bench too, and a download
263        // in flight is writing it: removing either under the work is a file
264        // the bench or the install would then load from nowhere.
265        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    /// Install one feed variant: fetch its doc, take the quant the row
317    /// draws, and download that file into the models directory.
318    ///
319    /// The outcome rides [`SessionUpdate::DictateModelsChanged`], the same
320    /// way a catalogue check's does.
321    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    /// Take the install no further, naming the step that stopped.
353    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    /// Name the file while the doc is still being read, so the page's bar
363    /// has something to say from the first tick.
364    fn set_install_file(&self, file: &str, total: u64) {
365        self.note_download(file, 0, total);
366    }
367
368    /// Record a downloaded model, answering whether the store took it.
369    ///
370    /// `false` is a store that could not write, which is the caller's to act
371    /// on: a file nothing recorded is a file the page can neither show nor
372    /// remove, so the install path takes the bytes back rather than leaving
373    /// one behind.
374    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    /// The page's re-read, pushed the way a landed check pushes one.
399    pub(crate) fn push_models(&self) {
400        let models = self.dictate_models();
401        let _ = self.update_sender().send(SessionUpdate::DictateModelsChanged { models });
402    }
403
404    /// The feed source and the entry a variant names, when both are known.
405    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    /// Fetch, check and record one variant. Every failure lands as an
419    /// `InstallState::Failed` whose reason is its own, so the page says
420    /// which step stopped rather than that something did.
421    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        // The base is the `[dictate]` settings, with the candidate in the
440        // ASR slot and no normalizer: `prepare` fetches what the config
441        // names, and this config names one file.
442        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        // The file is named: the page's line can say what is being fetched
453        // before the first byte moves.
454        self.push_models();
455        let this = Arc::clone(&self);
456        let file = download.filename.clone();
457        let prepared = tokio::task::spawn_blocking(move || {
458            // The push rides the whole-percent change: a tick per progress
459            // event would re-encode the page's snapshot thousands of times
460            // over a 279 MB file, and a percent is what a reader sees move.
461            // The push itself matters as much as the throttle: a state the
462            // server records but never sends is a page that sits on `0 of 0`
463            // until the install is over.
464            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                // **A file nothing recorded goes back.** The page lists and
490                // removes by record, so an unrecorded file is one no control
491                // can reach - the install takes its bytes back rather than
492                // leaving litter behind it.
493                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    /// Install one speaking variant: read the Hub's current revision, take
508    /// the variant's ONNX file and every named voice at that revision, and
509    /// record the set only once every digest matched.
510    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        // Every file this run creates, so a failure part way through a
551        // multi-hundred-megabyte set leaves nothing behind. A file that was
552        // already here belongs to an earlier install and stays.
553        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    /// Record one speaking install, answering whether the store took it.
612    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
637/// Take back the files a failed install created, leaving files that were
638/// already on disk alone.
639fn 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
647/// Fetch one file of a speaking install, reporting the whole set's progress.
648async 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
673/// Build and load one engine off the runtime thread, answering it or the
674/// reason it could not serve.
675///
676/// Building before swapping is the order: a model that will not load
677/// leaves the previous engine serving rather than the role with nothing.
678async 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
689/// The spec one feed entry describes for the quant a row draws: where the
690/// file is, the entry's own byte length, the digest where its host publishes
691/// one, and the facts both carry.
692///
693/// The Hub's entries carry the URL and the digest themselves; the speech
694/// feed's carry neither, so its documents are read for the link - which is
695/// also where that feed's files' only verification (the byte length) comes
696/// from.
697async 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            // What loads the file: the speech models run on transcribe.cpp
733            // and a normalizer is llama.cpp's, which is the entry's own kind
734            // saying so rather than a guess about the feed.
735            runtime: Some(match entry.kind {
736                EntryKind::Asr => "transcribe.cpp".to_owned(),
737                EntryKind::Normalizer => "llama.cpp".to_owned(),
738            }),
739        },
740    ))
741}
742
743/// The download the row draws: the preferred quant chain, most wanted first.
744fn 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    /// The models the roles run, as the preflight resolved them: the
752    /// compiled pins until that resolution lands, then whatever the config
753    /// key, the runtime pick or the pin answered, per role.
754    pub fn active_models(&self) -> Vec<(DictateRole, ActiveModel)> {
755        self.dictate.active.lock().clone()
756    }
757
758    /// Resolve every role's model, fresh: the config key when set, else
759    /// the runtime pick, else the compiled pin.
760    ///
761    /// The key's variant resolves through the installed record first, a
762    /// local read; a variant that is not on disk reaches the feed's doc
763    /// table, which is the download source and the only network call here.
764    /// A key that resolves through neither is an error naming the key,
765    /// which the preflight renders as the reason forge stops.
766    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                // The speaking role resolves off `[tts]`, never this list.
776                DictateRole::Speaking => None,
777            };
778            let installed = self.installed_models();
779            // Read the pick only when the key does not decide: reading it
780            // unconditionally logs a warning about a row that cannot matter.
781            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    /// The runtime pick recorded for one role, when the store answers one.
802    ///
803    /// A failed read reads as no pick - the role falls to the pin - and it
804    /// says so, because a corrupt row silently reverting the pick would look
805    /// like a pick that never happened.
806    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    /// The model a config key names: the installed record, else the feed's
824    /// doc table, else nothing.
825    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    /// The feed's entry for one variant: the loaded feed, or one catalogue
849    /// fetch when the loaded feed does not carry it.
850    ///
851    /// The fetch is what keeps a config key working on a fresh machine,
852    /// whose local store and cache both predate the variant.
853    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    /// Record a config-resolved model the preflight has just fetched, so
868    /// the page's installed set and the next boot's resolution both see it.
869    ///
870    /// Only the config leg reaches here: a runtime pick was installed
871    /// before it could be picked, and the compiled pins are not recorded,
872    /// because nothing here names their variant without the feed.
873    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    /// The activation's state, for the page's read.
893    pub fn dictate_activate(&self) -> ActivateState {
894        self.dictate_activate.lock().clone()
895    }
896
897    /// Make one installed model the role's active model on the running
898    /// forge: build the new engine while the old still serves, swap, then
899    /// drop the old.
900    ///
901    /// A role `forge.toml` pins is refused by name - the page can still
902    /// download and bench it, but the runtime cannot move it - and a live
903    /// take refuses the swap rather than being dropped under.
904    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    /// Return one role to its `[dictate]` key or compiled default: clear
944    /// the runtime pick and swap the engine back, in activation's own
945    /// build-then-swap order.
946    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    /// The config key that pins one role, when one does.
988    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    /// The compiled default one role runs, when it has one: the cleanup
999    /// role has none while `normalizer` is off.
1000    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    /// Load one installed model and swap the role onto it.
1014    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    /// Swap one speaking variant in, building the engine before the swap so
1058    /// a failed load leaves the previous one serving.
1059    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    /// Swap the role back to its compiled default and drop the pick.
1076    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    /// Build the engine the role's new model needs while the old one still
1098    /// serves, swap the `Arc`, then drop the old - never drop first, which
1099    /// would leave the role with no engine at all if the new load fails.
1100    ///
1101    /// Answers the RFC 3339 stamp the pick is recorded with, or the reason
1102    /// the load failed; a failure changes nothing.
1103    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            // The speaking role's engine is the voice service's, and a swap
1111            // rebuilds it there rather than through dictate's config.
1112            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            // The role already runs this file: nothing to build, and a
1117            // rebuild would cost a second model load for no change.
1118            return Ok(rfc3339_now());
1119        }
1120        let stamp = rfc3339_now();
1121        // `at` is the runtime pick's own stamp; a pin or a config leg is not
1122        // a pick, so it carries none however it got there.
1123        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    /// Record one role's runtime pick, so the next boot answers it.
1161    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    /// Drop one role's runtime pick, so the role answers its config key or
1180    /// its compiled pin again.
1181    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
1195/// A `SystemTime` as RFC 3339, the stamp a result is comparable by.
1196pub(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    /// One fetched feed carrying the given entries, as the install path
1214    /// reads it: the variant lookup goes through this state.
1215    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    /// A doc body shaped like the feed's own: the marked table carrying
1225    /// one quant row, its link pointing back at the loopback that serves it.
1226    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    /// Everything a store-backed install test holds alive: the stub, its
1238    /// update feed, and the directories the models and the record land in.
1239    /// The plain `enabled_stub` keeps `db` at `None`, so recording is
1240    /// silently skipped there and a read-back test would pass vacuously.
1241    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    /// [`fixture`] with the `[dictate]` model keys a resolution test needs.
1253    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    /// [`fixture`] with anything else a test needs set on the config before
1261    /// the workspace is built.
1262    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    /// The `[dictate]` settings a resolution test resolves against.
1280    fn settings_of(ws: &Workspace) -> DictateSettings {
1281        ws.config_arc().dictate.clone()
1282    }
1283
1284    /// Put one variant in the installed set, as an install of it would.
1285    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    /// **A model is removed by file name, and refused while a role runs it.**
1297    /// A sweep leaves the candidates it scored and did not adopt behind, and
1298    /// the file on disk is the litter - but the engine holds the file a role
1299    /// runs, so that one goes through the role first.
1300    #[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        // The file on disk is what the removal is about.
1307        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        // The role runs one of them: its swap is the caller's next step, and
1311        // the refusal names the label.
1312        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    /// **A file name no record carries is refused, not joined.** The name
1357    /// crosses from a page, so `../..` and anything else a caller invents
1358    /// must name nothing rather than remove something outside the models
1359    /// directory - the same rule `delete_read_aloud` keeps for a take.
1360    #[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    /// **A removal is refused while the file is in flight under it.** The
1375    /// engine holds the file a running bench loads, and a download in flight
1376    /// is writing it: removing either under the work leaves a bench or an
1377    /// install that would then load from nowhere.
1378    #[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    /// The belt for a pick that is already stranded: one naming a variant no
1417    /// record carries resolves to the pin with a warning rather than refusing
1418    /// the boot whole.
1419    #[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    /// A late failure takes back the files this run created and leaves the
1433    /// ones an earlier install already had.
1434    #[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    /// A speaking pick beats the compiled pin, and clearing it (the
1449    /// deactivate's own record move) returns the role to the pin.
1450    #[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    /// An installed speaking variant resolves to the model and every voice,
1465    /// each with the digest its install recorded, so a load checks the whole
1466    /// set rather than the model alone.
1467    #[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    /// **A removal clears the runtime pick it strands.** A pick left naming a
1502    /// file that is gone refused the next boot - forge never left the
1503    /// launchpad and nothing on the page could fix it - so the removal is
1504    /// where the invariant is kept.
1505    #[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    /// Record one role's runtime pick, as an activation of it would.
1526    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    /// One role's resolved model.
1537    fn role_model(resolved: &[(DictateRole, ActiveModel)], role: DictateRole) -> &ActiveModel {
1538        &resolved.iter().find(|(resolved, _)| *resolved == role).expect("the role resolves").1
1539    }
1540
1541    /// The config key wins over the runtime pick and the compiled pin:
1542    /// `forge.toml` names the model, and the runtime cannot move it.
1543    #[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        // A transcribe_model key must not leak into the cleanup role.
1561        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    /// Without a key the runtime pick wins over the compiled pin, and it
1571    /// answers with the facts the install recorded.
1572    #[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    /// With neither, the compiled pin stands, for both roles.
1589    #[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    /// Setting a key never clears the pick: removing the key returns the
1604    /// role to the last runtime choice, not to the compiled pin.
1605    #[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    /// A key naming a variant the installed set and the feed both lack is
1628    /// the one resolution failure: the error names the key and the
1629    /// variant, and the preflight renders it as the reason forge stops.
1630    #[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    /// A config-resolved variant the preflight has fetched is recorded as
1648    /// installed - the record the page's installed set and the next boot's
1649    /// resolution read - and recording it again changes nothing.
1650    #[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    /// A runtime pick was installed before it could be picked, so
1684    /// recording never touches it: its record is what the pick resolves
1685    /// through in the first place.
1686    #[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    /// The next *settled* re-read the install or activation pushes, on the
1708    /// catalogue check's own fifteen-second budget: a frame whose work has
1709    /// finished (`idle`) or stopped (`failed`). The progress frames in
1710    /// between are the page's, and a test that wants them collects its own.
1711    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    /// A variant's doc names the file, the bytes arrive whole, and the
1738    /// model is recorded as installed: the record the next boot and the
1739    /// page's own reads both answer from.
1740    #[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    /// Review Focus 2: bytes that disagree with the entry's own figure land
1781    /// failed, both numbers named, and nothing is recorded as installed.
1782    #[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    /// A doc that carries no download for the quant the entry names - a
1820    /// table that moved, or one upstream trimmed - lands failed naming the
1821    /// file, rather than fetching some other row.
1822    #[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    /// **The variant whose only document is the README.** Moonshine's
1856    /// language fine-tunes - and breeze - have no page in the docs tree at
1857    /// all, so the download link can only come from the published repo's
1858    /// README: a 404 on the doc must fall through to it, not end the
1859    /// install. (Measured against the real feed: 60 of 74 variants have a
1860    /// per-variant doc, 14 have only the README, and every one is reachable
1861    /// from one of the two.)
1862    #[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![
1876                // Nothing under /docs for this variant: unrouted paths 404,
1877                // which is exactly what the real tree answers for it.
1878                (
1879                    String::from("/repos/handy-computer/moonshine-base-ar-gguf/raw/main/README.md"),
1880                    200,
1881                    format!(
1882                        "# moonshine-base-ar\n\n| Quant | Download |\n| --- | --- |\n\
1883                         | Q8_0 | [{file}]({base}/weights/{file}) |\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    /// **A Hub entry installs by its own url and verifies its own digest.**
1902    /// The speech feed's docs publish neither, so every other test here
1903    /// exercises the README-derived path; this one is the Hub's - the url
1904    /// the entry carries is what is fetched, and a digest that disagrees
1905    /// with the bytes fails the install by name rather than landing a file.
1906    #[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        // The url the entry carries is the one the download resolves to.
1923        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        // The digest the bytes carry: the install lands.
1931        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        // A digest the bytes do not match: the install fails by name.
1940        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    /// **A file nothing recorded goes back.** The page lists and removes a
1958    /// model by its record, so a store that could not write one leaves a file
1959    /// no control can reach: the install takes the bytes back and fails by
1960    /// name rather than landing litter behind it.
1961    #[tokio::test]
1962    async fn a_store_that_cannot_record_takes_the_downloaded_bytes_back() {
1963        // `enabled_stub` keeps `db` at `None`, so recording answers false.
1964        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    /// Review Focus 5: with `[dictate]` off there is no models directory to
1994    /// install into and no engine to run one, so the command refuses by name.
1995    #[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    /// A second install while one is downloading is refused, and the one in
2007    /// flight is untouched: its progress bar is the answer.
2008    #[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    /// Installing the same file again replaces its record rather than
2024    /// stacking a second one: the set is one entry per file, which is what
2025    /// the page and the active-model resolution both read.
2026    #[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    /// **The page's progress line moves because the server pushes on it.**
2061    /// A state the core records but never sends is a page that sits at
2062    /// `0 of 0` until the install is over - measured live on the stack, and
2063    /// the reason this test exists.
2064    #[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                        // **Bytes on the transfer, not just the state.** The
2105                        // pre-spawn frame already says `downloading` with the
2106                        // file named, so a test that stopped there would pass
2107                        // with the progress frames never sent at all.
2108                        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    /// A file of bytes that are not a model, where the engine would look
2133    /// for one.
2134    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    /// Review Focus 3: an activation whose model will not load lands
2141    /// failed, and the engine slot and the roles are left as they were.
2142    #[tokio::test]
2143    async fn an_activation_that_cannot_load_leaves_the_previous_engine_in_place() {
2144        // The cleanup role off, so the engine build names exactly one
2145        // model - the one under test - and the failure cannot come from
2146        // another model's file being missing. The whole fixture is bound:
2147        // destructuring the tempdirs away would delete the models dir
2148        // under the test.
2149        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    /// Review Focus 4: a live take refuses the swap by name - the seat it
2180    /// is on - and nothing starts.
2181    #[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    /// A failed activation changes nothing a restart would answer: the
2207    /// runtime pick still names the model the machine was running.
2208    #[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    /// The config pin refuses activation by name; downloads and benchmarks
2240    /// stay allowed, but the runtime cannot move the role.
2241    #[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    /// A deactivation that cannot load the default keeps the pick: the
2261    /// record is dropped only after the swap lands, so the page's rows,
2262    /// the next boot and the running engine never disagree.
2263    #[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        // The role runs the pick, and the compiled default is not on disk.
2269        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}