From 7057cf81207955c0cb2961c4d79ea99a274b179e Mon Sep 17 00:00:00 2001 From: qnbs <155236708+qnbs@users.noreply.github.com> Date: Tue, 11 Aug 2026 08:30:17 +0200 Subject: [PATCH 01/78] fix: fail closed encrypted storage lifecycle --- App.tsx | 23 ++-- README.md | 9 +- TODO.md | 2 +- components/settings/GeneralSections.tsx | 26 +++++ components/settings/IdbUnlockModal.tsx | 51 +-------- components/settings/PassphraseModal.tsx | 68 ++---------- components/settings/PrivacySection.tsx | 14 +-- docs/IDB-ENCRYPTION.md | 64 +++++------ ...8-idb-encryption-lifecycle-and-recovery.md | 63 +++++++++++ features/settings/settingsSlice.ts | 8 +- hooks/useSettingsView.ts | 25 +---- index.css | 6 ++ locales/ar/help.json | 2 +- locales/ar/settings.json | 9 +- locales/de/help.json | 2 +- locales/de/settings.json | 9 +- locales/el/help.json | 2 +- locales/el/settings.json | 9 +- locales/en/help.json | 2 +- locales/en/settings.json | 9 +- locales/es/help.json | 2 +- locales/es/settings.json | 9 +- locales/eu/help.json | 2 +- locales/eu/settings.json | 9 +- locales/fa/help.json | 2 +- locales/fa/settings.json | 9 +- locales/fi/help.json | 2 +- locales/fi/settings.json | 9 +- locales/fr/help.json | 2 +- locales/fr/settings.json | 9 +- locales/he/help.json | 2 +- locales/he/settings.json | 9 +- locales/hu/help.json | 2 +- locales/hu/settings.json | 9 +- locales/is/help.json | 2 +- locales/is/settings.json | 9 +- locales/it/help.json | 2 +- locales/it/settings.json | 9 +- locales/ja/help.json | 2 +- locales/ja/settings.json | 9 +- locales/ko/help.json | 2 +- locales/ko/settings.json | 9 +- locales/pt/help.json | 2 +- locales/pt/settings.json | 9 +- locales/ru/help.json | 2 +- locales/ru/settings.json | 9 +- locales/sv/help.json | 2 +- locales/sv/settings.json | 9 +- locales/zh/help.json | 2 +- locales/zh/settings.json | 9 +- public/locales/ar/bundle.json | 12 ++- public/locales/de/bundle.json | 12 ++- public/locales/el/bundle.json | 12 ++- public/locales/en/bundle.json | 12 ++- public/locales/es/bundle.json | 12 ++- public/locales/eu/bundle.json | 12 ++- public/locales/fa/bundle.json | 12 ++- public/locales/fi/bundle.json | 12 ++- public/locales/fr/bundle.json | 12 ++- public/locales/he/bundle.json | 12 ++- public/locales/hu/bundle.json | 12 ++- public/locales/is/bundle.json | 12 ++- public/locales/it/bundle.json | 12 ++- public/locales/ja/bundle.json | 12 ++- public/locales/ko/bundle.json | 12 ++- public/locales/pt/bundle.json | 12 ++- public/locales/ru/bundle.json | 12 ++- public/locales/sv/bundle.json | 12 ++- public/locales/zh/bundle.json | 12 ++- services/storage/idbAssetStore.ts | 5 + services/storage/idbCodexStore.ts | 5 + services/storage/idbProjectStore.ts | 8 +- services/storage/idbSnapshotStore.ts | 4 +- services/storage/storageEncryptionService.ts | 79 ++++++++------ .../services/storage/idbProjectStore.test.ts | 3 + tests/unit/settings/GeneralSections.test.tsx | 8 ++ tests/unit/settings/IdbUnlockModal.test.tsx | 60 +---------- tests/unit/settings/PassphraseModal.test.tsx | 101 +++--------------- tests/unit/settings/PrivacySection.test.tsx | 40 ++----- tests/unit/settingsSlice.test.ts | 8 +- tests/unit/storage/idbStoreEncryption.test.ts | 92 +++++++++++++++- .../storage/storageEncryptionService.test.ts | 67 ++++++------ tests/unit/storageEncryptionService.test.ts | 32 +++--- types.ts | 3 + 84 files changed, 775 insertions(+), 536 deletions(-) create mode 100644 docs/adr/0018-idb-encryption-lifecycle-and-recovery.md diff --git a/App.tsx b/App.tsx index c2923661..25e889ad 100644 --- a/App.tsx +++ b/App.tsx @@ -67,7 +67,6 @@ import { pluginRegistry } from './services/pluginRegistry'; import { repairProjectI18nFields } from './services/projectI18nRepair'; import { hasCompletedSpotlightTour, startSpotlightTour } from './services/spotlightTour'; import { - clearIdbPassphrase, hasPassphraseSentinel, isIdbEncryptionReady, } from './services/storage/storageEncryptionService'; @@ -196,15 +195,6 @@ const App: FC = ({ isNewUser }) => { const isIdbUnlockOpen = useTransientUiStore((s) => s.isIdbUnlockOpen); const setIdbUnlockOpen = useTransientUiStore((s) => s.setIdbUnlockOpen); - // QNBS-v3: escape hatch — clears sentinel + disables flag so the app is accessible again - const handleForgotPassphrase = useCallback(async () => { - await clearIdbPassphrase(); - dispatch(featureFlagsActions.setEnableIdbAtRestEncryption(false)); - setIdbUnlockOpen(false); - // QNBS-v3: WCAG 4.1.3 — assertive announcement so screen reader users know the security state changed - announce(t('settings.privacy.encryptionDisabledStatus'), 'assertive'); - }, [dispatch, setIdbUnlockOpen, announce, t]); - // Collaboration Panel State const [isCollabPanelOpen, setIsCollabPanelOpen] = useState(false); @@ -256,6 +246,14 @@ const App: FC = ({ isNewUser }) => { } }, [settings.appearancePreset]); + useEffect(() => { + // QNBS-v3: Decorative fixed layers are opt-out so long-form writers can keep a neutral canvas. + document.body.classList.toggle( + 'writing-surface-plain', + settings.writingSurfaceStyle === 'plain', + ); + }, [settings.writingSurfaceStyle]); + useEffect(() => { document.body.classList.toggle( 'accessibility-high-contrast', @@ -809,10 +807,7 @@ const App: FC = ({ isNewUser }) => { )} {isIdbUnlockOpen && ( setIdbUnlockOpen(false)}> - setIdbUnlockOpen(false)} - onForgotPassphrase={() => void handleForgotPassphrase()} - /> + setIdbUnlockOpen(false)} /> )} diff --git a/README.md b/README.md index 0b73422b..fa6dc899 100644 --- a/README.md +++ b/README.md @@ -307,12 +307,13 @@ Real-time P2P co-editing via **Yjs + collab-transport** (vendor fork of y-webrtc ### 🔒 IDB At-Rest Encryption _(B-1, v1.19.0)_ -All project data, snapshots, and settings stored in IndexedDB can be encrypted at rest via `services/storage/storageEncryptionService.ts`: +The current primary project, settings, snapshot, image, Codex, RAG, and binder-asset IndexedDB paths can be encrypted at rest via `services/storage/storageEncryptionService.ts`. This is not yet a claim that every IndexedDB surface is covered: - **AES-256-GCM** with a PBKDF2-derived key (600 000 iterations, SHA-256, 32-byte random salt). -- Gated behind `featureFlags.enableIdbAtRestEncryption` (on by default since v1.23; the passphrase unlock UX is complete — Settings → Privacy). +- Gated behind `featureFlags.enableIdbAtRestEncryption`. When a library is configured but locked, protected reads and writes fail closed rather than falling back to plaintext. +- Disable and passphrase rotation are temporarily unavailable until a journaled, cross-store migration protocol can prove recovery after interruption. - Same passphrase-entry unlock screen (`IdbUnlockModal`) on cold start, session-scoped in-memory key, on **both** the web build and the Tauri desktop build — Tauri's WebView uses the same IndexedDB-backed storage path, not an OS keychain. (No `tauri-plugin-stronghold` or equivalent OS-keychain integration ships today — see the API-key encryption note below for the desktop-specific mechanism that does exist.) -- GDPR-compliant: encrypted blobs are unreadable without the passphrase, even from the browser profile directory. +- At-rest protection reduces disclosure from an extracted browser profile while the library is locked; it does not protect an unlocked renderer, a compromised device, or every persistence surface. ### 🔐 Encrypted Library Backup @@ -320,7 +321,7 @@ One-click encrypted export of your entire project library from **Settings → Da - Archives all projects as a **ZIP** containing `META.json` + `vault.bin`. - `vault.bin` is encrypted with **AES-256-GCM** — the decryption key is derived from your chosen passphrase using PBKDF2. -- No plaintext project data ever leaves your device unencrypted. +- The encrypted vault holds its project payload in `vault.bin`; users must still protect the downloaded archive and should not confuse it with ordinary plaintext JSON export. - Import on any device using the same passphrase to restore your full library. ### 🔑 Encryption — which mechanism protects what diff --git a/TODO.md b/TODO.md index a31ac6db..c6eb6fda 100644 --- a/TODO.md +++ b/TODO.md @@ -281,7 +281,7 @@ are all version-bumped and synced for this release. - ✅ **Production blank screen — zod/rolldown DCE** (2026-06-02) — `init_locales is not defined`: rolldown's prod DCE dropped zod's `__esm` init wrappers (zod `sideEffects:false`). Fixed via `patches/zod@4.4.3.patch` (`sideEffects:true`). Added `smoke:prod` (headless mount check on built `dist/`) to CI build job + `unhandledrejection` startup handler — closes the dev-mode-E2E blind spot - 🔄 **C-6** — ar/he UI translation **complete** (2026-06-03): 18 modules translated in `locales/{ar,he}/` (help.json English fallback), Noto fonts + RTL shell layout shipped as Beta. Remaining: native-speaker review + help-article prose — community task. See `docs/I18N-GLOSSARY-RTL.md` - 🔄 **C-7 remainder** — Coverage → L85%/B75%/F80%; Stryker break 75→80 (current thresholds: L73/F65/B58). **Phase 3 started (2026-06-02):** +33 LoRA tests (useLoraView, training wizard, sub-panels — were 0%) -- ✅ IDB at-rest encryption UX (2026-06-02 reconciliation) — `IdbUnlockModal` (startup unlock + 2-step forgot-passphrase escape hatch, `App.tsx:182-188,638-643`), `PassphraseModal` (set/change/disable), real read/write gating `idbProjectStore.ts:209-265`, session lock + key rotation (Phase 1). `enableIdbAtRestEncryption` flag in Settings › Privacy with ⚠ warning +- 🟡 IDB at-rest encryption lifecycle (2026-08-11 reconciliation) — startup unlock, session lock, and fail-closed protected writes are implemented. Disable, forgot-passphrase deletion, and passphrase rotation are intentionally blocked until a durable cross-database migration journal and recovery protocol exist; do not describe them as completed. - ✅ **P0-2** — Plugin worker isolation (`workers/plugin.worker.ts`) — routes plugin execution to isolated worker context with timeout and sandboxed API - 🟡 **P0-4** — DuckDB OPFS at-rest encryption (`services/duckdb/duckdbEncryption.ts`) — cell-level encryption is now wired for the one column holding literal manuscript prose, `codex_mentions.excerpt` (v1.25.0): `duckdbCodexWrite()` encrypts it into `excerpt_enc BLOB` when `enableIdbAtRestEncryption` is active, with `services/duckdb/codexExcerptEncryptionMigration.ts` backfilling pre-existing plaintext rows. Full OPFS **file-level** encryption remains infeasible (DuckDB-WASM owns the OPFS file handle directly) and is an accepted, permanent limitation, not a remaining task — see `.github/SECURITY.md` SEC-6. - ✅ **P0-5** — Voice WASM model download UI (`components/voice/VoiceModelDownloadModal.tsx`) — progress modal for Whisper/Kokoro model downloads with cancel/retry diff --git a/components/settings/GeneralSections.tsx b/components/settings/GeneralSections.tsx index d011520a..0be30dc6 100644 --- a/components/settings/GeneralSections.tsx +++ b/components/settings/GeneralSections.tsx @@ -206,6 +206,32 @@ export const AppearanceSection: FC = () => { +
+ + {t('settings.appearance.writingSurface')} + +

+ {t('settings.appearance.writingSurfaceHint')} +

+
+ + +
+
diff --git a/components/settings/IdbUnlockModal.tsx b/components/settings/IdbUnlockModal.tsx index 3ad9b0ee..1397fce6 100644 --- a/components/settings/IdbUnlockModal.tsx +++ b/components/settings/IdbUnlockModal.tsx @@ -7,7 +7,6 @@ import { Modal } from '../ui/Modal'; interface Props { onUnlocked: () => void; - onForgotPassphrase?: () => void; } const ATTEMPT_STORAGE_KEY = 'worldscript-idb-unlock-attempts'; @@ -100,16 +99,13 @@ function lockoutMs(attempts: number): number { return Math.min(2 ** (attempts - 4), 60) * 1000; } -export const IdbUnlockModal: FC = ({ onUnlocked, onForgotPassphrase }) => { +export const IdbUnlockModal: FC = ({ onUnlocked }) => { const { t } = useTranslation(); const [passphrase, setPassphrase] = useState(''); const [error, setError] = useState(''); const [busy, setBusy] = useState(false); - // QNBS-v3: two-step confirm for forgot-passphrase to prevent accidental clicks - const [showForgotConfirm, setShowForgotConfirm] = useState(false); const [lockoutRemaining, setLockoutRemaining] = useState(0); const inputRef = useRef(null); - const cancelBtnRef = useRef(null); // QNBS-v3: Rate-limiting tick — update remaining lockout time every second useEffect(() => { @@ -127,17 +123,6 @@ export const IdbUnlockModal: FC = ({ onUnlocked, onForgotPassphrase }) => inputRef.current?.focus(); }, []); - // QNBS-v3: WCAG 2.4.3 focus management — when confirmation panel opens, move focus to - // the Cancel button so keyboard users don't lose their position in the document. - useEffect(() => { - if (showForgotConfirm) { - cancelBtnRef.current?.focus(); - } else { - // Restore focus to the passphrase input when the panel is dismissed. - inputRef.current?.focus(); - } - }, [showForgotConfirm]); - const handleUnlock = useCallback(async () => { if (!passphrase) return; if (lockoutRemaining > 0) return; @@ -238,40 +223,6 @@ export const IdbUnlockModal: FC = ({ onUnlocked, onForgotPassphrase }) => : t('settings.privacy.encryptionUnlockButton')} - - {onForgotPassphrase && ( -
- {!showForgotConfirm ? ( - - ) : ( -
- {/* QNBS-v3: role="alert" so screen readers immediately read the warning when this section appears */} - -
- - {/* QNBS-v3: aria-describedby links destructive button to the warning text for AT users */} - -
-
- )} -
- )} ); diff --git a/components/settings/PassphraseModal.tsx b/components/settings/PassphraseModal.tsx index 9cd3877e..4c6754e5 100644 --- a/components/settings/PassphraseModal.tsx +++ b/components/settings/PassphraseModal.tsx @@ -5,12 +5,12 @@ import { Button } from '../ui/Button'; import { Modal } from '../ui/Modal'; import { Spinner } from '../ui/Spinner'; -export type PassphraseModalMode = 'set' | 'change' | 'disable' | 'unlock'; +export type PassphraseModalMode = 'set' | 'unlock'; interface Props { mode: PassphraseModalMode; onClose: () => void; - /** Called with (current, next) — for 'set': ('' , passphrase); for 'unlock'/'disable': (passphrase, ''); for 'change': (old, new). */ + /** Called with (current, next) — for 'set': ('', passphrase); for 'unlock': (passphrase, ''). */ onConfirm: (current: string, next: string) => Promise; } @@ -35,14 +35,10 @@ export const PassphraseModal: FC = ({ mode, onClose, onConfirm }) => { const title = mode === 'set' ? t('settings.privacy.encryptionModalSetTitle') - : mode === 'change' - ? t('settings.privacy.encryptionModalChangeTitle') - : mode === 'unlock' - ? t('settings.privacy.encryptionModalUnlockTitle') - : t('settings.privacy.encryptionModalDisableTitle'); + : t('settings.privacy.encryptionModalUnlockTitle'); const validate = useCallback((): string => { - if (mode === 'set' || mode === 'change') { + if (mode === 'set') { if (next.length < MIN_LEN) return t('settings.privacy.encryptionTooShort'); if (next !== confirm) return t('settings.privacy.encryptionMismatch'); } @@ -71,22 +67,12 @@ export const PassphraseModal: FC = ({ mode, onClose, onConfirm }) => { const confirmButtonLabel = mode === 'set' ? t('settings.privacy.encryptionSetButton') - : mode === 'change' - ? t('settings.privacy.encryptionChangeButton') - : mode === 'unlock' - ? t('settings.privacy.encryptionUnlockButton') - : t('settings.privacy.encryptionDisableButton'); + : t('settings.privacy.encryptionUnlockButton'); const hasError = error.length > 0; - // QNBS-v3: 'disable' mode is a destructive confirmation — alertdialog announces immediately via AT return ( - +
{/* 'unlock' mode: single current-passphrase field to re-derive the in-memory key */} {mode === 'unlock' && ( @@ -114,33 +100,7 @@ export const PassphraseModal: FC = ({ mode, onClose, onConfirm }) => {
)} - {(mode === 'change' || mode === 'disable') && ( -
- - { - setCurrent(e.target.value); - setError(''); - }} - // QNBS-v3: aria-describedby + aria-invalid wire the error to the field for screen readers - aria-describedby={hasError ? ERROR_ID : undefined} - aria-invalid={hasError} - className="w-full px-3 py-2 rounded-lg border border-[var(--sc-border-subtle)] bg-[var(--sc-surface-base)] text-[var(--sc-text-primary)] focus-visible:ring-2 focus-visible:ring-[var(--sc-border-focus)] outline-none" - /> -
- )} - - {(mode === 'set' || mode === 'change') && ( + {mode === 'set' && ( <>
= ({ mode, onClose, onConfirm }) => {

)} - {/* forgot passphrase hint — only relevant when entering an existing passphrase */} - {(mode === 'unlock' || mode === 'disable') && ( -

- {t('settings.privacy.encryptionForgotPassphrase')}{' '} - - {t('settings.privacy.encryptionForgotPassphraseWarning')} - -

- )} - {/* QNBS-v3: pre-rendered with minHeight so the DOM node exists before text is injected — required by NVDA/JAWS for role="alert" to fire the live-region announcement */}

= ({ mode, onClose, onConfirm }) => { {t('common.cancel')} - {/* QNBS-v3: Lock Session clears the in-memory key without disabling encryption — user must re-enter passphrase on next access. */}

+ {/* QNBS-v3: Let writers remove decorative layers without changing the selected theme. */}
{t('settings.appearance.writingSurface')} @@ -216,6 +217,7 @@ export const AppearanceSection: FC = () => {
diff --git a/locales/ar/lora.json b/locales/ar/lora.json index 171c42bc..a6dcf961 100644 --- a/locales/ar/lora.json +++ b/locales/ar/lora.json @@ -39,15 +39,34 @@ "lora.onboarding.cpuFallback": "الاحتياطي عبر المعالج (أبطأ)", "lora.onboarding.description": "أنشئ نموذج ذكاء اصطناعي مخصّصًا مُدرّبًا على مخطوطاتك. بياناتك لا تغادر جهازك أبدًا.", "lora.onboarding.envError": "فشل التحقق من البيئة. يرجى التأكد من تثبيت Python والحزم المطلوبة.", + "lora.onboarding.error.configurationPathUnavailable": "Could not access the app configuration folder.", + "lora.onboarding.error.configurationWriteFailed": "Could not save the selected Python path.", + "lora.onboarding.error.configuredPathEmpty": "No path was selected.", + "lora.onboarding.error.configuredPathNotAbsolute": "Path must be an absolute path to a Python executable.", + "lora.onboarding.error.executableNotFound": "That file is not a valid executable.", + "lora.onboarding.error.generic": "Selection failed. Please try a different Python executable.", + "lora.onboarding.error.helperExitNonzero": "The environment-check helper exited with an error.", + "lora.onboarding.error.helperReportParseFailed": "Could not read the environment-check results.", + "lora.onboarding.error.helperScriptMissing": "The environment-check helper script is missing.", + "lora.onboarding.error.helperSpawnFailed": "Could not run the environment-check helper.", + "lora.onboarding.error.helperTimedOut": "The environment check timed out.", + "lora.onboarding.error.incompatibleVersion": "Python 3.10 or newer is required.", + "lora.onboarding.error.permissionDenied": "Permission denied when running that executable.", + "lora.onboarding.error.processSpawnFailed": "Could not start that executable.", + "lora.onboarding.error.pythonProbeTaskFailed": "The Python check failed unexpectedly.", + "lora.onboarding.error.versionParseFailed": "Could not read the Python version output.", + "lora.onboarding.error.versionProbeFailed": "Could not determine the Python version.", + "lora.onboarding.error.versionProbeTimedOut": "Timed out while checking the Python version.", "lora.onboarding.getStarted": "ابدأ الآن", "lora.onboarding.installCmd": "pip install unsloth trl peft", "lora.onboarding.installed": "مُثبَّت", "lora.onboarding.notFound": "غير موجود", "lora.onboarding.privacyDetail": "يجري كل التدريب محليًا. لا تُرسَل مخطوطتك أبدًا إلى أي خدمة سحابية.", "lora.onboarding.privacyPromise": "خاص وغير متصل بنسبة 100%", + "lora.onboarding.selectPython": "اختيار ملف Python التنفيذي", + "lora.onboarding.selectingPython": "Opening file picker…", "lora.onboarding.systemCheck": "متطلبات النظام", "lora.onboarding.title": "درّب أسلوب كتابتك", - "lora.onboarding.selectPython": "اختيار ملف Python التنفيذي", "lora.presets.deepNarrative.desc": "أسلوب سردي كامل — ~60 دقيقة، 12 غيغابايت ذاكرة رسوميات", "lora.presets.deepNarrative.label": "سرد عميق", "lora.presets.dialogueMaster.desc": "مُحسَّن للحوار — ~45 دقيقة، 8 غيغابايت ذاكرة رسوميات", diff --git a/locales/de/lora.json b/locales/de/lora.json index 5533d05a..d9ce3464 100644 --- a/locales/de/lora.json +++ b/locales/de/lora.json @@ -39,6 +39,25 @@ "lora.onboarding.cpuFallback": "CPU-Fallback (langsamer)", "lora.onboarding.description": "Erstelle ein personalisiertes KI-Modell, das auf deinen Manuskripten trainiert wurde. Deine Daten verlassen niemals dein Gerät.", "lora.onboarding.envError": "Umgebungsprüfung fehlgeschlagen. Bitte stelle sicher, dass Python und erforderliche Pakete installiert sind.", + "lora.onboarding.error.configuredPathEmpty": "Es wurde kein Pfad ausgewählt.", + "lora.onboarding.error.configuredPathNotAbsolute": "Der Pfad muss ein absoluter Pfad zu einer Python-Anwendung sein.", + "lora.onboarding.error.executableNotFound": "Diese Datei ist keine gültige ausführbare Datei.", + "lora.onboarding.error.permissionDenied": "Zugriff auf diese Anwendung wurde verweigert.", + "lora.onboarding.error.processSpawnFailed": "Diese Anwendung konnte nicht gestartet werden.", + "lora.onboarding.error.versionProbeFailed": "Die Python-Version konnte nicht ermittelt werden.", + "lora.onboarding.error.versionParseFailed": "Die Ausgabe der Python-Version konnte nicht gelesen werden.", + "lora.onboarding.error.incompatibleVersion": "Python 3.10 oder neuer ist erforderlich.", + "lora.onboarding.error.versionProbeTimedOut": "Zeitüberschreitung bei der Prüfung der Python-Version.", + "lora.onboarding.error.pythonProbeTaskFailed": "Die Python-Prüfung ist unerwartet fehlgeschlagen.", + "lora.onboarding.error.configurationPathUnavailable": "Auf den Konfigurationsordner der App konnte nicht zugegriffen werden.", + "lora.onboarding.error.configurationWriteFailed": "Der ausgewählte Python-Pfad konnte nicht gespeichert werden.", + "lora.onboarding.error.helperScriptMissing": "Das Hilfsskript für die Umgebungsprüfung fehlt.", + "lora.onboarding.error.helperTimedOut": "Zeitüberschreitung bei der Umgebungsprüfung.", + "lora.onboarding.error.helperSpawnFailed": "Das Hilfsskript für die Umgebungsprüfung konnte nicht gestartet werden.", + "lora.onboarding.error.helperExitNonzero": "Das Hilfsskript für die Umgebungsprüfung wurde mit einem Fehler beendet.", + "lora.onboarding.error.helperReportParseFailed": "Die Ergebnisse der Umgebungsprüfung konnten nicht gelesen werden.", + "lora.onboarding.error.generic": "Auswahl fehlgeschlagen. Bitte wähle eine andere Python-Anwendung.", + "lora.onboarding.selectingPython": "Dateiauswahl wird geöffnet…", "lora.onboarding.getStarted": "Loslegen", "lora.onboarding.installCmd": "pip install unsloth trl peft", "lora.onboarding.installed": "installiert", diff --git a/locales/el/lora.json b/locales/el/lora.json index 69d068f3..78bb4e78 100644 --- a/locales/el/lora.json +++ b/locales/el/lora.json @@ -39,15 +39,34 @@ "lora.onboarding.cpuFallback": "Εναλλακτικό CPU (πιο αργό)", "lora.onboarding.description": "Δημιουργία a personalized AI model trained on your manuscripts. Your data never leaves your device.", "lora.onboarding.envError": "Ο έλεγχος περιβάλλοντος απέτυχε. Βεβαιωθείτε ότι έχουν εγκατασταθεί η Python και τα απαιτούμενα πακέτα.", + "lora.onboarding.error.configurationPathUnavailable": "Could not access the app configuration folder.", + "lora.onboarding.error.configurationWriteFailed": "Could not save the selected Python path.", + "lora.onboarding.error.configuredPathEmpty": "No path was selected.", + "lora.onboarding.error.configuredPathNotAbsolute": "Path must be an absolute path to a Python executable.", + "lora.onboarding.error.executableNotFound": "That file is not a valid executable.", + "lora.onboarding.error.generic": "Selection failed. Please try a different Python executable.", + "lora.onboarding.error.helperExitNonzero": "The environment-check helper exited with an error.", + "lora.onboarding.error.helperReportParseFailed": "Could not read the environment-check results.", + "lora.onboarding.error.helperScriptMissing": "The environment-check helper script is missing.", + "lora.onboarding.error.helperSpawnFailed": "Could not run the environment-check helper.", + "lora.onboarding.error.helperTimedOut": "The environment check timed out.", + "lora.onboarding.error.incompatibleVersion": "Python 3.10 or newer is required.", + "lora.onboarding.error.permissionDenied": "Permission denied when running that executable.", + "lora.onboarding.error.processSpawnFailed": "Could not start that executable.", + "lora.onboarding.error.pythonProbeTaskFailed": "The Python check failed unexpectedly.", + "lora.onboarding.error.versionParseFailed": "Could not read the Python version output.", + "lora.onboarding.error.versionProbeFailed": "Could not determine the Python version.", + "lora.onboarding.error.versionProbeTimedOut": "Timed out while checking the Python version.", "lora.onboarding.getStarted": "Ξεκινήστε", "lora.onboarding.installCmd": "pip install unsloth trl peft", "lora.onboarding.installed": "εγκατασταθεί", "lora.onboarding.notFound": "δεν βρέθηκε", "lora.onboarding.privacyDetail": "Όλες οι προπονήσεις γίνονται τοπικά. Το χειρόγραφό σας δεν αποστέλλεται ποτέ σε καμία υπηρεσία cloud.", "lora.onboarding.privacyPromise": "100% Ιδιωτικό & Εκτός σύνδεσης", + "lora.onboarding.selectPython": "Επιλέξτε εκτελέσιμο Python", + "lora.onboarding.selectingPython": "Opening file picker…", "lora.onboarding.systemCheck": "Απαιτήσεις συστήματος", "lora.onboarding.title": "Εκπαιδεύστε το στυλ γραφής σας", - "lora.onboarding.selectPython": "Επιλέξτε εκτελέσιμο Python", "lora.presets.deepNarrative.desc": "Πλήρες στυλ αφήγησης — ~60 λεπτά, 12 GB VRAM", "lora.presets.deepNarrative.label": "Βαθιά Αφήγηση", "lora.presets.dialogueMaster.desc": "Βελτιστοποιημένος διάλογος — ~45 λεπτά, 8 GB VRAM", diff --git a/locales/en/lora.json b/locales/en/lora.json index a54a8678..3b20fe5d 100644 --- a/locales/en/lora.json +++ b/locales/en/lora.json @@ -13,6 +13,25 @@ "lora.onboarding.cpuFallback": "CPU fallback (slower)", "lora.onboarding.getStarted": "Get Started", "lora.onboarding.envError": "Environment check failed. Please ensure Python and required packages are installed.", + "lora.onboarding.selectingPython": "Opening file picker…", + "lora.onboarding.error.configuredPathEmpty": "No path was selected.", + "lora.onboarding.error.configuredPathNotAbsolute": "Path must be an absolute path to a Python executable.", + "lora.onboarding.error.executableNotFound": "That file is not a valid executable.", + "lora.onboarding.error.permissionDenied": "Permission denied when running that executable.", + "lora.onboarding.error.processSpawnFailed": "Could not start that executable.", + "lora.onboarding.error.versionProbeFailed": "Could not determine the Python version.", + "lora.onboarding.error.versionParseFailed": "Could not read the Python version output.", + "lora.onboarding.error.incompatibleVersion": "Python 3.10 or newer is required.", + "lora.onboarding.error.versionProbeTimedOut": "Timed out while checking the Python version.", + "lora.onboarding.error.pythonProbeTaskFailed": "The Python check failed unexpectedly.", + "lora.onboarding.error.configurationPathUnavailable": "Could not access the app configuration folder.", + "lora.onboarding.error.configurationWriteFailed": "Could not save the selected Python path.", + "lora.onboarding.error.helperScriptMissing": "The environment-check helper script is missing.", + "lora.onboarding.error.helperTimedOut": "The environment check timed out.", + "lora.onboarding.error.helperSpawnFailed": "Could not run the environment-check helper.", + "lora.onboarding.error.helperExitNonzero": "The environment-check helper exited with an error.", + "lora.onboarding.error.helperReportParseFailed": "Could not read the environment-check results.", + "lora.onboarding.error.generic": "Selection failed. Please try a different Python executable.", "lora.library.title": "Adapter Library", "lora.library.empty": "No adapters yet. Train your first style model to get started.", "lora.wizard.title": "Training Wizard", diff --git a/locales/es/lora.json b/locales/es/lora.json index 2f753501..e05b9c05 100644 --- a/locales/es/lora.json +++ b/locales/es/lora.json @@ -39,6 +39,25 @@ "lora.onboarding.cpuFallback": "Reserva CPU (más lento)", "lora.onboarding.description": "Crea un modelo de IA personalizado entrenado con tus manuscritos. Tus datos nunca salen de tu dispositivo.", "lora.onboarding.envError": "Error al verificar el entorno. Asegúrate de que Python y los paquetes requeridos están instalados.", + "lora.onboarding.error.configuredPathEmpty": "No se seleccionó ninguna ruta.", + "lora.onboarding.error.configuredPathNotAbsolute": "La ruta debe ser una ruta absoluta a un ejecutable de Python.", + "lora.onboarding.error.executableNotFound": "Ese archivo no es un ejecutable válido.", + "lora.onboarding.error.permissionDenied": "Permiso denegado al ejecutar ese archivo.", + "lora.onboarding.error.processSpawnFailed": "No se pudo iniciar ese ejecutable.", + "lora.onboarding.error.versionProbeFailed": "No se pudo determinar la versión de Python.", + "lora.onboarding.error.versionParseFailed": "No se pudo leer la salida de la versión de Python.", + "lora.onboarding.error.incompatibleVersion": "Se requiere Python 3.10 o más reciente.", + "lora.onboarding.error.versionProbeTimedOut": "Tiempo de espera agotado al verificar la versión de Python.", + "lora.onboarding.error.pythonProbeTaskFailed": "La verificación de Python falló inesperadamente.", + "lora.onboarding.error.configurationPathUnavailable": "No se pudo acceder a la carpeta de configuración de la app.", + "lora.onboarding.error.configurationWriteFailed": "No se pudo guardar la ruta de Python seleccionada.", + "lora.onboarding.error.helperScriptMissing": "Falta el script auxiliar de verificación del entorno.", + "lora.onboarding.error.helperTimedOut": "Se agotó el tiempo de espera de la verificación del entorno.", + "lora.onboarding.error.helperSpawnFailed": "No se pudo ejecutar el auxiliar de verificación del entorno.", + "lora.onboarding.error.helperExitNonzero": "El auxiliar de verificación del entorno terminó con un error.", + "lora.onboarding.error.helperReportParseFailed": "No se pudieron leer los resultados de la verificación del entorno.", + "lora.onboarding.error.generic": "La selección falló. Prueba con otro ejecutable de Python.", + "lora.onboarding.selectingPython": "Abriendo el selector de archivos…", "lora.onboarding.getStarted": "Comenzar", "lora.onboarding.installCmd": "pip install unsloth trl peft", "lora.onboarding.installed": "instalado", diff --git a/locales/eu/lora.json b/locales/eu/lora.json index ddd6168b..bf638551 100644 --- a/locales/eu/lora.json +++ b/locales/eu/lora.json @@ -39,15 +39,34 @@ "lora.onboarding.cpuFallback": "CPUren atzerapena (motelagoa)", "lora.onboarding.description": "Sortu zure eskuizkribuetan trebatutako AI eredu pertsonalizatu bat. Zure datuak ez dira inoiz zure gailutik irteten.", "lora.onboarding.envError": "Ingurunearen egiaztapenak huts egin du. Mesedez, ziurtatu Python eta beharrezko paketeak instalatuta daudela.", + "lora.onboarding.error.configurationPathUnavailable": "Could not access the app configuration folder.", + "lora.onboarding.error.configurationWriteFailed": "Could not save the selected Python path.", + "lora.onboarding.error.configuredPathEmpty": "No path was selected.", + "lora.onboarding.error.configuredPathNotAbsolute": "Path must be an absolute path to a Python executable.", + "lora.onboarding.error.executableNotFound": "That file is not a valid executable.", + "lora.onboarding.error.generic": "Selection failed. Please try a different Python executable.", + "lora.onboarding.error.helperExitNonzero": "The environment-check helper exited with an error.", + "lora.onboarding.error.helperReportParseFailed": "Could not read the environment-check results.", + "lora.onboarding.error.helperScriptMissing": "The environment-check helper script is missing.", + "lora.onboarding.error.helperSpawnFailed": "Could not run the environment-check helper.", + "lora.onboarding.error.helperTimedOut": "The environment check timed out.", + "lora.onboarding.error.incompatibleVersion": "Python 3.10 or newer is required.", + "lora.onboarding.error.permissionDenied": "Permission denied when running that executable.", + "lora.onboarding.error.processSpawnFailed": "Could not start that executable.", + "lora.onboarding.error.pythonProbeTaskFailed": "The Python check failed unexpectedly.", + "lora.onboarding.error.versionParseFailed": "Could not read the Python version output.", + "lora.onboarding.error.versionProbeFailed": "Could not determine the Python version.", + "lora.onboarding.error.versionProbeTimedOut": "Timed out while checking the Python version.", "lora.onboarding.getStarted": "Hasi", "lora.onboarding.installCmd": "pip install unsloth trl peft", "lora.onboarding.installed": "instalatuta", "lora.onboarding.notFound": "ez da aurkitu", "lora.onboarding.privacyDetail": "Prestakuntza guztiak lokalean egiten dira. Zure eskuizkribua ez da inoiz hodeiko zerbitzura bidaltzen.", "lora.onboarding.privacyPromise": "%100 pribatua eta lineaz kanpo", + "lora.onboarding.selectPython": "Aukeratu Python exekutagarria", + "lora.onboarding.selectingPython": "Opening file picker…", "lora.onboarding.systemCheck": "Sistemaren eskakizunak", "lora.onboarding.title": "Prestatu zure idazketa-estiloa", - "lora.onboarding.selectPython": "Aukeratu Python exekutagarria", "lora.presets.deepNarrative.desc": "Narrazio estilo osoa — ~60 min, 12 GB VRAM", "lora.presets.deepNarrative.label": "Narrazio Sakona", "lora.presets.dialogueMaster.desc": "Elkarrizketa optimizatua — ~45 min, 8 GB VRAM", diff --git a/locales/fa/lora.json b/locales/fa/lora.json index 0840f768..eaf96e13 100644 --- a/locales/fa/lora.json +++ b/locales/fa/lora.json @@ -39,15 +39,34 @@ "lora.onboarding.cpuFallback": "بازگشت مجدد CPU (آهسته تر)", "lora.onboarding.description": "یک مدل هوش مصنوعی شخصی سازی شده بر روی دست نوشته های خود ایجاد کنید. اطلاعات شما هرگز از دستگاه شما خارج نمی شود.", "lora.onboarding.envError": "بررسی محیط زیست انجام نشد. لطفا مطمئن شوید که پایتون و بسته های مورد نیاز نصب شده است.", + "lora.onboarding.error.configurationPathUnavailable": "Could not access the app configuration folder.", + "lora.onboarding.error.configurationWriteFailed": "Could not save the selected Python path.", + "lora.onboarding.error.configuredPathEmpty": "No path was selected.", + "lora.onboarding.error.configuredPathNotAbsolute": "Path must be an absolute path to a Python executable.", + "lora.onboarding.error.executableNotFound": "That file is not a valid executable.", + "lora.onboarding.error.generic": "Selection failed. Please try a different Python executable.", + "lora.onboarding.error.helperExitNonzero": "The environment-check helper exited with an error.", + "lora.onboarding.error.helperReportParseFailed": "Could not read the environment-check results.", + "lora.onboarding.error.helperScriptMissing": "The environment-check helper script is missing.", + "lora.onboarding.error.helperSpawnFailed": "Could not run the environment-check helper.", + "lora.onboarding.error.helperTimedOut": "The environment check timed out.", + "lora.onboarding.error.incompatibleVersion": "Python 3.10 or newer is required.", + "lora.onboarding.error.permissionDenied": "Permission denied when running that executable.", + "lora.onboarding.error.processSpawnFailed": "Could not start that executable.", + "lora.onboarding.error.pythonProbeTaskFailed": "The Python check failed unexpectedly.", + "lora.onboarding.error.versionParseFailed": "Could not read the Python version output.", + "lora.onboarding.error.versionProbeFailed": "Could not determine the Python version.", + "lora.onboarding.error.versionProbeTimedOut": "Timed out while checking the Python version.", "lora.onboarding.getStarted": "شروع کنید", "lora.onboarding.installCmd": "pip install unsloth trl peft", "lora.onboarding.installed": "نصب شده است", "lora.onboarding.notFound": "یافت نشد", "lora.onboarding.privacyDetail": "تمام آموزش ها به صورت محلی انجام می شود. دستنوشته شما هرگز به هیچ سرویس ابری ارسال نمی شود.", "lora.onboarding.privacyPromise": "100% خصوصی و آفلاین", + "lora.onboarding.selectPython": "انتخاب فایل اجرایی پایتون", + "lora.onboarding.selectingPython": "Opening file picker…", "lora.onboarding.systemCheck": "سیستم مورد نیاز", "lora.onboarding.title": "سبک نوشتن خود را آموزش دهید", - "lora.onboarding.selectPython": "انتخاب فایل اجرایی پایتون", "lora.presets.deepNarrative.desc": "سبک روایت کامل - 60 دقیقه، 12 گیگابایت VRAM", "lora.presets.deepNarrative.label": "روایت عمیق", "lora.presets.dialogueMaster.desc": "دیالوگ بهینه شده - ~45 دقیقه، 8 گیگابایت VRAM", diff --git a/locales/fi/lora.json b/locales/fi/lora.json index 2cf6a1e3..89e8b4e3 100644 --- a/locales/fi/lora.json +++ b/locales/fi/lora.json @@ -39,15 +39,34 @@ "lora.onboarding.cpuFallback": "CPU-varaus (hitaampi)", "lora.onboarding.description": "Luo henkilökohtainen tekoälymalli, joka on koulutettu käsikirjoituksiisi. Tietosi eivät koskaan poistu laitteestasi.", "lora.onboarding.envError": "Ympäristötarkastus epäonnistui. Varmista, että Python ja tarvittavat paketit on asennettu.", + "lora.onboarding.error.configurationPathUnavailable": "Could not access the app configuration folder.", + "lora.onboarding.error.configurationWriteFailed": "Could not save the selected Python path.", + "lora.onboarding.error.configuredPathEmpty": "No path was selected.", + "lora.onboarding.error.configuredPathNotAbsolute": "Path must be an absolute path to a Python executable.", + "lora.onboarding.error.executableNotFound": "That file is not a valid executable.", + "lora.onboarding.error.generic": "Selection failed. Please try a different Python executable.", + "lora.onboarding.error.helperExitNonzero": "The environment-check helper exited with an error.", + "lora.onboarding.error.helperReportParseFailed": "Could not read the environment-check results.", + "lora.onboarding.error.helperScriptMissing": "The environment-check helper script is missing.", + "lora.onboarding.error.helperSpawnFailed": "Could not run the environment-check helper.", + "lora.onboarding.error.helperTimedOut": "The environment check timed out.", + "lora.onboarding.error.incompatibleVersion": "Python 3.10 or newer is required.", + "lora.onboarding.error.permissionDenied": "Permission denied when running that executable.", + "lora.onboarding.error.processSpawnFailed": "Could not start that executable.", + "lora.onboarding.error.pythonProbeTaskFailed": "The Python check failed unexpectedly.", + "lora.onboarding.error.versionParseFailed": "Could not read the Python version output.", + "lora.onboarding.error.versionProbeFailed": "Could not determine the Python version.", + "lora.onboarding.error.versionProbeTimedOut": "Timed out while checking the Python version.", "lora.onboarding.getStarted": "Aloita", "lora.onboarding.installCmd": "pip install unsloth trl peft", "lora.onboarding.installed": "asennettu", "lora.onboarding.notFound": "ei löytynyt", "lora.onboarding.privacyDetail": "Kaikki koulutus tapahtuu paikallisesti. Käsikirjoitustasi ei koskaan lähetetä mihinkään pilvipalveluun.", "lora.onboarding.privacyPromise": "100% yksityinen ja offline-tilassa", + "lora.onboarding.selectPython": "Valitse Python-suoritustiedosto", + "lora.onboarding.selectingPython": "Opening file picker…", "lora.onboarding.systemCheck": "Järjestelmävaatimukset", "lora.onboarding.title": "Harjoittele kirjoitustyyliäsi", - "lora.onboarding.selectPython": "Valitse Python-suoritustiedosto", "lora.presets.deepNarrative.desc": "Täysi kerrontyyli – ~60 min, 12 Gt VRAM", "lora.presets.deepNarrative.label": "Syvä kerronta", "lora.presets.dialogueMaster.desc": "Dialogioptimoitu – ~45 min, 8 Gt VRAM", diff --git a/locales/fr/lora.json b/locales/fr/lora.json index 7595bc1a..8e2ff905 100644 --- a/locales/fr/lora.json +++ b/locales/fr/lora.json @@ -39,6 +39,25 @@ "lora.onboarding.cpuFallback": "Repli CPU (plus lent)", "lora.onboarding.description": "Créez un modèle IA personnalisé entraîné sur vos manuscrits. Vos données ne quittent jamais votre appareil.", "lora.onboarding.envError": "Échec de la vérification de l'environnement. Veuillez vous assurer que Python et les paquets requis sont installés.", + "lora.onboarding.error.configuredPathEmpty": "Aucun chemin n'a été sélectionné.", + "lora.onboarding.error.configuredPathNotAbsolute": "Le chemin doit être un chemin absolu vers un exécutable Python.", + "lora.onboarding.error.executableNotFound": "Ce fichier n'est pas un exécutable valide.", + "lora.onboarding.error.permissionDenied": "Permission refusée lors de l'exécution de ce fichier.", + "lora.onboarding.error.processSpawnFailed": "Impossible de démarrer cet exécutable.", + "lora.onboarding.error.versionProbeFailed": "Impossible de déterminer la version de Python.", + "lora.onboarding.error.versionParseFailed": "Impossible de lire la sortie de la version de Python.", + "lora.onboarding.error.incompatibleVersion": "Python 3.10 ou plus récent est requis.", + "lora.onboarding.error.versionProbeTimedOut": "Délai dépassé lors de la vérification de la version de Python.", + "lora.onboarding.error.pythonProbeTaskFailed": "La vérification de Python a échoué de manière inattendue.", + "lora.onboarding.error.configurationPathUnavailable": "Impossible d'accéder au dossier de configuration de l'application.", + "lora.onboarding.error.configurationWriteFailed": "Impossible d'enregistrer le chemin Python sélectionné.", + "lora.onboarding.error.helperScriptMissing": "Le script auxiliaire de vérification de l'environnement est manquant.", + "lora.onboarding.error.helperTimedOut": "La vérification de l'environnement a expiré.", + "lora.onboarding.error.helperSpawnFailed": "Impossible d'exécuter l'auxiliaire de vérification de l'environnement.", + "lora.onboarding.error.helperExitNonzero": "L'auxiliaire de vérification de l'environnement s'est terminé avec une erreur.", + "lora.onboarding.error.helperReportParseFailed": "Impossible de lire les résultats de la vérification de l'environnement.", + "lora.onboarding.error.generic": "La sélection a échoué. Essayez un autre exécutable Python.", + "lora.onboarding.selectingPython": "Ouverture du sélecteur de fichiers…", "lora.onboarding.getStarted": "Commencer", "lora.onboarding.installCmd": "pip install unsloth trl peft", "lora.onboarding.installed": "installé", diff --git a/locales/he/lora.json b/locales/he/lora.json index 9a7fee94..ec99d823 100644 --- a/locales/he/lora.json +++ b/locales/he/lora.json @@ -39,15 +39,34 @@ "lora.onboarding.cpuFallback": "חלופת מעבד (איטי יותר)", "lora.onboarding.description": "צרו מודל AI מותאם אישית המאומן על כתבי היד שלכם. הנתונים שלכם לעולם אינם עוזבים את המכשיר.", "lora.onboarding.envError": "בדיקת הסביבה נכשלה. אנא וודאו ש-Python והחבילות הנדרשות מותקנות.", + "lora.onboarding.error.configurationPathUnavailable": "Could not access the app configuration folder.", + "lora.onboarding.error.configurationWriteFailed": "Could not save the selected Python path.", + "lora.onboarding.error.configuredPathEmpty": "No path was selected.", + "lora.onboarding.error.configuredPathNotAbsolute": "Path must be an absolute path to a Python executable.", + "lora.onboarding.error.executableNotFound": "That file is not a valid executable.", + "lora.onboarding.error.generic": "Selection failed. Please try a different Python executable.", + "lora.onboarding.error.helperExitNonzero": "The environment-check helper exited with an error.", + "lora.onboarding.error.helperReportParseFailed": "Could not read the environment-check results.", + "lora.onboarding.error.helperScriptMissing": "The environment-check helper script is missing.", + "lora.onboarding.error.helperSpawnFailed": "Could not run the environment-check helper.", + "lora.onboarding.error.helperTimedOut": "The environment check timed out.", + "lora.onboarding.error.incompatibleVersion": "Python 3.10 or newer is required.", + "lora.onboarding.error.permissionDenied": "Permission denied when running that executable.", + "lora.onboarding.error.processSpawnFailed": "Could not start that executable.", + "lora.onboarding.error.pythonProbeTaskFailed": "The Python check failed unexpectedly.", + "lora.onboarding.error.versionParseFailed": "Could not read the Python version output.", + "lora.onboarding.error.versionProbeFailed": "Could not determine the Python version.", + "lora.onboarding.error.versionProbeTimedOut": "Timed out while checking the Python version.", "lora.onboarding.getStarted": "התחלה", "lora.onboarding.installCmd": "pip install unsloth trl peft", "lora.onboarding.installed": "מותקן", "lora.onboarding.notFound": "לא נמצא", "lora.onboarding.privacyDetail": "כל האימון מתרחש מקומית. כתב היד שלכם לעולם אינו נשלח לשירות ענן כלשהו.", "lora.onboarding.privacyPromise": "100% פרטי ולא מקוון", + "lora.onboarding.selectPython": "בחירת קובץ ההפעלה של Python", + "lora.onboarding.selectingPython": "Opening file picker…", "lora.onboarding.systemCheck": "דרישות מערכת", "lora.onboarding.title": "אמנו את סגנון הכתיבה שלכם", - "lora.onboarding.selectPython": "בחירת קובץ ההפעלה של Python", "lora.presets.deepNarrative.desc": "סגנון נרטיבי מלא — ~60 דק׳, 12 GB VRAM", "lora.presets.deepNarrative.label": "נרטיב עמוק", "lora.presets.dialogueMaster.desc": "מותאם לדיאלוג — ~45 דק׳, 8 GB VRAM", diff --git a/locales/hu/lora.json b/locales/hu/lora.json index 68575187..f12e6b9a 100644 --- a/locales/hu/lora.json +++ b/locales/hu/lora.json @@ -39,15 +39,34 @@ "lora.onboarding.cpuFallback": "CPU tartalék (lassabb)", "lora.onboarding.description": "Hozzon létre egy személyre szabott mesterséges intelligencia-modellt a kéziratai alapján. Adatai soha nem hagyják el az eszközt.", "lora.onboarding.envError": "A környezeti ellenőrzés sikertelen. Győződjön meg arról, hogy a Python és a szükséges csomagok telepítve vannak.", + "lora.onboarding.error.configurationPathUnavailable": "Could not access the app configuration folder.", + "lora.onboarding.error.configurationWriteFailed": "Could not save the selected Python path.", + "lora.onboarding.error.configuredPathEmpty": "No path was selected.", + "lora.onboarding.error.configuredPathNotAbsolute": "Path must be an absolute path to a Python executable.", + "lora.onboarding.error.executableNotFound": "That file is not a valid executable.", + "lora.onboarding.error.generic": "Selection failed. Please try a different Python executable.", + "lora.onboarding.error.helperExitNonzero": "The environment-check helper exited with an error.", + "lora.onboarding.error.helperReportParseFailed": "Could not read the environment-check results.", + "lora.onboarding.error.helperScriptMissing": "The environment-check helper script is missing.", + "lora.onboarding.error.helperSpawnFailed": "Could not run the environment-check helper.", + "lora.onboarding.error.helperTimedOut": "The environment check timed out.", + "lora.onboarding.error.incompatibleVersion": "Python 3.10 or newer is required.", + "lora.onboarding.error.permissionDenied": "Permission denied when running that executable.", + "lora.onboarding.error.processSpawnFailed": "Could not start that executable.", + "lora.onboarding.error.pythonProbeTaskFailed": "The Python check failed unexpectedly.", + "lora.onboarding.error.versionParseFailed": "Could not read the Python version output.", + "lora.onboarding.error.versionProbeFailed": "Could not determine the Python version.", + "lora.onboarding.error.versionProbeTimedOut": "Timed out while checking the Python version.", "lora.onboarding.getStarted": "Kezdje el", "lora.onboarding.installCmd": "pip install unsloth trl peft", "lora.onboarding.installed": "telepítve", "lora.onboarding.notFound": "nem található", "lora.onboarding.privacyDetail": "Minden képzés helyben történik. A kéziratot soha nem küldik el semmilyen felhőszolgáltatásnak.", "lora.onboarding.privacyPromise": "100% privát és offline", + "lora.onboarding.selectPython": "Python futtatható fájl kiválasztása", + "lora.onboarding.selectingPython": "Opening file picker…", "lora.onboarding.systemCheck": "Rendszerkövetelmények", "lora.onboarding.title": "Tanítsa meg írási stílusát", - "lora.onboarding.selectPython": "Python futtatható fájl kiválasztása", "lora.presets.deepNarrative.desc": "Teljes narratív stílus — ~60 perc, 12 GB VRAM", "lora.presets.deepNarrative.label": "Mély narratíva", "lora.presets.dialogueMaster.desc": "Párbeszédre optimalizált – ~45 perc, 8 GB VRAM", diff --git a/locales/is/lora.json b/locales/is/lora.json index 050499a3..472c2709 100644 --- a/locales/is/lora.json +++ b/locales/is/lora.json @@ -39,15 +39,34 @@ "lora.onboarding.cpuFallback": "CPU fallback (hægara)", "lora.onboarding.description": "Búðu til sérsniðið gervigreind líkan sem er þjálfað í handritum þínum. Gögnin þín fara aldrei úr tækinu þínu.", "lora.onboarding.envError": "Umhverfisathugun mistókst. Gakktu úr skugga um að Python og nauðsynlegir pakkar séu settir upp.", + "lora.onboarding.error.configurationPathUnavailable": "Could not access the app configuration folder.", + "lora.onboarding.error.configurationWriteFailed": "Could not save the selected Python path.", + "lora.onboarding.error.configuredPathEmpty": "No path was selected.", + "lora.onboarding.error.configuredPathNotAbsolute": "Path must be an absolute path to a Python executable.", + "lora.onboarding.error.executableNotFound": "That file is not a valid executable.", + "lora.onboarding.error.generic": "Selection failed. Please try a different Python executable.", + "lora.onboarding.error.helperExitNonzero": "The environment-check helper exited with an error.", + "lora.onboarding.error.helperReportParseFailed": "Could not read the environment-check results.", + "lora.onboarding.error.helperScriptMissing": "The environment-check helper script is missing.", + "lora.onboarding.error.helperSpawnFailed": "Could not run the environment-check helper.", + "lora.onboarding.error.helperTimedOut": "The environment check timed out.", + "lora.onboarding.error.incompatibleVersion": "Python 3.10 or newer is required.", + "lora.onboarding.error.permissionDenied": "Permission denied when running that executable.", + "lora.onboarding.error.processSpawnFailed": "Could not start that executable.", + "lora.onboarding.error.pythonProbeTaskFailed": "The Python check failed unexpectedly.", + "lora.onboarding.error.versionParseFailed": "Could not read the Python version output.", + "lora.onboarding.error.versionProbeFailed": "Could not determine the Python version.", + "lora.onboarding.error.versionProbeTimedOut": "Timed out while checking the Python version.", "lora.onboarding.getStarted": "Byrjaðu", "lora.onboarding.installCmd": "pip setja unsloth trl peft", "lora.onboarding.installed": "uppsett", "lora.onboarding.notFound": "ekki fundið", "lora.onboarding.privacyDetail": "Öll þjálfun fer fram á staðnum. Handritið þitt er aldrei sent til neinnar skýjaþjónustu.", "lora.onboarding.privacyPromise": "100% einkamál og án nettengingar", + "lora.onboarding.selectPython": "Veldu Python keyrsluskrá", + "lora.onboarding.selectingPython": "Opening file picker…", "lora.onboarding.systemCheck": "Kerfiskröfur", "lora.onboarding.title": "Þjálfa ritstílinn þinn", - "lora.onboarding.selectPython": "Veldu Python keyrsluskrá", "lora.presets.deepNarrative.desc": "Fullur frásagnarstíll — ~60 mín., 12 GB VRAM", "lora.presets.deepNarrative.label": "Djúp frásögn", "lora.presets.dialogueMaster.desc": "Samræðubjartað — ~45 mín., 8 GB VRAM", diff --git a/locales/it/lora.json b/locales/it/lora.json index b865b1f2..97b9c1fc 100644 --- a/locales/it/lora.json +++ b/locales/it/lora.json @@ -39,6 +39,25 @@ "lora.onboarding.cpuFallback": "Fallback CPU (più lento)", "lora.onboarding.description": "Crea un modello IA personalizzato addestrato sui tuoi manoscritti. I tuoi dati non lasciano mai il tuo dispositivo.", "lora.onboarding.envError": "Controllo ambiente fallito. Assicurati che Python e i pacchetti richiesti siano installati.", + "lora.onboarding.error.configuredPathEmpty": "Non è stato selezionato alcun percorso.", + "lora.onboarding.error.configuredPathNotAbsolute": "Il percorso deve essere un percorso assoluto verso un eseguibile Python.", + "lora.onboarding.error.executableNotFound": "Quel file non è un eseguibile valido.", + "lora.onboarding.error.permissionDenied": "Permesso negato durante l'esecuzione di quel file.", + "lora.onboarding.error.processSpawnFailed": "Impossibile avviare quell'eseguibile.", + "lora.onboarding.error.versionProbeFailed": "Impossibile determinare la versione di Python.", + "lora.onboarding.error.versionParseFailed": "Impossibile leggere l'output della versione di Python.", + "lora.onboarding.error.incompatibleVersion": "È richiesto Python 3.10 o più recente.", + "lora.onboarding.error.versionProbeTimedOut": "Timeout durante il controllo della versione di Python.", + "lora.onboarding.error.pythonProbeTaskFailed": "Il controllo di Python non è riuscito in modo imprevisto.", + "lora.onboarding.error.configurationPathUnavailable": "Impossibile accedere alla cartella di configurazione dell'app.", + "lora.onboarding.error.configurationWriteFailed": "Impossibile salvare il percorso Python selezionato.", + "lora.onboarding.error.helperScriptMissing": "Manca lo script di supporto per il controllo dell'ambiente.", + "lora.onboarding.error.helperTimedOut": "Il controllo dell'ambiente è scaduto.", + "lora.onboarding.error.helperSpawnFailed": "Impossibile eseguire lo script di supporto per il controllo dell'ambiente.", + "lora.onboarding.error.helperExitNonzero": "Lo script di supporto per il controllo dell'ambiente è terminato con un errore.", + "lora.onboarding.error.helperReportParseFailed": "Impossibile leggere i risultati del controllo dell'ambiente.", + "lora.onboarding.error.generic": "Selezione non riuscita. Prova un altro eseguibile Python.", + "lora.onboarding.selectingPython": "Apertura selezione file…", "lora.onboarding.getStarted": "Inizia", "lora.onboarding.installCmd": "pip install unsloth trl peft", "lora.onboarding.installed": "installato", diff --git a/locales/ja/lora.json b/locales/ja/lora.json index dfa4649b..5d5392de 100644 --- a/locales/ja/lora.json +++ b/locales/ja/lora.json @@ -39,15 +39,34 @@ "lora.onboarding.cpuFallback": "CPU フォールバック (低速)", "lora.onboarding.description": "作成 a personalized AI model trained on your manuscripts. Your data never leaves your device.", "lora.onboarding.envError": "環境チェックに失敗しました。 Python と必要なパッケージがインストールされていることを確認してください。", + "lora.onboarding.error.configurationPathUnavailable": "Could not access the app configuration folder.", + "lora.onboarding.error.configurationWriteFailed": "Could not save the selected Python path.", + "lora.onboarding.error.configuredPathEmpty": "No path was selected.", + "lora.onboarding.error.configuredPathNotAbsolute": "Path must be an absolute path to a Python executable.", + "lora.onboarding.error.executableNotFound": "That file is not a valid executable.", + "lora.onboarding.error.generic": "Selection failed. Please try a different Python executable.", + "lora.onboarding.error.helperExitNonzero": "The environment-check helper exited with an error.", + "lora.onboarding.error.helperReportParseFailed": "Could not read the environment-check results.", + "lora.onboarding.error.helperScriptMissing": "The environment-check helper script is missing.", + "lora.onboarding.error.helperSpawnFailed": "Could not run the environment-check helper.", + "lora.onboarding.error.helperTimedOut": "The environment check timed out.", + "lora.onboarding.error.incompatibleVersion": "Python 3.10 or newer is required.", + "lora.onboarding.error.permissionDenied": "Permission denied when running that executable.", + "lora.onboarding.error.processSpawnFailed": "Could not start that executable.", + "lora.onboarding.error.pythonProbeTaskFailed": "The Python check failed unexpectedly.", + "lora.onboarding.error.versionParseFailed": "Could not read the Python version output.", + "lora.onboarding.error.versionProbeFailed": "Could not determine the Python version.", + "lora.onboarding.error.versionProbeTimedOut": "Timed out while checking the Python version.", "lora.onboarding.getStarted": "始めましょう", "lora.onboarding.installCmd": "pip install unsloth trl peft", "lora.onboarding.installed": "インストールされています", "lora.onboarding.notFound": "見つかりません", "lora.onboarding.privacyDetail": "トレーニングはすべてローカルで行われます。あなたの原稿がクラウド サービスに送信されることはありません。", "lora.onboarding.privacyPromise": "100% プライベート&オフライン", + "lora.onboarding.selectPython": "Python 実行ファイルを選択", + "lora.onboarding.selectingPython": "Opening file picker…", "lora.onboarding.systemCheck": "システム要件", "lora.onboarding.title": "文章のスタイルを訓練する", - "lora.onboarding.selectPython": "Python 実行ファイルを選択", "lora.presets.deepNarrative.desc": "完全なナラティブ スタイル — ~60 分、12 GB VRAM", "lora.presets.deepNarrative.label": "深い物語", "lora.presets.dialogueMaster.desc": "ダイアログに最適化 — 約 45 分、8 GB VRAM", diff --git a/locales/ko/lora.json b/locales/ko/lora.json index 80bc7093..7ed91ba5 100644 --- a/locales/ko/lora.json +++ b/locales/ko/lora.json @@ -39,15 +39,34 @@ "lora.onboarding.cpuFallback": "CPU 대체(느림)", "lora.onboarding.description": "귀하의 원고에 대해 훈련된 맞춤형 AI 모델을 만드십시오. 귀하의 데이터는 귀하의 장치를 떠나지 않습니다.", "lora.onboarding.envError": "환경 확인에 실패했습니다. Python과 필수 패키지가 설치되어 있는지 확인하세요.", + "lora.onboarding.error.configurationPathUnavailable": "Could not access the app configuration folder.", + "lora.onboarding.error.configurationWriteFailed": "Could not save the selected Python path.", + "lora.onboarding.error.configuredPathEmpty": "No path was selected.", + "lora.onboarding.error.configuredPathNotAbsolute": "Path must be an absolute path to a Python executable.", + "lora.onboarding.error.executableNotFound": "That file is not a valid executable.", + "lora.onboarding.error.generic": "Selection failed. Please try a different Python executable.", + "lora.onboarding.error.helperExitNonzero": "The environment-check helper exited with an error.", + "lora.onboarding.error.helperReportParseFailed": "Could not read the environment-check results.", + "lora.onboarding.error.helperScriptMissing": "The environment-check helper script is missing.", + "lora.onboarding.error.helperSpawnFailed": "Could not run the environment-check helper.", + "lora.onboarding.error.helperTimedOut": "The environment check timed out.", + "lora.onboarding.error.incompatibleVersion": "Python 3.10 or newer is required.", + "lora.onboarding.error.permissionDenied": "Permission denied when running that executable.", + "lora.onboarding.error.processSpawnFailed": "Could not start that executable.", + "lora.onboarding.error.pythonProbeTaskFailed": "The Python check failed unexpectedly.", + "lora.onboarding.error.versionParseFailed": "Could not read the Python version output.", + "lora.onboarding.error.versionProbeFailed": "Could not determine the Python version.", + "lora.onboarding.error.versionProbeTimedOut": "Timed out while checking the Python version.", "lora.onboarding.getStarted": "시작하기", "lora.onboarding.installCmd": "pip 설치 unsloth trl peft", "lora.onboarding.installed": "설치됨", "lora.onboarding.notFound": "찾을 수 없음", "lora.onboarding.privacyDetail": "모든 훈련은 현지에서 이루어집니다. 귀하의 원고는 어떤 클라우드 서비스로도 전송되지 않습니다.", "lora.onboarding.privacyPromise": "100% 비공개 및 오프라인", + "lora.onboarding.selectPython": "Python 실행 파일 선택", + "lora.onboarding.selectingPython": "Opening file picker…", "lora.onboarding.systemCheck": "시스템 요구 사항", "lora.onboarding.title": "글쓰기 스타일을 훈련하세요", - "lora.onboarding.selectPython": "Python 실행 파일 선택", "lora.presets.deepNarrative.desc": "전체 내러티브 스타일 — ~60분, 12GB VRAM", "lora.presets.deepNarrative.label": "깊은 내러티브", "lora.presets.dialogueMaster.desc": "대화 최적화 — ~45분, 8GB VRAM", diff --git a/locales/pt/lora.json b/locales/pt/lora.json index 365e0e0e..7e065c5b 100644 --- a/locales/pt/lora.json +++ b/locales/pt/lora.json @@ -39,15 +39,34 @@ "lora.onboarding.cpuFallback": "Fallback de CPU (mais lento)", "lora.onboarding.description": "Criar a personalized IA model trained on your manuscripts. Your data never leaves your device.", "lora.onboarding.envError": "A verificação do ambiente falhou. Certifique-se de que o Python e os pacotes necessários estejam instalados.", + "lora.onboarding.error.configurationPathUnavailable": "Could not access the app configuration folder.", + "lora.onboarding.error.configurationWriteFailed": "Could not save the selected Python path.", + "lora.onboarding.error.configuredPathEmpty": "No path was selected.", + "lora.onboarding.error.configuredPathNotAbsolute": "Path must be an absolute path to a Python executable.", + "lora.onboarding.error.executableNotFound": "That file is not a valid executable.", + "lora.onboarding.error.generic": "Selection failed. Please try a different Python executable.", + "lora.onboarding.error.helperExitNonzero": "The environment-check helper exited with an error.", + "lora.onboarding.error.helperReportParseFailed": "Could not read the environment-check results.", + "lora.onboarding.error.helperScriptMissing": "The environment-check helper script is missing.", + "lora.onboarding.error.helperSpawnFailed": "Could not run the environment-check helper.", + "lora.onboarding.error.helperTimedOut": "The environment check timed out.", + "lora.onboarding.error.incompatibleVersion": "Python 3.10 or newer is required.", + "lora.onboarding.error.permissionDenied": "Permission denied when running that executable.", + "lora.onboarding.error.processSpawnFailed": "Could not start that executable.", + "lora.onboarding.error.pythonProbeTaskFailed": "The Python check failed unexpectedly.", + "lora.onboarding.error.versionParseFailed": "Could not read the Python version output.", + "lora.onboarding.error.versionProbeFailed": "Could not determine the Python version.", + "lora.onboarding.error.versionProbeTimedOut": "Timed out while checking the Python version.", "lora.onboarding.getStarted": "Comece", "lora.onboarding.installCmd": "pip instalar sem preguiça trl peft", "lora.onboarding.installed": "instalado", "lora.onboarding.notFound": "não encontrado", "lora.onboarding.privacyDetail": "Todo o treinamento acontece localmente. Seu manuscrito nunca é enviado para nenhum serviço em nuvem.", "lora.onboarding.privacyPromise": "100% privado e off-line", + "lora.onboarding.selectPython": "Escolher executável Python", + "lora.onboarding.selectingPython": "Opening file picker…", "lora.onboarding.systemCheck": "Requisitos do sistema", "lora.onboarding.title": "Treine seu estilo de escrita", - "lora.onboarding.selectPython": "Escolher executável Python", "lora.presets.deepNarrative.desc": "Estilo narrativo completo — ~60 min, 12 GB VRAM", "lora.presets.deepNarrative.label": "Narrativa Profunda", "lora.presets.dialogueMaster.desc": "Otimizado para diálogo – ~45 min, 8 GB VRAM", diff --git a/locales/ru/lora.json b/locales/ru/lora.json index 720837cb..fb26a723 100644 --- a/locales/ru/lora.json +++ b/locales/ru/lora.json @@ -39,15 +39,34 @@ "lora.onboarding.cpuFallback": "Резервный процессор ЦП (медленнее)", "lora.onboarding.description": "Создайте персонализированную модель искусственного интеллекта, обученную на ваших рукописях. Ваши данные никогда не покидают ваше устройство.", "lora.onboarding.envError": "Проверка среды не удалась. Убедитесь, что Python и необходимые пакеты установлены.", + "lora.onboarding.error.configurationPathUnavailable": "Could not access the app configuration folder.", + "lora.onboarding.error.configurationWriteFailed": "Could not save the selected Python path.", + "lora.onboarding.error.configuredPathEmpty": "No path was selected.", + "lora.onboarding.error.configuredPathNotAbsolute": "Path must be an absolute path to a Python executable.", + "lora.onboarding.error.executableNotFound": "That file is not a valid executable.", + "lora.onboarding.error.generic": "Selection failed. Please try a different Python executable.", + "lora.onboarding.error.helperExitNonzero": "The environment-check helper exited with an error.", + "lora.onboarding.error.helperReportParseFailed": "Could not read the environment-check results.", + "lora.onboarding.error.helperScriptMissing": "The environment-check helper script is missing.", + "lora.onboarding.error.helperSpawnFailed": "Could not run the environment-check helper.", + "lora.onboarding.error.helperTimedOut": "The environment check timed out.", + "lora.onboarding.error.incompatibleVersion": "Python 3.10 or newer is required.", + "lora.onboarding.error.permissionDenied": "Permission denied when running that executable.", + "lora.onboarding.error.processSpawnFailed": "Could not start that executable.", + "lora.onboarding.error.pythonProbeTaskFailed": "The Python check failed unexpectedly.", + "lora.onboarding.error.versionParseFailed": "Could not read the Python version output.", + "lora.onboarding.error.versionProbeFailed": "Could not determine the Python version.", + "lora.onboarding.error.versionProbeTimedOut": "Timed out while checking the Python version.", "lora.onboarding.getStarted": "Начать", "lora.onboarding.installCmd": "pip install unsloth trl peft", "lora.onboarding.installed": "установлен", "lora.onboarding.notFound": "не найдено", "lora.onboarding.privacyDetail": "Все обучение происходит локально. Ваша рукопись никогда не отправляется в какой-либо облачный сервис.", "lora.onboarding.privacyPromise": "100% конфиденциальность и офлайн", + "lora.onboarding.selectPython": "Выбрать исполняемый файл Python", + "lora.onboarding.selectingPython": "Opening file picker…", "lora.onboarding.systemCheck": "Системные требования", "lora.onboarding.title": "Тренируйте свой стиль письма", - "lora.onboarding.selectPython": "Выбрать исполняемый файл Python", "lora.presets.deepNarrative.desc": "Полный стиль повествования — ~60 мин, 12 ГБ видеопамяти", "lora.presets.deepNarrative.label": "Глубокое повествование", "lora.presets.dialogueMaster.desc": "Оптимизация для диалогов — ~45 минут, 8 ГБ видеопамяти", diff --git a/locales/sv/lora.json b/locales/sv/lora.json index 1a69a0b2..62ab41c2 100644 --- a/locales/sv/lora.json +++ b/locales/sv/lora.json @@ -39,15 +39,34 @@ "lora.onboarding.cpuFallback": "CPU fallback (långsammare)", "lora.onboarding.description": "Skapa en personlig AI-modell tränad på dina manuskript. Din data lämnar aldrig din enhet.", "lora.onboarding.envError": "Omgivningskontrollen misslyckades. Se till att Python och nödvändiga paket är installerade.", + "lora.onboarding.error.configurationPathUnavailable": "Could not access the app configuration folder.", + "lora.onboarding.error.configurationWriteFailed": "Could not save the selected Python path.", + "lora.onboarding.error.configuredPathEmpty": "No path was selected.", + "lora.onboarding.error.configuredPathNotAbsolute": "Path must be an absolute path to a Python executable.", + "lora.onboarding.error.executableNotFound": "That file is not a valid executable.", + "lora.onboarding.error.generic": "Selection failed. Please try a different Python executable.", + "lora.onboarding.error.helperExitNonzero": "The environment-check helper exited with an error.", + "lora.onboarding.error.helperReportParseFailed": "Could not read the environment-check results.", + "lora.onboarding.error.helperScriptMissing": "The environment-check helper script is missing.", + "lora.onboarding.error.helperSpawnFailed": "Could not run the environment-check helper.", + "lora.onboarding.error.helperTimedOut": "The environment check timed out.", + "lora.onboarding.error.incompatibleVersion": "Python 3.10 or newer is required.", + "lora.onboarding.error.permissionDenied": "Permission denied when running that executable.", + "lora.onboarding.error.processSpawnFailed": "Could not start that executable.", + "lora.onboarding.error.pythonProbeTaskFailed": "The Python check failed unexpectedly.", + "lora.onboarding.error.versionParseFailed": "Could not read the Python version output.", + "lora.onboarding.error.versionProbeFailed": "Could not determine the Python version.", + "lora.onboarding.error.versionProbeTimedOut": "Timed out while checking the Python version.", "lora.onboarding.getStarted": "Kom igång", "lora.onboarding.installCmd": "pip installera unloth trl peft", "lora.onboarding.installed": "installerat", "lora.onboarding.notFound": "hittades inte", "lora.onboarding.privacyDetail": "All träning sker lokalt. Ditt manuskript skickas aldrig till någon molntjänst.", "lora.onboarding.privacyPromise": "100 % privat och offline", + "lora.onboarding.selectPython": "Välj Python-körbar fil", + "lora.onboarding.selectingPython": "Opening file picker…", "lora.onboarding.systemCheck": "Systemkrav", "lora.onboarding.title": "Träna din skrivstil", - "lora.onboarding.selectPython": "Välj Python-körbar fil", "lora.presets.deepNarrative.desc": "Fullständig berättarstil — ~60 min, 12 GB VRAM", "lora.presets.deepNarrative.label": "Djup berättelse", "lora.presets.dialogueMaster.desc": "Dialogoptimerad — ~45 min, 8 GB VRAM", diff --git a/locales/zh/lora.json b/locales/zh/lora.json index cd7c5ec7..a5d1f786 100644 --- a/locales/zh/lora.json +++ b/locales/zh/lora.json @@ -39,15 +39,34 @@ "lora.onboarding.cpuFallback": "CPU 回退(较慢)", "lora.onboarding.description": "创建 a personalized AI model trained on your manuscripts. Your data never leaves your device.", "lora.onboarding.envError": "环境检查失败。请确保安装了 Python 和所需的包。", + "lora.onboarding.error.configurationPathUnavailable": "Could not access the app configuration folder.", + "lora.onboarding.error.configurationWriteFailed": "Could not save the selected Python path.", + "lora.onboarding.error.configuredPathEmpty": "No path was selected.", + "lora.onboarding.error.configuredPathNotAbsolute": "Path must be an absolute path to a Python executable.", + "lora.onboarding.error.executableNotFound": "That file is not a valid executable.", + "lora.onboarding.error.generic": "Selection failed. Please try a different Python executable.", + "lora.onboarding.error.helperExitNonzero": "The environment-check helper exited with an error.", + "lora.onboarding.error.helperReportParseFailed": "Could not read the environment-check results.", + "lora.onboarding.error.helperScriptMissing": "The environment-check helper script is missing.", + "lora.onboarding.error.helperSpawnFailed": "Could not run the environment-check helper.", + "lora.onboarding.error.helperTimedOut": "The environment check timed out.", + "lora.onboarding.error.incompatibleVersion": "Python 3.10 or newer is required.", + "lora.onboarding.error.permissionDenied": "Permission denied when running that executable.", + "lora.onboarding.error.processSpawnFailed": "Could not start that executable.", + "lora.onboarding.error.pythonProbeTaskFailed": "The Python check failed unexpectedly.", + "lora.onboarding.error.versionParseFailed": "Could not read the Python version output.", + "lora.onboarding.error.versionProbeFailed": "Could not determine the Python version.", + "lora.onboarding.error.versionProbeTimedOut": "Timed out while checking the Python version.", "lora.onboarding.getStarted": "开始使用", "lora.onboarding.installCmd": "pip install unsloth trl peft", "lora.onboarding.installed": "已安装", "lora.onboarding.notFound": "未找到", "lora.onboarding.privacyDetail": "所有培训均在本地进行。您的手稿永远不会发送到任何云服务。", "lora.onboarding.privacyPromise": "100% 私密且离线", + "lora.onboarding.selectPython": "选择 Python 可执行文件", + "lora.onboarding.selectingPython": "Opening file picker…", "lora.onboarding.systemCheck": "系统要求", "lora.onboarding.title": "训练你的写作风格", - "lora.onboarding.selectPython": "选择 Python 可执行文件", "lora.presets.deepNarrative.desc": "完整的叙事风格 — 约 60 分钟,12 GB VRAM", "lora.presets.deepNarrative.label": "深度叙事", "lora.presets.dialogueMaster.desc": "对话优化 — 约 45 分钟,8 GB VRAM", diff --git a/public/locales/ar/bundle.json b/public/locales/ar/bundle.json index 8a81ca11..866a9918 100644 --- a/public/locales/ar/bundle.json +++ b/public/locales/ar/bundle.json @@ -1307,12 +1307,32 @@ "lora.onboarding.cpuFallback": "الاحتياطي عبر المعالج (أبطأ)", "lora.onboarding.description": "أنشئ نموذج ذكاء اصطناعي مخصّصًا مُدرّبًا على مخطوطاتك. بياناتك لا تغادر جهازك أبدًا.", "lora.onboarding.envError": "فشل التحقق من البيئة. يرجى التأكد من تثبيت Python والحزم المطلوبة.", + "lora.onboarding.error.configurationPathUnavailable": "Could not access the app configuration folder.", + "lora.onboarding.error.configurationWriteFailed": "Could not save the selected Python path.", + "lora.onboarding.error.configuredPathEmpty": "No path was selected.", + "lora.onboarding.error.configuredPathNotAbsolute": "Path must be an absolute path to a Python executable.", + "lora.onboarding.error.executableNotFound": "That file is not a valid executable.", + "lora.onboarding.error.generic": "Selection failed. Please try a different Python executable.", + "lora.onboarding.error.helperExitNonzero": "The environment-check helper exited with an error.", + "lora.onboarding.error.helperReportParseFailed": "Could not read the environment-check results.", + "lora.onboarding.error.helperScriptMissing": "The environment-check helper script is missing.", + "lora.onboarding.error.helperSpawnFailed": "Could not run the environment-check helper.", + "lora.onboarding.error.helperTimedOut": "The environment check timed out.", + "lora.onboarding.error.incompatibleVersion": "Python 3.10 or newer is required.", + "lora.onboarding.error.permissionDenied": "Permission denied when running that executable.", + "lora.onboarding.error.processSpawnFailed": "Could not start that executable.", + "lora.onboarding.error.pythonProbeTaskFailed": "The Python check failed unexpectedly.", + "lora.onboarding.error.versionParseFailed": "Could not read the Python version output.", + "lora.onboarding.error.versionProbeFailed": "Could not determine the Python version.", + "lora.onboarding.error.versionProbeTimedOut": "Timed out while checking the Python version.", "lora.onboarding.getStarted": "ابدأ الآن", "lora.onboarding.installCmd": "pip install unsloth trl peft", "lora.onboarding.installed": "مُثبَّت", "lora.onboarding.notFound": "غير موجود", "lora.onboarding.privacyDetail": "يجري كل التدريب محليًا. لا تُرسَل مخطوطتك أبدًا إلى أي خدمة سحابية.", "lora.onboarding.privacyPromise": "خاص وغير متصل بنسبة 100%", + "lora.onboarding.selectPython": "اختيار ملف Python التنفيذي", + "lora.onboarding.selectingPython": "Opening file picker…", "lora.onboarding.systemCheck": "متطلبات النظام", "lora.onboarding.title": "درّب أسلوب كتابتك", "lora.presets.deepNarrative.desc": "أسلوب سردي كامل — ~60 دقيقة، 12 غيغابايت ذاكرة رسوميات", diff --git a/public/locales/de/bundle.json b/public/locales/de/bundle.json index 83524d57..9c593dd6 100644 --- a/public/locales/de/bundle.json +++ b/public/locales/de/bundle.json @@ -1307,6 +1307,25 @@ "lora.onboarding.cpuFallback": "CPU-Fallback (langsamer)", "lora.onboarding.description": "Erstelle ein personalisiertes KI-Modell, das auf deinen Manuskripten trainiert wurde. Deine Daten verlassen niemals dein Gerät.", "lora.onboarding.envError": "Umgebungsprüfung fehlgeschlagen. Bitte stelle sicher, dass Python und erforderliche Pakete installiert sind.", + "lora.onboarding.error.configuredPathEmpty": "Es wurde kein Pfad ausgewählt.", + "lora.onboarding.error.configuredPathNotAbsolute": "Der Pfad muss ein absoluter Pfad zu einer Python-Anwendung sein.", + "lora.onboarding.error.executableNotFound": "Diese Datei ist keine gültige ausführbare Datei.", + "lora.onboarding.error.permissionDenied": "Zugriff auf diese Anwendung wurde verweigert.", + "lora.onboarding.error.processSpawnFailed": "Diese Anwendung konnte nicht gestartet werden.", + "lora.onboarding.error.versionProbeFailed": "Die Python-Version konnte nicht ermittelt werden.", + "lora.onboarding.error.versionParseFailed": "Die Ausgabe der Python-Version konnte nicht gelesen werden.", + "lora.onboarding.error.incompatibleVersion": "Python 3.10 oder neuer ist erforderlich.", + "lora.onboarding.error.versionProbeTimedOut": "Zeitüberschreitung bei der Prüfung der Python-Version.", + "lora.onboarding.error.pythonProbeTaskFailed": "Die Python-Prüfung ist unerwartet fehlgeschlagen.", + "lora.onboarding.error.configurationPathUnavailable": "Auf den Konfigurationsordner der App konnte nicht zugegriffen werden.", + "lora.onboarding.error.configurationWriteFailed": "Der ausgewählte Python-Pfad konnte nicht gespeichert werden.", + "lora.onboarding.error.helperScriptMissing": "Das Hilfsskript für die Umgebungsprüfung fehlt.", + "lora.onboarding.error.helperTimedOut": "Zeitüberschreitung bei der Umgebungsprüfung.", + "lora.onboarding.error.helperSpawnFailed": "Das Hilfsskript für die Umgebungsprüfung konnte nicht gestartet werden.", + "lora.onboarding.error.helperExitNonzero": "Das Hilfsskript für die Umgebungsprüfung wurde mit einem Fehler beendet.", + "lora.onboarding.error.helperReportParseFailed": "Die Ergebnisse der Umgebungsprüfung konnten nicht gelesen werden.", + "lora.onboarding.error.generic": "Auswahl fehlgeschlagen. Bitte wähle eine andere Python-Anwendung.", + "lora.onboarding.selectingPython": "Dateiauswahl wird geöffnet…", "lora.onboarding.getStarted": "Loslegen", "lora.onboarding.installCmd": "pip install unsloth trl peft", "lora.onboarding.installed": "installiert", @@ -1315,6 +1334,7 @@ "lora.onboarding.privacyPromise": "100% Privat & Offline", "lora.onboarding.systemCheck": "Systemanforderungen", "lora.onboarding.title": "Deinen Schreibstil trainieren", + "lora.onboarding.selectPython": "Python-Programm auswählen", "lora.presets.deepNarrative.desc": "Vollständiger Erzählstil — ~60 Min., 12 GB VRAM", "lora.presets.deepNarrative.label": "Tiefe Erzählung", "lora.presets.dialogueMaster.desc": "Dialogoptimiert — ~45 Min., 8 GB VRAM", diff --git a/public/locales/el/bundle.json b/public/locales/el/bundle.json index 8f8bfe25..87fbc1c4 100644 --- a/public/locales/el/bundle.json +++ b/public/locales/el/bundle.json @@ -1307,12 +1307,32 @@ "lora.onboarding.cpuFallback": "Εναλλακτικό CPU (πιο αργό)", "lora.onboarding.description": "Δημιουργία a personalized AI model trained on your manuscripts. Your data never leaves your device.", "lora.onboarding.envError": "Ο έλεγχος περιβάλλοντος απέτυχε. Βεβαιωθείτε ότι έχουν εγκατασταθεί η Python και τα απαιτούμενα πακέτα.", + "lora.onboarding.error.configurationPathUnavailable": "Could not access the app configuration folder.", + "lora.onboarding.error.configurationWriteFailed": "Could not save the selected Python path.", + "lora.onboarding.error.configuredPathEmpty": "No path was selected.", + "lora.onboarding.error.configuredPathNotAbsolute": "Path must be an absolute path to a Python executable.", + "lora.onboarding.error.executableNotFound": "That file is not a valid executable.", + "lora.onboarding.error.generic": "Selection failed. Please try a different Python executable.", + "lora.onboarding.error.helperExitNonzero": "The environment-check helper exited with an error.", + "lora.onboarding.error.helperReportParseFailed": "Could not read the environment-check results.", + "lora.onboarding.error.helperScriptMissing": "The environment-check helper script is missing.", + "lora.onboarding.error.helperSpawnFailed": "Could not run the environment-check helper.", + "lora.onboarding.error.helperTimedOut": "The environment check timed out.", + "lora.onboarding.error.incompatibleVersion": "Python 3.10 or newer is required.", + "lora.onboarding.error.permissionDenied": "Permission denied when running that executable.", + "lora.onboarding.error.processSpawnFailed": "Could not start that executable.", + "lora.onboarding.error.pythonProbeTaskFailed": "The Python check failed unexpectedly.", + "lora.onboarding.error.versionParseFailed": "Could not read the Python version output.", + "lora.onboarding.error.versionProbeFailed": "Could not determine the Python version.", + "lora.onboarding.error.versionProbeTimedOut": "Timed out while checking the Python version.", "lora.onboarding.getStarted": "Ξεκινήστε", "lora.onboarding.installCmd": "pip install unsloth trl peft", "lora.onboarding.installed": "εγκατασταθεί", "lora.onboarding.notFound": "δεν βρέθηκε", "lora.onboarding.privacyDetail": "Όλες οι προπονήσεις γίνονται τοπικά. Το χειρόγραφό σας δεν αποστέλλεται ποτέ σε καμία υπηρεσία cloud.", "lora.onboarding.privacyPromise": "100% Ιδιωτικό & Εκτός σύνδεσης", + "lora.onboarding.selectPython": "Επιλέξτε εκτελέσιμο Python", + "lora.onboarding.selectingPython": "Opening file picker…", "lora.onboarding.systemCheck": "Απαιτήσεις συστήματος", "lora.onboarding.title": "Εκπαιδεύστε το στυλ γραφής σας", "lora.presets.deepNarrative.desc": "Πλήρες στυλ αφήγησης — ~60 λεπτά, 12 GB VRAM", diff --git a/public/locales/en/bundle.json b/public/locales/en/bundle.json index 4d38e666..941368e2 100644 --- a/public/locales/en/bundle.json +++ b/public/locales/en/bundle.json @@ -1269,6 +1269,7 @@ "help.writing.writer.title": "AI Writing Studio workflow", "lora.title": "LoRA Fine-Tuning", "lora.onboarding.title": "Train Your Writing Style", + "lora.onboarding.selectPython": "Choose Python executable", "lora.onboarding.description": "Create a personalized AI model trained on your manuscripts. Your data never leaves your device.", "lora.onboarding.privacyPromise": "100% Private & Offline", "lora.onboarding.privacyDetail": "All training happens locally. Your manuscript is never sent to any cloud service.", @@ -1280,6 +1281,25 @@ "lora.onboarding.cpuFallback": "CPU fallback (slower)", "lora.onboarding.getStarted": "Get Started", "lora.onboarding.envError": "Environment check failed. Please ensure Python and required packages are installed.", + "lora.onboarding.selectingPython": "Opening file picker…", + "lora.onboarding.error.configuredPathEmpty": "No path was selected.", + "lora.onboarding.error.configuredPathNotAbsolute": "Path must be an absolute path to a Python executable.", + "lora.onboarding.error.executableNotFound": "That file is not a valid executable.", + "lora.onboarding.error.permissionDenied": "Permission denied when running that executable.", + "lora.onboarding.error.processSpawnFailed": "Could not start that executable.", + "lora.onboarding.error.versionProbeFailed": "Could not determine the Python version.", + "lora.onboarding.error.versionParseFailed": "Could not read the Python version output.", + "lora.onboarding.error.incompatibleVersion": "Python 3.10 or newer is required.", + "lora.onboarding.error.versionProbeTimedOut": "Timed out while checking the Python version.", + "lora.onboarding.error.pythonProbeTaskFailed": "The Python check failed unexpectedly.", + "lora.onboarding.error.configurationPathUnavailable": "Could not access the app configuration folder.", + "lora.onboarding.error.configurationWriteFailed": "Could not save the selected Python path.", + "lora.onboarding.error.helperScriptMissing": "The environment-check helper script is missing.", + "lora.onboarding.error.helperTimedOut": "The environment check timed out.", + "lora.onboarding.error.helperSpawnFailed": "Could not run the environment-check helper.", + "lora.onboarding.error.helperExitNonzero": "The environment-check helper exited with an error.", + "lora.onboarding.error.helperReportParseFailed": "Could not read the environment-check results.", + "lora.onboarding.error.generic": "Selection failed. Please try a different Python executable.", "lora.library.title": "Adapter Library", "lora.library.empty": "No adapters yet. Train your first style model to get started.", "lora.wizard.title": "Training Wizard", diff --git a/public/locales/es/bundle.json b/public/locales/es/bundle.json index 91555956..9810f96e 100644 --- a/public/locales/es/bundle.json +++ b/public/locales/es/bundle.json @@ -1307,6 +1307,25 @@ "lora.onboarding.cpuFallback": "Reserva CPU (más lento)", "lora.onboarding.description": "Crea un modelo de IA personalizado entrenado con tus manuscritos. Tus datos nunca salen de tu dispositivo.", "lora.onboarding.envError": "Error al verificar el entorno. Asegúrate de que Python y los paquetes requeridos están instalados.", + "lora.onboarding.error.configuredPathEmpty": "No se seleccionó ninguna ruta.", + "lora.onboarding.error.configuredPathNotAbsolute": "La ruta debe ser una ruta absoluta a un ejecutable de Python.", + "lora.onboarding.error.executableNotFound": "Ese archivo no es un ejecutable válido.", + "lora.onboarding.error.permissionDenied": "Permiso denegado al ejecutar ese archivo.", + "lora.onboarding.error.processSpawnFailed": "No se pudo iniciar ese ejecutable.", + "lora.onboarding.error.versionProbeFailed": "No se pudo determinar la versión de Python.", + "lora.onboarding.error.versionParseFailed": "No se pudo leer la salida de la versión de Python.", + "lora.onboarding.error.incompatibleVersion": "Se requiere Python 3.10 o más reciente.", + "lora.onboarding.error.versionProbeTimedOut": "Tiempo de espera agotado al verificar la versión de Python.", + "lora.onboarding.error.pythonProbeTaskFailed": "La verificación de Python falló inesperadamente.", + "lora.onboarding.error.configurationPathUnavailable": "No se pudo acceder a la carpeta de configuración de la app.", + "lora.onboarding.error.configurationWriteFailed": "No se pudo guardar la ruta de Python seleccionada.", + "lora.onboarding.error.helperScriptMissing": "Falta el script auxiliar de verificación del entorno.", + "lora.onboarding.error.helperTimedOut": "Se agotó el tiempo de espera de la verificación del entorno.", + "lora.onboarding.error.helperSpawnFailed": "No se pudo ejecutar el auxiliar de verificación del entorno.", + "lora.onboarding.error.helperExitNonzero": "El auxiliar de verificación del entorno terminó con un error.", + "lora.onboarding.error.helperReportParseFailed": "No se pudieron leer los resultados de la verificación del entorno.", + "lora.onboarding.error.generic": "La selección falló. Prueba con otro ejecutable de Python.", + "lora.onboarding.selectingPython": "Abriendo el selector de archivos…", "lora.onboarding.getStarted": "Comenzar", "lora.onboarding.installCmd": "pip install unsloth trl peft", "lora.onboarding.installed": "instalado", @@ -1315,6 +1334,7 @@ "lora.onboarding.privacyPromise": "100% Privado y sin conexión", "lora.onboarding.systemCheck": "Requisitos del sistema", "lora.onboarding.title": "Entrena tu estilo de escritura", + "lora.onboarding.selectPython": "Elegir ejecutable de Python", "lora.presets.deepNarrative.desc": "Estilo narrativo completo — ~60 min, 12 GB VRAM", "lora.presets.deepNarrative.label": "Narrativa profunda", "lora.presets.dialogueMaster.desc": "Optimizado para diálogos — ~45 min, 8 GB VRAM", diff --git a/public/locales/eu/bundle.json b/public/locales/eu/bundle.json index 56c3fd15..eebc343b 100644 --- a/public/locales/eu/bundle.json +++ b/public/locales/eu/bundle.json @@ -1307,12 +1307,32 @@ "lora.onboarding.cpuFallback": "CPUren atzerapena (motelagoa)", "lora.onboarding.description": "Sortu zure eskuizkribuetan trebatutako AI eredu pertsonalizatu bat. Zure datuak ez dira inoiz zure gailutik irteten.", "lora.onboarding.envError": "Ingurunearen egiaztapenak huts egin du. Mesedez, ziurtatu Python eta beharrezko paketeak instalatuta daudela.", + "lora.onboarding.error.configurationPathUnavailable": "Could not access the app configuration folder.", + "lora.onboarding.error.configurationWriteFailed": "Could not save the selected Python path.", + "lora.onboarding.error.configuredPathEmpty": "No path was selected.", + "lora.onboarding.error.configuredPathNotAbsolute": "Path must be an absolute path to a Python executable.", + "lora.onboarding.error.executableNotFound": "That file is not a valid executable.", + "lora.onboarding.error.generic": "Selection failed. Please try a different Python executable.", + "lora.onboarding.error.helperExitNonzero": "The environment-check helper exited with an error.", + "lora.onboarding.error.helperReportParseFailed": "Could not read the environment-check results.", + "lora.onboarding.error.helperScriptMissing": "The environment-check helper script is missing.", + "lora.onboarding.error.helperSpawnFailed": "Could not run the environment-check helper.", + "lora.onboarding.error.helperTimedOut": "The environment check timed out.", + "lora.onboarding.error.incompatibleVersion": "Python 3.10 or newer is required.", + "lora.onboarding.error.permissionDenied": "Permission denied when running that executable.", + "lora.onboarding.error.processSpawnFailed": "Could not start that executable.", + "lora.onboarding.error.pythonProbeTaskFailed": "The Python check failed unexpectedly.", + "lora.onboarding.error.versionParseFailed": "Could not read the Python version output.", + "lora.onboarding.error.versionProbeFailed": "Could not determine the Python version.", + "lora.onboarding.error.versionProbeTimedOut": "Timed out while checking the Python version.", "lora.onboarding.getStarted": "Hasi", "lora.onboarding.installCmd": "pip install unsloth trl peft", "lora.onboarding.installed": "instalatuta", "lora.onboarding.notFound": "ez da aurkitu", "lora.onboarding.privacyDetail": "Prestakuntza guztiak lokalean egiten dira. Zure eskuizkribua ez da inoiz hodeiko zerbitzura bidaltzen.", "lora.onboarding.privacyPromise": "%100 pribatua eta lineaz kanpo", + "lora.onboarding.selectPython": "Aukeratu Python exekutagarria", + "lora.onboarding.selectingPython": "Opening file picker…", "lora.onboarding.systemCheck": "Sistemaren eskakizunak", "lora.onboarding.title": "Prestatu zure idazketa-estiloa", "lora.presets.deepNarrative.desc": "Narrazio estilo osoa — ~60 min, 12 GB VRAM", diff --git a/public/locales/fa/bundle.json b/public/locales/fa/bundle.json index 18568e5d..35c7ece3 100644 --- a/public/locales/fa/bundle.json +++ b/public/locales/fa/bundle.json @@ -1307,12 +1307,32 @@ "lora.onboarding.cpuFallback": "بازگشت مجدد CPU (آهسته تر)", "lora.onboarding.description": "یک مدل هوش مصنوعی شخصی سازی شده بر روی دست نوشته های خود ایجاد کنید. اطلاعات شما هرگز از دستگاه شما خارج نمی شود.", "lora.onboarding.envError": "بررسی محیط زیست انجام نشد. لطفا مطمئن شوید که پایتون و بسته های مورد نیاز نصب شده است.", + "lora.onboarding.error.configurationPathUnavailable": "Could not access the app configuration folder.", + "lora.onboarding.error.configurationWriteFailed": "Could not save the selected Python path.", + "lora.onboarding.error.configuredPathEmpty": "No path was selected.", + "lora.onboarding.error.configuredPathNotAbsolute": "Path must be an absolute path to a Python executable.", + "lora.onboarding.error.executableNotFound": "That file is not a valid executable.", + "lora.onboarding.error.generic": "Selection failed. Please try a different Python executable.", + "lora.onboarding.error.helperExitNonzero": "The environment-check helper exited with an error.", + "lora.onboarding.error.helperReportParseFailed": "Could not read the environment-check results.", + "lora.onboarding.error.helperScriptMissing": "The environment-check helper script is missing.", + "lora.onboarding.error.helperSpawnFailed": "Could not run the environment-check helper.", + "lora.onboarding.error.helperTimedOut": "The environment check timed out.", + "lora.onboarding.error.incompatibleVersion": "Python 3.10 or newer is required.", + "lora.onboarding.error.permissionDenied": "Permission denied when running that executable.", + "lora.onboarding.error.processSpawnFailed": "Could not start that executable.", + "lora.onboarding.error.pythonProbeTaskFailed": "The Python check failed unexpectedly.", + "lora.onboarding.error.versionParseFailed": "Could not read the Python version output.", + "lora.onboarding.error.versionProbeFailed": "Could not determine the Python version.", + "lora.onboarding.error.versionProbeTimedOut": "Timed out while checking the Python version.", "lora.onboarding.getStarted": "شروع کنید", "lora.onboarding.installCmd": "pip install unsloth trl peft", "lora.onboarding.installed": "نصب شده است", "lora.onboarding.notFound": "یافت نشد", "lora.onboarding.privacyDetail": "تمام آموزش ها به صورت محلی انجام می شود. دستنوشته شما هرگز به هیچ سرویس ابری ارسال نمی شود.", "lora.onboarding.privacyPromise": "100% خصوصی و آفلاین", + "lora.onboarding.selectPython": "انتخاب فایل اجرایی پایتون", + "lora.onboarding.selectingPython": "Opening file picker…", "lora.onboarding.systemCheck": "سیستم مورد نیاز", "lora.onboarding.title": "سبک نوشتن خود را آموزش دهید", "lora.presets.deepNarrative.desc": "سبک روایت کامل - 60 دقیقه، 12 گیگابایت VRAM", diff --git a/public/locales/fi/bundle.json b/public/locales/fi/bundle.json index 1a8b9a74..e5ca26b7 100644 --- a/public/locales/fi/bundle.json +++ b/public/locales/fi/bundle.json @@ -1307,12 +1307,32 @@ "lora.onboarding.cpuFallback": "CPU-varaus (hitaampi)", "lora.onboarding.description": "Luo henkilökohtainen tekoälymalli, joka on koulutettu käsikirjoituksiisi. Tietosi eivät koskaan poistu laitteestasi.", "lora.onboarding.envError": "Ympäristötarkastus epäonnistui. Varmista, että Python ja tarvittavat paketit on asennettu.", + "lora.onboarding.error.configurationPathUnavailable": "Could not access the app configuration folder.", + "lora.onboarding.error.configurationWriteFailed": "Could not save the selected Python path.", + "lora.onboarding.error.configuredPathEmpty": "No path was selected.", + "lora.onboarding.error.configuredPathNotAbsolute": "Path must be an absolute path to a Python executable.", + "lora.onboarding.error.executableNotFound": "That file is not a valid executable.", + "lora.onboarding.error.generic": "Selection failed. Please try a different Python executable.", + "lora.onboarding.error.helperExitNonzero": "The environment-check helper exited with an error.", + "lora.onboarding.error.helperReportParseFailed": "Could not read the environment-check results.", + "lora.onboarding.error.helperScriptMissing": "The environment-check helper script is missing.", + "lora.onboarding.error.helperSpawnFailed": "Could not run the environment-check helper.", + "lora.onboarding.error.helperTimedOut": "The environment check timed out.", + "lora.onboarding.error.incompatibleVersion": "Python 3.10 or newer is required.", + "lora.onboarding.error.permissionDenied": "Permission denied when running that executable.", + "lora.onboarding.error.processSpawnFailed": "Could not start that executable.", + "lora.onboarding.error.pythonProbeTaskFailed": "The Python check failed unexpectedly.", + "lora.onboarding.error.versionParseFailed": "Could not read the Python version output.", + "lora.onboarding.error.versionProbeFailed": "Could not determine the Python version.", + "lora.onboarding.error.versionProbeTimedOut": "Timed out while checking the Python version.", "lora.onboarding.getStarted": "Aloita", "lora.onboarding.installCmd": "pip install unsloth trl peft", "lora.onboarding.installed": "asennettu", "lora.onboarding.notFound": "ei löytynyt", "lora.onboarding.privacyDetail": "Kaikki koulutus tapahtuu paikallisesti. Käsikirjoitustasi ei koskaan lähetetä mihinkään pilvipalveluun.", "lora.onboarding.privacyPromise": "100% yksityinen ja offline-tilassa", + "lora.onboarding.selectPython": "Valitse Python-suoritustiedosto", + "lora.onboarding.selectingPython": "Opening file picker…", "lora.onboarding.systemCheck": "Järjestelmävaatimukset", "lora.onboarding.title": "Harjoittele kirjoitustyyliäsi", "lora.presets.deepNarrative.desc": "Täysi kerrontyyli – ~60 min, 12 Gt VRAM", diff --git a/public/locales/fr/bundle.json b/public/locales/fr/bundle.json index c08d2141..71530714 100644 --- a/public/locales/fr/bundle.json +++ b/public/locales/fr/bundle.json @@ -1307,6 +1307,25 @@ "lora.onboarding.cpuFallback": "Repli CPU (plus lent)", "lora.onboarding.description": "Créez un modèle IA personnalisé entraîné sur vos manuscrits. Vos données ne quittent jamais votre appareil.", "lora.onboarding.envError": "Échec de la vérification de l'environnement. Veuillez vous assurer que Python et les paquets requis sont installés.", + "lora.onboarding.error.configuredPathEmpty": "Aucun chemin n'a été sélectionné.", + "lora.onboarding.error.configuredPathNotAbsolute": "Le chemin doit être un chemin absolu vers un exécutable Python.", + "lora.onboarding.error.executableNotFound": "Ce fichier n'est pas un exécutable valide.", + "lora.onboarding.error.permissionDenied": "Permission refusée lors de l'exécution de ce fichier.", + "lora.onboarding.error.processSpawnFailed": "Impossible de démarrer cet exécutable.", + "lora.onboarding.error.versionProbeFailed": "Impossible de déterminer la version de Python.", + "lora.onboarding.error.versionParseFailed": "Impossible de lire la sortie de la version de Python.", + "lora.onboarding.error.incompatibleVersion": "Python 3.10 ou plus récent est requis.", + "lora.onboarding.error.versionProbeTimedOut": "Délai dépassé lors de la vérification de la version de Python.", + "lora.onboarding.error.pythonProbeTaskFailed": "La vérification de Python a échoué de manière inattendue.", + "lora.onboarding.error.configurationPathUnavailable": "Impossible d'accéder au dossier de configuration de l'application.", + "lora.onboarding.error.configurationWriteFailed": "Impossible d'enregistrer le chemin Python sélectionné.", + "lora.onboarding.error.helperScriptMissing": "Le script auxiliaire de vérification de l'environnement est manquant.", + "lora.onboarding.error.helperTimedOut": "La vérification de l'environnement a expiré.", + "lora.onboarding.error.helperSpawnFailed": "Impossible d'exécuter l'auxiliaire de vérification de l'environnement.", + "lora.onboarding.error.helperExitNonzero": "L'auxiliaire de vérification de l'environnement s'est terminé avec une erreur.", + "lora.onboarding.error.helperReportParseFailed": "Impossible de lire les résultats de la vérification de l'environnement.", + "lora.onboarding.error.generic": "La sélection a échoué. Essayez un autre exécutable Python.", + "lora.onboarding.selectingPython": "Ouverture du sélecteur de fichiers…", "lora.onboarding.getStarted": "Commencer", "lora.onboarding.installCmd": "pip install unsloth trl peft", "lora.onboarding.installed": "installé", @@ -1315,6 +1334,7 @@ "lora.onboarding.privacyPromise": "100% Privé et hors ligne", "lora.onboarding.systemCheck": "Prérequis système", "lora.onboarding.title": "Entraîner votre style d'écriture", + "lora.onboarding.selectPython": "Choisir l’exécutable Python", "lora.presets.deepNarrative.desc": "Style narratif complet — ~60 min, 12 Go VRAM", "lora.presets.deepNarrative.label": "Narration profonde", "lora.presets.dialogueMaster.desc": "Optimisé pour les dialogues — ~45 min, 8 Go VRAM", diff --git a/public/locales/he/bundle.json b/public/locales/he/bundle.json index 76e8059a..37301da9 100644 --- a/public/locales/he/bundle.json +++ b/public/locales/he/bundle.json @@ -1307,12 +1307,32 @@ "lora.onboarding.cpuFallback": "חלופת מעבד (איטי יותר)", "lora.onboarding.description": "צרו מודל AI מותאם אישית המאומן על כתבי היד שלכם. הנתונים שלכם לעולם אינם עוזבים את המכשיר.", "lora.onboarding.envError": "בדיקת הסביבה נכשלה. אנא וודאו ש-Python והחבילות הנדרשות מותקנות.", + "lora.onboarding.error.configurationPathUnavailable": "Could not access the app configuration folder.", + "lora.onboarding.error.configurationWriteFailed": "Could not save the selected Python path.", + "lora.onboarding.error.configuredPathEmpty": "No path was selected.", + "lora.onboarding.error.configuredPathNotAbsolute": "Path must be an absolute path to a Python executable.", + "lora.onboarding.error.executableNotFound": "That file is not a valid executable.", + "lora.onboarding.error.generic": "Selection failed. Please try a different Python executable.", + "lora.onboarding.error.helperExitNonzero": "The environment-check helper exited with an error.", + "lora.onboarding.error.helperReportParseFailed": "Could not read the environment-check results.", + "lora.onboarding.error.helperScriptMissing": "The environment-check helper script is missing.", + "lora.onboarding.error.helperSpawnFailed": "Could not run the environment-check helper.", + "lora.onboarding.error.helperTimedOut": "The environment check timed out.", + "lora.onboarding.error.incompatibleVersion": "Python 3.10 or newer is required.", + "lora.onboarding.error.permissionDenied": "Permission denied when running that executable.", + "lora.onboarding.error.processSpawnFailed": "Could not start that executable.", + "lora.onboarding.error.pythonProbeTaskFailed": "The Python check failed unexpectedly.", + "lora.onboarding.error.versionParseFailed": "Could not read the Python version output.", + "lora.onboarding.error.versionProbeFailed": "Could not determine the Python version.", + "lora.onboarding.error.versionProbeTimedOut": "Timed out while checking the Python version.", "lora.onboarding.getStarted": "התחלה", "lora.onboarding.installCmd": "pip install unsloth trl peft", "lora.onboarding.installed": "מותקן", "lora.onboarding.notFound": "לא נמצא", "lora.onboarding.privacyDetail": "כל האימון מתרחש מקומית. כתב היד שלכם לעולם אינו נשלח לשירות ענן כלשהו.", "lora.onboarding.privacyPromise": "100% פרטי ולא מקוון", + "lora.onboarding.selectPython": "בחירת קובץ ההפעלה של Python", + "lora.onboarding.selectingPython": "Opening file picker…", "lora.onboarding.systemCheck": "דרישות מערכת", "lora.onboarding.title": "אמנו את סגנון הכתיבה שלכם", "lora.presets.deepNarrative.desc": "סגנון נרטיבי מלא — ~60 דק׳, 12 GB VRAM", diff --git a/public/locales/hu/bundle.json b/public/locales/hu/bundle.json index 771b16e6..00631cbe 100644 --- a/public/locales/hu/bundle.json +++ b/public/locales/hu/bundle.json @@ -1307,12 +1307,32 @@ "lora.onboarding.cpuFallback": "CPU tartalék (lassabb)", "lora.onboarding.description": "Hozzon létre egy személyre szabott mesterséges intelligencia-modellt a kéziratai alapján. Adatai soha nem hagyják el az eszközt.", "lora.onboarding.envError": "A környezeti ellenőrzés sikertelen. Győződjön meg arról, hogy a Python és a szükséges csomagok telepítve vannak.", + "lora.onboarding.error.configurationPathUnavailable": "Could not access the app configuration folder.", + "lora.onboarding.error.configurationWriteFailed": "Could not save the selected Python path.", + "lora.onboarding.error.configuredPathEmpty": "No path was selected.", + "lora.onboarding.error.configuredPathNotAbsolute": "Path must be an absolute path to a Python executable.", + "lora.onboarding.error.executableNotFound": "That file is not a valid executable.", + "lora.onboarding.error.generic": "Selection failed. Please try a different Python executable.", + "lora.onboarding.error.helperExitNonzero": "The environment-check helper exited with an error.", + "lora.onboarding.error.helperReportParseFailed": "Could not read the environment-check results.", + "lora.onboarding.error.helperScriptMissing": "The environment-check helper script is missing.", + "lora.onboarding.error.helperSpawnFailed": "Could not run the environment-check helper.", + "lora.onboarding.error.helperTimedOut": "The environment check timed out.", + "lora.onboarding.error.incompatibleVersion": "Python 3.10 or newer is required.", + "lora.onboarding.error.permissionDenied": "Permission denied when running that executable.", + "lora.onboarding.error.processSpawnFailed": "Could not start that executable.", + "lora.onboarding.error.pythonProbeTaskFailed": "The Python check failed unexpectedly.", + "lora.onboarding.error.versionParseFailed": "Could not read the Python version output.", + "lora.onboarding.error.versionProbeFailed": "Could not determine the Python version.", + "lora.onboarding.error.versionProbeTimedOut": "Timed out while checking the Python version.", "lora.onboarding.getStarted": "Kezdje el", "lora.onboarding.installCmd": "pip install unsloth trl peft", "lora.onboarding.installed": "telepítve", "lora.onboarding.notFound": "nem található", "lora.onboarding.privacyDetail": "Minden képzés helyben történik. A kéziratot soha nem küldik el semmilyen felhőszolgáltatásnak.", "lora.onboarding.privacyPromise": "100% privát és offline", + "lora.onboarding.selectPython": "Python futtatható fájl kiválasztása", + "lora.onboarding.selectingPython": "Opening file picker…", "lora.onboarding.systemCheck": "Rendszerkövetelmények", "lora.onboarding.title": "Tanítsa meg írási stílusát", "lora.presets.deepNarrative.desc": "Teljes narratív stílus — ~60 perc, 12 GB VRAM", diff --git a/public/locales/is/bundle.json b/public/locales/is/bundle.json index 8b961ba7..0116c021 100644 --- a/public/locales/is/bundle.json +++ b/public/locales/is/bundle.json @@ -1307,12 +1307,32 @@ "lora.onboarding.cpuFallback": "CPU fallback (hægara)", "lora.onboarding.description": "Búðu til sérsniðið gervigreind líkan sem er þjálfað í handritum þínum. Gögnin þín fara aldrei úr tækinu þínu.", "lora.onboarding.envError": "Umhverfisathugun mistókst. Gakktu úr skugga um að Python og nauðsynlegir pakkar séu settir upp.", + "lora.onboarding.error.configurationPathUnavailable": "Could not access the app configuration folder.", + "lora.onboarding.error.configurationWriteFailed": "Could not save the selected Python path.", + "lora.onboarding.error.configuredPathEmpty": "No path was selected.", + "lora.onboarding.error.configuredPathNotAbsolute": "Path must be an absolute path to a Python executable.", + "lora.onboarding.error.executableNotFound": "That file is not a valid executable.", + "lora.onboarding.error.generic": "Selection failed. Please try a different Python executable.", + "lora.onboarding.error.helperExitNonzero": "The environment-check helper exited with an error.", + "lora.onboarding.error.helperReportParseFailed": "Could not read the environment-check results.", + "lora.onboarding.error.helperScriptMissing": "The environment-check helper script is missing.", + "lora.onboarding.error.helperSpawnFailed": "Could not run the environment-check helper.", + "lora.onboarding.error.helperTimedOut": "The environment check timed out.", + "lora.onboarding.error.incompatibleVersion": "Python 3.10 or newer is required.", + "lora.onboarding.error.permissionDenied": "Permission denied when running that executable.", + "lora.onboarding.error.processSpawnFailed": "Could not start that executable.", + "lora.onboarding.error.pythonProbeTaskFailed": "The Python check failed unexpectedly.", + "lora.onboarding.error.versionParseFailed": "Could not read the Python version output.", + "lora.onboarding.error.versionProbeFailed": "Could not determine the Python version.", + "lora.onboarding.error.versionProbeTimedOut": "Timed out while checking the Python version.", "lora.onboarding.getStarted": "Byrjaðu", "lora.onboarding.installCmd": "pip setja unsloth trl peft", "lora.onboarding.installed": "uppsett", "lora.onboarding.notFound": "ekki fundið", "lora.onboarding.privacyDetail": "Öll þjálfun fer fram á staðnum. Handritið þitt er aldrei sent til neinnar skýjaþjónustu.", "lora.onboarding.privacyPromise": "100% einkamál og án nettengingar", + "lora.onboarding.selectPython": "Veldu Python keyrsluskrá", + "lora.onboarding.selectingPython": "Opening file picker…", "lora.onboarding.systemCheck": "Kerfiskröfur", "lora.onboarding.title": "Þjálfa ritstílinn þinn", "lora.presets.deepNarrative.desc": "Fullur frásagnarstíll — ~60 mín., 12 GB VRAM", diff --git a/public/locales/it/bundle.json b/public/locales/it/bundle.json index 3bb2ff43..0fee7eaa 100644 --- a/public/locales/it/bundle.json +++ b/public/locales/it/bundle.json @@ -1307,6 +1307,25 @@ "lora.onboarding.cpuFallback": "Fallback CPU (più lento)", "lora.onboarding.description": "Crea un modello IA personalizzato addestrato sui tuoi manoscritti. I tuoi dati non lasciano mai il tuo dispositivo.", "lora.onboarding.envError": "Controllo ambiente fallito. Assicurati che Python e i pacchetti richiesti siano installati.", + "lora.onboarding.error.configuredPathEmpty": "Non è stato selezionato alcun percorso.", + "lora.onboarding.error.configuredPathNotAbsolute": "Il percorso deve essere un percorso assoluto verso un eseguibile Python.", + "lora.onboarding.error.executableNotFound": "Quel file non è un eseguibile valido.", + "lora.onboarding.error.permissionDenied": "Permesso negato durante l'esecuzione di quel file.", + "lora.onboarding.error.processSpawnFailed": "Impossibile avviare quell'eseguibile.", + "lora.onboarding.error.versionProbeFailed": "Impossibile determinare la versione di Python.", + "lora.onboarding.error.versionParseFailed": "Impossibile leggere l'output della versione di Python.", + "lora.onboarding.error.incompatibleVersion": "È richiesto Python 3.10 o più recente.", + "lora.onboarding.error.versionProbeTimedOut": "Timeout durante il controllo della versione di Python.", + "lora.onboarding.error.pythonProbeTaskFailed": "Il controllo di Python non è riuscito in modo imprevisto.", + "lora.onboarding.error.configurationPathUnavailable": "Impossibile accedere alla cartella di configurazione dell'app.", + "lora.onboarding.error.configurationWriteFailed": "Impossibile salvare il percorso Python selezionato.", + "lora.onboarding.error.helperScriptMissing": "Manca lo script di supporto per il controllo dell'ambiente.", + "lora.onboarding.error.helperTimedOut": "Il controllo dell'ambiente è scaduto.", + "lora.onboarding.error.helperSpawnFailed": "Impossibile eseguire lo script di supporto per il controllo dell'ambiente.", + "lora.onboarding.error.helperExitNonzero": "Lo script di supporto per il controllo dell'ambiente è terminato con un errore.", + "lora.onboarding.error.helperReportParseFailed": "Impossibile leggere i risultati del controllo dell'ambiente.", + "lora.onboarding.error.generic": "Selezione non riuscita. Prova un altro eseguibile Python.", + "lora.onboarding.selectingPython": "Apertura selezione file…", "lora.onboarding.getStarted": "Inizia", "lora.onboarding.installCmd": "pip install unsloth trl peft", "lora.onboarding.installed": "installato", @@ -1315,6 +1334,7 @@ "lora.onboarding.privacyPromise": "100% Privato e offline", "lora.onboarding.systemCheck": "Requisiti di sistema", "lora.onboarding.title": "Allena il tuo stile di scrittura", + "lora.onboarding.selectPython": "Scegli l'eseguibile Python", "lora.presets.deepNarrative.desc": "Stile narrativo completo — ~60 min, 12 GB VRAM", "lora.presets.deepNarrative.label": "Narrativa profonda", "lora.presets.dialogueMaster.desc": "Ottimizzato per i dialoghi — ~45 min, 8 GB VRAM", diff --git a/public/locales/ja/bundle.json b/public/locales/ja/bundle.json index 79cdcfd7..a0670a7b 100644 --- a/public/locales/ja/bundle.json +++ b/public/locales/ja/bundle.json @@ -1307,12 +1307,32 @@ "lora.onboarding.cpuFallback": "CPU フォールバック (低速)", "lora.onboarding.description": "作成 a personalized AI model trained on your manuscripts. Your data never leaves your device.", "lora.onboarding.envError": "環境チェックに失敗しました。 Python と必要なパッケージがインストールされていることを確認してください。", + "lora.onboarding.error.configurationPathUnavailable": "Could not access the app configuration folder.", + "lora.onboarding.error.configurationWriteFailed": "Could not save the selected Python path.", + "lora.onboarding.error.configuredPathEmpty": "No path was selected.", + "lora.onboarding.error.configuredPathNotAbsolute": "Path must be an absolute path to a Python executable.", + "lora.onboarding.error.executableNotFound": "That file is not a valid executable.", + "lora.onboarding.error.generic": "Selection failed. Please try a different Python executable.", + "lora.onboarding.error.helperExitNonzero": "The environment-check helper exited with an error.", + "lora.onboarding.error.helperReportParseFailed": "Could not read the environment-check results.", + "lora.onboarding.error.helperScriptMissing": "The environment-check helper script is missing.", + "lora.onboarding.error.helperSpawnFailed": "Could not run the environment-check helper.", + "lora.onboarding.error.helperTimedOut": "The environment check timed out.", + "lora.onboarding.error.incompatibleVersion": "Python 3.10 or newer is required.", + "lora.onboarding.error.permissionDenied": "Permission denied when running that executable.", + "lora.onboarding.error.processSpawnFailed": "Could not start that executable.", + "lora.onboarding.error.pythonProbeTaskFailed": "The Python check failed unexpectedly.", + "lora.onboarding.error.versionParseFailed": "Could not read the Python version output.", + "lora.onboarding.error.versionProbeFailed": "Could not determine the Python version.", + "lora.onboarding.error.versionProbeTimedOut": "Timed out while checking the Python version.", "lora.onboarding.getStarted": "始めましょう", "lora.onboarding.installCmd": "pip install unsloth trl peft", "lora.onboarding.installed": "インストールされています", "lora.onboarding.notFound": "見つかりません", "lora.onboarding.privacyDetail": "トレーニングはすべてローカルで行われます。あなたの原稿がクラウド サービスに送信されることはありません。", "lora.onboarding.privacyPromise": "100% プライベート&オフライン", + "lora.onboarding.selectPython": "Python 実行ファイルを選択", + "lora.onboarding.selectingPython": "Opening file picker…", "lora.onboarding.systemCheck": "システム要件", "lora.onboarding.title": "文章のスタイルを訓練する", "lora.presets.deepNarrative.desc": "完全なナラティブ スタイル — ~60 分、12 GB VRAM", diff --git a/public/locales/ko/bundle.json b/public/locales/ko/bundle.json index 4e0d2c83..11d96aa8 100644 --- a/public/locales/ko/bundle.json +++ b/public/locales/ko/bundle.json @@ -1307,12 +1307,32 @@ "lora.onboarding.cpuFallback": "CPU 대체(느림)", "lora.onboarding.description": "귀하의 원고에 대해 훈련된 맞춤형 AI 모델을 만드십시오. 귀하의 데이터는 귀하의 장치를 떠나지 않습니다.", "lora.onboarding.envError": "환경 확인에 실패했습니다. Python과 필수 패키지가 설치되어 있는지 확인하세요.", + "lora.onboarding.error.configurationPathUnavailable": "Could not access the app configuration folder.", + "lora.onboarding.error.configurationWriteFailed": "Could not save the selected Python path.", + "lora.onboarding.error.configuredPathEmpty": "No path was selected.", + "lora.onboarding.error.configuredPathNotAbsolute": "Path must be an absolute path to a Python executable.", + "lora.onboarding.error.executableNotFound": "That file is not a valid executable.", + "lora.onboarding.error.generic": "Selection failed. Please try a different Python executable.", + "lora.onboarding.error.helperExitNonzero": "The environment-check helper exited with an error.", + "lora.onboarding.error.helperReportParseFailed": "Could not read the environment-check results.", + "lora.onboarding.error.helperScriptMissing": "The environment-check helper script is missing.", + "lora.onboarding.error.helperSpawnFailed": "Could not run the environment-check helper.", + "lora.onboarding.error.helperTimedOut": "The environment check timed out.", + "lora.onboarding.error.incompatibleVersion": "Python 3.10 or newer is required.", + "lora.onboarding.error.permissionDenied": "Permission denied when running that executable.", + "lora.onboarding.error.processSpawnFailed": "Could not start that executable.", + "lora.onboarding.error.pythonProbeTaskFailed": "The Python check failed unexpectedly.", + "lora.onboarding.error.versionParseFailed": "Could not read the Python version output.", + "lora.onboarding.error.versionProbeFailed": "Could not determine the Python version.", + "lora.onboarding.error.versionProbeTimedOut": "Timed out while checking the Python version.", "lora.onboarding.getStarted": "시작하기", "lora.onboarding.installCmd": "pip 설치 unsloth trl peft", "lora.onboarding.installed": "설치됨", "lora.onboarding.notFound": "찾을 수 없음", "lora.onboarding.privacyDetail": "모든 훈련은 현지에서 이루어집니다. 귀하의 원고는 어떤 클라우드 서비스로도 전송되지 않습니다.", "lora.onboarding.privacyPromise": "100% 비공개 및 오프라인", + "lora.onboarding.selectPython": "Python 실행 파일 선택", + "lora.onboarding.selectingPython": "Opening file picker…", "lora.onboarding.systemCheck": "시스템 요구 사항", "lora.onboarding.title": "글쓰기 스타일을 훈련하세요", "lora.presets.deepNarrative.desc": "전체 내러티브 스타일 — ~60분, 12GB VRAM", diff --git a/public/locales/pt/bundle.json b/public/locales/pt/bundle.json index 51ae59c2..dfaccbf9 100644 --- a/public/locales/pt/bundle.json +++ b/public/locales/pt/bundle.json @@ -1307,12 +1307,32 @@ "lora.onboarding.cpuFallback": "Fallback de CPU (mais lento)", "lora.onboarding.description": "Criar a personalized IA model trained on your manuscripts. Your data never leaves your device.", "lora.onboarding.envError": "A verificação do ambiente falhou. Certifique-se de que o Python e os pacotes necessários estejam instalados.", + "lora.onboarding.error.configurationPathUnavailable": "Could not access the app configuration folder.", + "lora.onboarding.error.configurationWriteFailed": "Could not save the selected Python path.", + "lora.onboarding.error.configuredPathEmpty": "No path was selected.", + "lora.onboarding.error.configuredPathNotAbsolute": "Path must be an absolute path to a Python executable.", + "lora.onboarding.error.executableNotFound": "That file is not a valid executable.", + "lora.onboarding.error.generic": "Selection failed. Please try a different Python executable.", + "lora.onboarding.error.helperExitNonzero": "The environment-check helper exited with an error.", + "lora.onboarding.error.helperReportParseFailed": "Could not read the environment-check results.", + "lora.onboarding.error.helperScriptMissing": "The environment-check helper script is missing.", + "lora.onboarding.error.helperSpawnFailed": "Could not run the environment-check helper.", + "lora.onboarding.error.helperTimedOut": "The environment check timed out.", + "lora.onboarding.error.incompatibleVersion": "Python 3.10 or newer is required.", + "lora.onboarding.error.permissionDenied": "Permission denied when running that executable.", + "lora.onboarding.error.processSpawnFailed": "Could not start that executable.", + "lora.onboarding.error.pythonProbeTaskFailed": "The Python check failed unexpectedly.", + "lora.onboarding.error.versionParseFailed": "Could not read the Python version output.", + "lora.onboarding.error.versionProbeFailed": "Could not determine the Python version.", + "lora.onboarding.error.versionProbeTimedOut": "Timed out while checking the Python version.", "lora.onboarding.getStarted": "Comece", "lora.onboarding.installCmd": "pip instalar sem preguiça trl peft", "lora.onboarding.installed": "instalado", "lora.onboarding.notFound": "não encontrado", "lora.onboarding.privacyDetail": "Todo o treinamento acontece localmente. Seu manuscrito nunca é enviado para nenhum serviço em nuvem.", "lora.onboarding.privacyPromise": "100% privado e off-line", + "lora.onboarding.selectPython": "Escolher executável Python", + "lora.onboarding.selectingPython": "Opening file picker…", "lora.onboarding.systemCheck": "Requisitos do sistema", "lora.onboarding.title": "Treine seu estilo de escrita", "lora.presets.deepNarrative.desc": "Estilo narrativo completo — ~60 min, 12 GB VRAM", diff --git a/public/locales/ru/bundle.json b/public/locales/ru/bundle.json index df753bcf..a116d479 100644 --- a/public/locales/ru/bundle.json +++ b/public/locales/ru/bundle.json @@ -1307,12 +1307,32 @@ "lora.onboarding.cpuFallback": "Резервный процессор ЦП (медленнее)", "lora.onboarding.description": "Создайте персонализированную модель искусственного интеллекта, обученную на ваших рукописях. Ваши данные никогда не покидают ваше устройство.", "lora.onboarding.envError": "Проверка среды не удалась. Убедитесь, что Python и необходимые пакеты установлены.", + "lora.onboarding.error.configurationPathUnavailable": "Could not access the app configuration folder.", + "lora.onboarding.error.configurationWriteFailed": "Could not save the selected Python path.", + "lora.onboarding.error.configuredPathEmpty": "No path was selected.", + "lora.onboarding.error.configuredPathNotAbsolute": "Path must be an absolute path to a Python executable.", + "lora.onboarding.error.executableNotFound": "That file is not a valid executable.", + "lora.onboarding.error.generic": "Selection failed. Please try a different Python executable.", + "lora.onboarding.error.helperExitNonzero": "The environment-check helper exited with an error.", + "lora.onboarding.error.helperReportParseFailed": "Could not read the environment-check results.", + "lora.onboarding.error.helperScriptMissing": "The environment-check helper script is missing.", + "lora.onboarding.error.helperSpawnFailed": "Could not run the environment-check helper.", + "lora.onboarding.error.helperTimedOut": "The environment check timed out.", + "lora.onboarding.error.incompatibleVersion": "Python 3.10 or newer is required.", + "lora.onboarding.error.permissionDenied": "Permission denied when running that executable.", + "lora.onboarding.error.processSpawnFailed": "Could not start that executable.", + "lora.onboarding.error.pythonProbeTaskFailed": "The Python check failed unexpectedly.", + "lora.onboarding.error.versionParseFailed": "Could not read the Python version output.", + "lora.onboarding.error.versionProbeFailed": "Could not determine the Python version.", + "lora.onboarding.error.versionProbeTimedOut": "Timed out while checking the Python version.", "lora.onboarding.getStarted": "Начать", "lora.onboarding.installCmd": "pip install unsloth trl peft", "lora.onboarding.installed": "установлен", "lora.onboarding.notFound": "не найдено", "lora.onboarding.privacyDetail": "Все обучение происходит локально. Ваша рукопись никогда не отправляется в какой-либо облачный сервис.", "lora.onboarding.privacyPromise": "100% конфиденциальность и офлайн", + "lora.onboarding.selectPython": "Выбрать исполняемый файл Python", + "lora.onboarding.selectingPython": "Opening file picker…", "lora.onboarding.systemCheck": "Системные требования", "lora.onboarding.title": "Тренируйте свой стиль письма", "lora.presets.deepNarrative.desc": "Полный стиль повествования — ~60 мин, 12 ГБ видеопамяти", diff --git a/public/locales/sv/bundle.json b/public/locales/sv/bundle.json index e13ca95e..0580da9f 100644 --- a/public/locales/sv/bundle.json +++ b/public/locales/sv/bundle.json @@ -1307,12 +1307,32 @@ "lora.onboarding.cpuFallback": "CPU fallback (långsammare)", "lora.onboarding.description": "Skapa en personlig AI-modell tränad på dina manuskript. Din data lämnar aldrig din enhet.", "lora.onboarding.envError": "Omgivningskontrollen misslyckades. Se till att Python och nödvändiga paket är installerade.", + "lora.onboarding.error.configurationPathUnavailable": "Could not access the app configuration folder.", + "lora.onboarding.error.configurationWriteFailed": "Could not save the selected Python path.", + "lora.onboarding.error.configuredPathEmpty": "No path was selected.", + "lora.onboarding.error.configuredPathNotAbsolute": "Path must be an absolute path to a Python executable.", + "lora.onboarding.error.executableNotFound": "That file is not a valid executable.", + "lora.onboarding.error.generic": "Selection failed. Please try a different Python executable.", + "lora.onboarding.error.helperExitNonzero": "The environment-check helper exited with an error.", + "lora.onboarding.error.helperReportParseFailed": "Could not read the environment-check results.", + "lora.onboarding.error.helperScriptMissing": "The environment-check helper script is missing.", + "lora.onboarding.error.helperSpawnFailed": "Could not run the environment-check helper.", + "lora.onboarding.error.helperTimedOut": "The environment check timed out.", + "lora.onboarding.error.incompatibleVersion": "Python 3.10 or newer is required.", + "lora.onboarding.error.permissionDenied": "Permission denied when running that executable.", + "lora.onboarding.error.processSpawnFailed": "Could not start that executable.", + "lora.onboarding.error.pythonProbeTaskFailed": "The Python check failed unexpectedly.", + "lora.onboarding.error.versionParseFailed": "Could not read the Python version output.", + "lora.onboarding.error.versionProbeFailed": "Could not determine the Python version.", + "lora.onboarding.error.versionProbeTimedOut": "Timed out while checking the Python version.", "lora.onboarding.getStarted": "Kom igång", "lora.onboarding.installCmd": "pip installera unloth trl peft", "lora.onboarding.installed": "installerat", "lora.onboarding.notFound": "hittades inte", "lora.onboarding.privacyDetail": "All träning sker lokalt. Ditt manuskript skickas aldrig till någon molntjänst.", "lora.onboarding.privacyPromise": "100 % privat och offline", + "lora.onboarding.selectPython": "Välj Python-körbar fil", + "lora.onboarding.selectingPython": "Opening file picker…", "lora.onboarding.systemCheck": "Systemkrav", "lora.onboarding.title": "Träna din skrivstil", "lora.presets.deepNarrative.desc": "Fullständig berättarstil — ~60 min, 12 GB VRAM", diff --git a/public/locales/zh/bundle.json b/public/locales/zh/bundle.json index 4402cde7..5c78f1d9 100644 --- a/public/locales/zh/bundle.json +++ b/public/locales/zh/bundle.json @@ -1307,12 +1307,32 @@ "lora.onboarding.cpuFallback": "CPU 回退(较慢)", "lora.onboarding.description": "创建 a personalized AI model trained on your manuscripts. Your data never leaves your device.", "lora.onboarding.envError": "环境检查失败。请确保安装了 Python 和所需的包。", + "lora.onboarding.error.configurationPathUnavailable": "Could not access the app configuration folder.", + "lora.onboarding.error.configurationWriteFailed": "Could not save the selected Python path.", + "lora.onboarding.error.configuredPathEmpty": "No path was selected.", + "lora.onboarding.error.configuredPathNotAbsolute": "Path must be an absolute path to a Python executable.", + "lora.onboarding.error.executableNotFound": "That file is not a valid executable.", + "lora.onboarding.error.generic": "Selection failed. Please try a different Python executable.", + "lora.onboarding.error.helperExitNonzero": "The environment-check helper exited with an error.", + "lora.onboarding.error.helperReportParseFailed": "Could not read the environment-check results.", + "lora.onboarding.error.helperScriptMissing": "The environment-check helper script is missing.", + "lora.onboarding.error.helperSpawnFailed": "Could not run the environment-check helper.", + "lora.onboarding.error.helperTimedOut": "The environment check timed out.", + "lora.onboarding.error.incompatibleVersion": "Python 3.10 or newer is required.", + "lora.onboarding.error.permissionDenied": "Permission denied when running that executable.", + "lora.onboarding.error.processSpawnFailed": "Could not start that executable.", + "lora.onboarding.error.pythonProbeTaskFailed": "The Python check failed unexpectedly.", + "lora.onboarding.error.versionParseFailed": "Could not read the Python version output.", + "lora.onboarding.error.versionProbeFailed": "Could not determine the Python version.", + "lora.onboarding.error.versionProbeTimedOut": "Timed out while checking the Python version.", "lora.onboarding.getStarted": "开始使用", "lora.onboarding.installCmd": "pip install unsloth trl peft", "lora.onboarding.installed": "已安装", "lora.onboarding.notFound": "未找到", "lora.onboarding.privacyDetail": "所有培训均在本地进行。您的手稿永远不会发送到任何云服务。", "lora.onboarding.privacyPromise": "100% 私密且离线", + "lora.onboarding.selectPython": "选择 Python 可执行文件", + "lora.onboarding.selectingPython": "Opening file picker…", "lora.onboarding.systemCheck": "系统要求", "lora.onboarding.title": "训练你的写作风格", "lora.presets.deepNarrative.desc": "完整的叙事风格 — 约 60 分钟,12 GB VRAM", diff --git a/services/lora/loraTrainingService.ts b/services/lora/loraTrainingService.ts index 11d3977d..0fdcea51 100644 --- a/services/lora/loraTrainingService.ts +++ b/services/lora/loraTrainingService.ts @@ -120,10 +120,12 @@ export async function mergeAdapter( outputPath: string, ): Promise { if (!isTauri()) throw new Error('Merge requires the desktop app.'); + // QNBS-v3: Tauri's #[tauri::command] macro binds top-level JS invoke keys as camelCase by + // default (no rename_all override on merge_lora) — snake_case keys here fail arg binding. await tauriInvoke('merge_lora', { - base_model: baseModel, - adapter_path: adapterPath, - output_path: outputPath, + baseModel, + adapterPath, + outputPath, }); } @@ -133,9 +135,10 @@ export async function generateOllamaModelfile( name: string, ): Promise { if (isTauri()) { + // QNBS-v3: same Tauri default-camelCase arg binding as merge_lora — no rename_all override. return tauriInvoke('generate_ollama_modelfile', { - base_model: baseModel, - adapter_path: adapterPath, + baseModel, + adapterPath, name, }); } @@ -213,8 +216,10 @@ export async function selectPythonExecutable(): Promise('set_lora_python_path', { - python_path: selected, + pythonPath: selected, }); return fromNativeEnvironment(result); } diff --git a/tests/unit/lora/LoraOnboarding.test.tsx b/tests/unit/lora/LoraOnboarding.test.tsx new file mode 100644 index 00000000..ad9ada00 --- /dev/null +++ b/tests/unit/lora/LoraOnboarding.test.tsx @@ -0,0 +1,167 @@ +/** + * LoraOnboarding tests — request-generation race guard, native error-category mapping + * (lastError surfaced regardless of pythonAvailable), and select-Python button a11y. + * QNBS-v3: regression coverage for the CodeRabbit/Codex/CodeAnt findings on this file. + */ + +import { act, render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import LoraOnboarding from '../../../components/lora/LoraOnboarding'; + +vi.mock('../../../hooks/useTranslation', () => ({ + useTranslation: () => ({ t: (k: string) => k, language: 'en' }), +})); + +const mockCheckTrainingEnvironment = vi.fn(); +const mockSelectPythonExecutable = vi.fn(); + +vi.mock('../../../services/lora/loraTrainingService', () => ({ + checkTrainingEnvironment: (...args: unknown[]) => mockCheckTrainingEnvironment(...args), + selectPythonExecutable: (...args: unknown[]) => mockSelectPythonExecutable(...args), +})); + +interface DeferredEnv { + pythonAvailable: boolean; + unslothAvailable: boolean; + cudaAvailable: boolean; + vramGb: number; + pythonVersion: string; + pythonPath?: string; + lastError?: string; + message?: string; +} + +function deferred() { + let resolve!: (v: T) => void; + let reject!: (e: unknown) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +} + +const baseEnv: DeferredEnv = { + pythonAvailable: false, + unslothAvailable: false, + cudaAvailable: false, + vramGb: 0, + pythonVersion: '', +}; + +describe('LoraOnboarding', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('shows the checking state, then renders the environment result', async () => { + mockCheckTrainingEnvironment.mockResolvedValue({ + ...baseEnv, + pythonAvailable: true, + pythonVersion: '3.12.1', + }); + render(); + expect(screen.getByText('lora.onboarding.checking')).toBeInTheDocument(); + await waitFor(() => expect(screen.getByText(/3\.12\.1/)).toBeInTheDocument()); + }); + + it('ignores a stale initial check that resolves after a manual selection wins the race', async () => { + const initial = deferred(); + mockCheckTrainingEnvironment.mockReturnValue(initial.promise); + mockSelectPythonExecutable.mockResolvedValue({ + ...baseEnv, + pythonAvailable: true, + pythonVersion: '3.13.0', + pythonPath: '/usr/bin/python3.13', + }); + + render(); + await waitFor(() => expect(mockCheckTrainingEnvironment).toHaveBeenCalled()); + + const user = userEvent.setup(); + await user.click(screen.getByRole('button', { name: 'lora.onboarding.selectPython' })); + await waitFor(() => expect(screen.getByText(/3\.13\.0/)).toBeInTheDocument()); + + // The slow initial check resolves late — it must not overwrite the newer manual-selection result. + await act(async () => { + initial.resolve({ ...baseEnv, pythonAvailable: true, pythonVersion: '3.9.0' }); + await initial.promise; + }); + expect(screen.getByText(/3\.13\.0/)).toBeInTheDocument(); + expect(screen.queryByText(/3\.9\.0/)).not.toBeInTheDocument(); + }); + + it('surfaces a translated native error even when pythonAvailable is true', async () => { + mockCheckTrainingEnvironment.mockResolvedValue({ + ...baseEnv, + pythonAvailable: true, + pythonVersion: '3.12.1', + lastError: 'helper_spawn_failed', + }); + render(); + await waitFor(() => + expect(screen.getByText(/lora\.onboarding\.error\.helperSpawnFailed/)).toBeInTheDocument(), + ); + expect(screen.queryByText(/3\.12\.1/)).not.toBeInTheDocument(); + }); + + it('falls back to the generic error key for an unmapped native category', async () => { + mockCheckTrainingEnvironment.mockResolvedValue({ ...baseEnv }); + mockSelectPythonExecutable.mockRejectedValue(new Error('some_unmapped_category')); + render(); + await waitFor(() => expect(mockCheckTrainingEnvironment).toHaveBeenCalled()); + + const user = userEvent.setup(); + await user.click(screen.getByRole('button', { name: 'lora.onboarding.selectPython' })); + await waitFor(() => + expect(screen.getByText(/lora\.onboarding\.error\.generic/)).toBeInTheDocument(), + ); + }); + + it('maps a known native error category and marks the button aria-busy while selecting', async () => { + mockCheckTrainingEnvironment.mockResolvedValue({ ...baseEnv }); + const selecting = deferred(); + mockSelectPythonExecutable.mockReturnValue(selecting.promise); + render(); + await waitFor(() => expect(mockCheckTrainingEnvironment).toHaveBeenCalled()); + + const user = userEvent.setup(); + const button = screen.getByRole('button', { name: 'lora.onboarding.selectPython' }); + await user.click(button); + + expect(screen.getByRole('button', { name: 'lora.onboarding.selectingPython' })).toHaveAttribute( + 'aria-busy', + 'true', + ); + + await act(async () => { + selecting.resolve({ + ...baseEnv, + pythonAvailable: false, + lastError: 'configured_path_not_absolute', + }); + await selecting.promise; + }); + await waitFor(() => + expect( + screen.getByText(/lora\.onboarding\.error\.configuredPathNotAbsolute/), + ).toBeInTheDocument(), + ); + expect(screen.getByRole('button', { name: 'lora.onboarding.selectPython' })).toHaveAttribute( + 'aria-busy', + 'false', + ); + }); + + it('calls onDismiss when Get Started is clicked', async () => { + mockCheckTrainingEnvironment.mockResolvedValue({ ...baseEnv }); + const onDismiss = vi.fn(); + render(); + await waitFor(() => expect(mockCheckTrainingEnvironment).toHaveBeenCalled()); + + const user = userEvent.setup(); + await user.click(screen.getByRole('button', { name: 'lora.onboarding.getStarted' })); + expect(onDismiss).toHaveBeenCalledTimes(1); + }); +}); diff --git a/tests/unit/lora/loraTrainingService.test.ts b/tests/unit/lora/loraTrainingService.test.ts index 808a43c9..7694a4ac 100644 --- a/tests/unit/lora/loraTrainingService.test.ts +++ b/tests/unit/lora/loraTrainingService.test.ts @@ -70,7 +70,7 @@ describe('loraTrainingService — Tauri desktop build', () => { expect(invoke).toHaveBeenCalledWith( 'generate_ollama_modelfile', expect.objectContaining({ - base_model: 'base', + baseModel: 'base', }), ); expect(typeof modelfile).toBe('string'); @@ -107,7 +107,7 @@ describe('loraTrainingService — Tauri desktop build', () => { pythonPath: '/opt/python 3.12/bin/python3', }); expect(invoke).toHaveBeenCalledWith('set_lora_python_path', { - python_path: '/opt/python 3.12/bin/python3', + pythonPath: '/opt/python 3.12/bin/python3', }); }); From e2044636c9db2ef748b3a59fc82331b4e5ba57d9 Mon Sep 17 00:00:00 2001 From: qnbs <155236708+qnbs@users.noreply.github.com> Date: Tue, 11 Aug 2026 16:09:38 +0200 Subject: [PATCH 32/78] fix: correct AiCreativity casing typo blocking typecheck MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 'balanced' isn't a valid AiCreativity value (only 'Focused' | 'Balanced' | 'Imaginative' are) — every other call site in this file already used 'Balanced'. This one typo failed tsgo --noEmit for the whole project, pre-existing and unrelated to the LoRA fix in the prior commit. Co-Authored-By: Claude Sonnet 5 --- tests/unit/aiProviderService.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/unit/aiProviderService.test.ts b/tests/unit/aiProviderService.test.ts index c425c791..3d8e0fe8 100644 --- a/tests/unit/aiProviderService.test.ts +++ b/tests/unit/aiProviderService.test.ts @@ -384,7 +384,7 @@ describe('testAIConnection — ollama desktop branch', () => { await streamText( 'Continue this scene', - 'balanced', + 'Balanced', { provider: 'ollama', model: 'ollama/local-model', From fd388472c496871ec5ec697128d64a065b7a3ab0 Mon Sep 17 00:00:00 2001 From: qnbs <155236708+qnbs@users.noreply.github.com> Date: Tue, 11 Aug 2026 16:32:30 +0200 Subject: [PATCH 33/78] fix: classify a killed training process as aborted, not failed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit abort_lora_training waits for the killed child to exit before resolving, so the concurrent train_lora invocation it just killed can reject first. startTrainingThunk's catch unconditionally dispatched trainingFailed, which archives and clears currentRun before abortTrainingThunk's own trainingAborted dispatch runs — that reducer then no-ops (guarded on `if (!state.currentRun) return`), so a successful user cancellation was recorded and surfaced as a training failure. Adds TrainingRun.cancellationRequested, set synchronously by abortTrainingThunk before it awaits confirmation. startTrainingThunk's catch checks this flag and dispatches trainingAborted instead of trainingFailed when set. Correct regardless of which thunk's dispatch wins the race, since both trainingFailed/trainingAborted reducers are no-ops once currentRun is already cleared. Co-Authored-By: Claude Sonnet 5 --- features/lora/loraSlice.ts | 9 +++++++++ features/lora/loraThunks.ts | 16 ++++++++++++++-- features/lora/types.ts | 3 +++ tests/unit/lora/loraSlice.test.ts | 13 +++++++++++++ tests/unit/lora/loraThunks.test.ts | 30 ++++++++++++++++++++++++++++-- 5 files changed, 67 insertions(+), 4 deletions(-) diff --git a/features/lora/loraSlice.ts b/features/lora/loraSlice.ts index 8323f001..c6886930 100644 --- a/features/lora/loraSlice.ts +++ b/features/lora/loraSlice.ts @@ -217,6 +217,14 @@ const loraSlice = createSlice({ state.currentRun = null; }, + // QNBS-v3: dispatched synchronously before abortTrainingThunk awaits confirmation — see + // TrainingRun.cancellationRequested for why startTrainingThunk needs this to classify its own + // subsequent rejection correctly. + trainingCancellationRequested(state) { + if (!state.currentRun) return; + state.currentRun.cancellationRequested = true; + }, + // ----------------------------------------------------------------------- // Evaluation // ----------------------------------------------------------------------- @@ -264,6 +272,7 @@ export const { trainingCompleted, trainingFailed, trainingAborted, + trainingCancellationRequested, setIsEvaluating, evaluationCompleted, hydrateLoraState, diff --git a/features/lora/loraThunks.ts b/features/lora/loraThunks.ts index d0522666..842a514e 100644 --- a/features/lora/loraThunks.ts +++ b/features/lora/loraThunks.ts @@ -19,6 +19,7 @@ import { setIsEvaluating, setIsMerging, trainingAborted, + trainingCancellationRequested, trainingCompleted, trainingFailed, trainingProgress, @@ -136,7 +137,7 @@ export const startTrainingThunk = createAsyncThunk< customEpochs?: number; }, ThunkConfig ->('lora/startTraining', async (config, { dispatch }) => { +>('lora/startTraining', async (config, { dispatch, getState }) => { const { assertLoraLocalOnly } = await import('../../services/ai/aiPolicy'); assertLoraLocalOnly(config.baseModelId); @@ -199,7 +200,15 @@ export const startTrainingThunk = createAsyncThunk< dispatch(adapterSaved(meta)); dispatch(trainingCompleted({ outputAdapterId: adapterId })); } catch (err) { - dispatch(trainingFailed(err instanceof Error ? err.message : String(err))); + // QNBS-v3: abort_lora_training waits for the killed child to exit before resolving, so the + // train_lora invoke it just killed can reject here first — without this check, a successful + // user cancellation would archive as a training failure instead of an abort (see + // TrainingRun.cancellationRequested). + if (getState().lora.currentRun?.cancellationRequested) { + dispatch(trainingAborted()); + } else { + dispatch(trainingFailed(err instanceof Error ? err.message : String(err))); + } } }); @@ -210,6 +219,9 @@ export const startTrainingThunk = createAsyncThunk< export const abortTrainingThunk = createAsyncThunk( 'lora/abortTraining', async (_, { dispatch }) => { + // QNBS-v3: set before awaiting so startTrainingThunk's catch (which can fire first — see there) + // can tell a killed process apart from a genuine training failure. + dispatch(trainingCancellationRequested()); const { abortTraining } = await import('../../services/lora/loraTrainingService'); await abortTraining(); dispatch(trainingAborted()); diff --git a/features/lora/types.ts b/features/lora/types.ts index fae6fa11..a1ab1dbe 100644 --- a/features/lora/types.ts +++ b/features/lora/types.ts @@ -167,6 +167,9 @@ export interface TrainingRun { outputAdapterId?: string; errorMessage?: string; isFallback?: boolean; + // QNBS-v3: set by abortTrainingThunk before it awaits confirmation — lets startTrainingThunk's + // catch classify a subsequent rejection as a user cancellation instead of a training failure. + cancellationRequested?: boolean; } // --------------------------------------------------------------------------- diff --git a/tests/unit/lora/loraSlice.test.ts b/tests/unit/lora/loraSlice.test.ts index 9925eae8..08cf93a4 100644 --- a/tests/unit/lora/loraSlice.test.ts +++ b/tests/unit/lora/loraSlice.test.ts @@ -13,6 +13,7 @@ import loraReducer, { setActiveAdapter, setIsBuilding, trainingAborted, + trainingCancellationRequested, trainingCompleted, trainingFailed, trainingProgress, @@ -119,6 +120,18 @@ describe('loraSlice — training state machine', () => { expect(s1.currentRun).toBeNull(); expect(s1.runHistory[0]!.status).toBe('aborted'); }); + + it('trainingCancellationRequested flags the active run without changing its status', () => { + const s0 = loraReducer(initial, trainingStarted(runPayload)); + const s1 = loraReducer(s0, trainingCancellationRequested()); + expect(s1.currentRun?.cancellationRequested).toBe(true); + expect(s1.currentRun?.status).toBe('training'); + }); + + it('trainingCancellationRequested is a no-op when there is no active run', () => { + const s1 = loraReducer(initial, trainingCancellationRequested()); + expect(s1.currentRun).toBeNull(); + }); }); describe('loraSlice — dataset', () => { diff --git a/tests/unit/lora/loraThunks.test.ts b/tests/unit/lora/loraThunks.test.ts index a52f3ada..bae72a60 100644 --- a/tests/unit/lora/loraThunks.test.ts +++ b/tests/unit/lora/loraThunks.test.ts @@ -91,8 +91,8 @@ function makeDispatch() { return vi.fn(); } -function makeGetState(loraAdapters: unknown[] = []) { - return () => ({ lora: { adapters: loraAdapters } }); +function makeGetState(loraAdapters: unknown[] = [], currentRun: unknown = null) { + return () => ({ lora: { adapters: loraAdapters, currentRun } }); } // QNBS-v3: RTK thunk test helper. ThunkFn uses `any` for dispatch/getState to satisfy @@ -340,6 +340,19 @@ describe('startTrainingThunk', () => { const started = dispatch.mock.calls.find((c) => c[0]?.type === 'lora/trainingStarted'); expect(started![0].payload.totalEpochs).toBe(10); }); + + it('dispatches trainingAborted instead of trainingFailed when a cancellation was requested', async () => { + // QNBS-v3: abort_lora_training killing the process makes train_lora reject first — the + // rejection must be classified as an abort, not a failure, when cancellationRequested is set. + mockStartTraining.mockRejectedValue(new Error('training_cancel_not_confirmed')); + const dispatch = makeDispatch(); + const getState = makeGetState([], { cancellationRequested: true }); + await run(startTrainingThunk(trainConfig), dispatch, getState); + const aborted = dispatch.mock.calls.find((c) => c[0]?.type === 'lora/trainingAborted'); + const failed = dispatch.mock.calls.find((c) => c[0]?.type === 'lora/trainingFailed'); + expect(aborted).toBeDefined(); + expect(failed).toBeUndefined(); + }); }); // --------------------------------------------------------------------------- @@ -359,6 +372,19 @@ describe('abortTrainingThunk', () => { const aborted = dispatch.mock.calls.find((c) => c[0]?.type === 'lora/trainingAborted'); expect(aborted).toBeDefined(); }); + + it('dispatches trainingCancellationRequested before awaiting the native abort', async () => { + const dispatch = makeDispatch(); + await run(abortTrainingThunk(), dispatch); + const requestedIndex = dispatch.mock.calls.findIndex( + (c) => c[0]?.type === 'lora/trainingCancellationRequested', + ); + const abortedIndex = dispatch.mock.calls.findIndex( + (c) => c[0]?.type === 'lora/trainingAborted', + ); + expect(requestedIndex).toBeGreaterThanOrEqual(0); + expect(requestedIndex).toBeLessThan(abortedIndex); + }); }); // --------------------------------------------------------------------------- From 8a6f8959c3f5eb9fd07d9cbffb3c853b0069e1c9 Mon Sep 17 00:00:00 2001 From: qnbs <155236708+qnbs@users.noreply.github.com> Date: Tue, 11 Aug 2026 16:32:55 +0200 Subject: [PATCH 34/78] fix: sync Local AI panel when a preload finishes outside handleDownload MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit preloadLocalModel can be triggered from outside LocalAiSection — the global download-progress modal's Retry button calls it directly via retryLastPreload. Only LocalAiSection.handleDownload updated readyIds, throughput, storage, and the ready announcement, so a successful retry left a mounted panel showing the model as not-ready with stale stats until the section unmounted and remounted. Adds subscribeLocalModelReady to localAiFacade, notified at the end of preloadLocalModel's success branch (after its own readyLocalModelIds/ lastLocalThroughput bookkeeping, so listeners never observe stale data). LocalAiSection subscribes and re-syncs on notification, guarded by a downloadingIdRef so its own handleDownload-initiated downloads (which already update this state directly) don't get double-announced — the notification fires synchronously inside preloadLocalModel, before handleDownload's own await resumes. Also fixes two unrelated pre-existing test issues found while working in these files: a stale assertion in localAiFacade.test.ts expecting generateLocalText's runLocalTextGeneration call to receive `undefined` for onProgress (it always wraps onProgress in an internal reportProgress closure so inferenceProgressEmitter gets progress unconditionally), and adds the sourcery-suggested retry-failure-surfaces-via-reportWebLlmError coverage plus direct tests for retryLastPreload-with-no-prior-preload and abort-suppresses-error-reporting. Co-Authored-By: Claude Sonnet 5 --- components/settings/LocalAiSection.tsx | 27 ++++++++ services/localAiFacade.ts | 15 +++++ tests/unit/LocalAiDownloadProgress.test.tsx | 21 ++++++ tests/unit/localAiFacade.test.ts | 28 +++++++- tests/unit/settings/LocalAiSection.test.tsx | 71 +++++++++++++++++++++ 5 files changed, 161 insertions(+), 1 deletion(-) diff --git a/components/settings/LocalAiSection.tsx b/components/settings/LocalAiSection.tsx index f1102f11..3107b8d8 100644 --- a/components/settings/LocalAiSection.tsx +++ b/components/settings/LocalAiSection.tsx @@ -29,6 +29,7 @@ import { isLocalAiBusy, type LocalThroughputSample, preloadLocalModel, + subscribeLocalModelReady, } from '../../services/localAiFacade'; import { logger } from '../../services/logger'; import { Card, CardContent, CardHeader } from '../ui/Card'; @@ -159,6 +160,32 @@ export const LocalAiSection: FC = () => { [announce, t, labelOf, refreshStorage], ); + // QNBS-v3: mirrors downloadingId for the subscription below — effects only re-run on dep + // changes, so a plain closure read would see a stale value; sync via effect, never in render. + const downloadingIdRef = useRef(null); + useEffect(() => { + downloadingIdRef.current = downloadingId; + }, [downloadingId]); + + useEffect( + () => + // QNBS-v3: preloadLocalModel can be triggered outside this section (the global download + // modal's Retry button) — without this, a successful retry left readyIds/throughput/storage + // stale here until the section remounted. Skip when this section's own handleDownload + // initiated it — that path already updates state itself, so this would double-announce. + subscribeLocalModelReady((modelId) => { + if (modelId === downloadingIdRef.current) return; + setReadyIds((prev) => (prev.has(modelId) ? prev : new Set(prev).add(modelId))); + setThroughput(getLastLocalThroughput()); + void refreshStorage(); + announce( + t('settings.ai.localAi.modelReadyAnnounce', { model: labelOf(modelId) }), + 'polite', + ); + }), + [announce, t, labelOf, refreshStorage], + ); + const handleClear = useCallback(async () => { // QNBS-v3: authoritative re-check at execution time — block if any local-AI work is in flight // app-wide (GPU mutex held or a WebLLM download running), not just this section's. diff --git a/services/localAiFacade.ts b/services/localAiFacade.ts index 2871b80d..89cccfc2 100644 --- a/services/localAiFacade.ts +++ b/services/localAiFacade.ts @@ -295,6 +295,20 @@ export function getReadyLocalModelIds(): readonly string[] { return Array.from(readyLocalModelIds); } +// QNBS-v3: preloadLocalModel can be called from outside LocalAiSection (e.g. the global download +// modal's Retry button). Listeners let any mounted panel re-sync its readyIds/throughput/storage +// after a warm that it did not itself initiate, instead of only reacting to its own call site. +const localModelReadyListeners = new Set<(modelId: string) => void>(); + +export function subscribeLocalModelReady(listener: (modelId: string) => void): () => void { + localModelReadyListeners.add(listener); + return () => localModelReadyListeners.delete(listener); +} + +function notifyLocalModelReady(modelId: string): void { + for (const listener of localModelReadyListeners) listener(modelId); +} + /** Reset session readiness — call after the on-disk model caches are cleared. */ export function clearReadyLocalModels(): void { readyLocalModelIds.clear(); @@ -387,6 +401,7 @@ export async function preloadLocalModel( at: Date.now(), }; } + notifyLocalModelReady(modelId); } else { // QNBS-v3: Do not leave a visible progress dialog pending when a fallback cannot warm WebLLM. inferenceProgressEmitter.reportWebLlmError('Local model preload did not complete'); diff --git a/tests/unit/LocalAiDownloadProgress.test.tsx b/tests/unit/LocalAiDownloadProgress.test.tsx index c4563e21..9c05f372 100644 --- a/tests/unit/LocalAiDownloadProgress.test.tsx +++ b/tests/unit/LocalAiDownloadProgress.test.tsx @@ -136,6 +136,27 @@ describe('LocalAiDownloadProgress', () => { expect(mockRetryLastPreload).toHaveBeenCalledTimes(1); }); + it('surfaces retry failures via reportWebLlmError when retryLastPreload rejects', async () => { + const { inferenceProgressEmitter } = await import('../../services/ai/inferenceProgressEmitter'); + const user = userEvent.setup(); + mockSnapshot = { + state: 'error', + progress: 0.2, + estimatedSecondsRemaining: null, + text: 'Download failed', + }; + const retryError = new Error('No local model download is available to retry'); + mockRetryLastPreload.mockRejectedValueOnce(retryError); + render(); + + await user.click(screen.getByText('settings.ai.localAi.retryButton')); + + expect(mockRetryLastPreload).toHaveBeenCalledTimes(1); + await waitFor(() => + expect(inferenceProgressEmitter.reportWebLlmError).toHaveBeenCalledWith(retryError.message), + ); + }); + it('updates progress when subscriber fires', async () => { mockSnapshot = { state: 'idle', progress: 0, estimatedSecondsRemaining: null, text: '' }; render(); diff --git a/tests/unit/localAiFacade.test.ts b/tests/unit/localAiFacade.test.ts index baad43da..06b39db7 100644 --- a/tests/unit/localAiFacade.test.ts +++ b/tests/unit/localAiFacade.test.ts @@ -96,10 +96,13 @@ describe('localAiFacade', () => { const { generateLocalText } = await import('../../services/localAiFacade'); const controller = new AbortController(); await generateLocalText('prompt', 'model', undefined, undefined, controller.signal); + // QNBS-v3: generateLocalText always wraps onProgress in its own reportProgress closure (so + // inferenceProgressEmitter gets progress even when the caller passes no onProgress) — the 3rd + // arg is never the caller's raw undefined. expect(mockRunLocalTextGeneration).toHaveBeenCalledWith( 'prompt', 'model', - undefined, + expect.any(Function), controller.signal, ); }); @@ -304,4 +307,27 @@ describe('localAiFacade', () => { expect(progressSpy).toHaveBeenCalledWith(0, 'Preparing local model'); expect(errorSpy).toHaveBeenCalledWith('Local model preload did not complete'); }); + + it('retryLastPreload throws when no preload has been requested yet', async () => { + // QNBS-v3: lastPreloadModelId is module-level state — reset the module so this observes the + // true initial value instead of whatever a prior test in this file already set it to. + vi.resetModules(); + const { retryLastPreload } = await import('../../services/localAiFacade'); + await expect(retryLastPreload()).rejects.toThrow(/no local model download/i); + }); + + it('does not publish a WebLLM error when preload fails because the user cancelled it', async () => { + const { inferenceProgressEmitter } = await import('../../services/ai/inferenceProgressEmitter'); + const errorSpy = vi.spyOn(inferenceProgressEmitter, 'reportWebLlmError'); + const { preloadLocalModel, abortActivePreload } = await import('../../services/localAiFacade'); + + mockRunLocalTextGeneration.mockImplementation(async () => { + // Simulates the Cancel button firing while generation is in flight. + abortActivePreload(); + throw new Error('aborted mid-flight'); + }); + + await expect(preloadLocalModel('Qwen2.5-0.5B')).rejects.toThrow('aborted mid-flight'); + expect(errorSpy).not.toHaveBeenCalled(); + }); }); diff --git a/tests/unit/settings/LocalAiSection.test.tsx b/tests/unit/settings/LocalAiSection.test.tsx index d480edee..ce6e4a09 100644 --- a/tests/unit/settings/LocalAiSection.test.tsx +++ b/tests/unit/settings/LocalAiSection.test.tsx @@ -26,6 +26,10 @@ const mocks = vi.hoisted(() => ({ modelRec: vi.fn(() => 'm-small'), })); +// QNBS-v3: captures the subscribeLocalModelReady callback so tests can simulate a preload that +// completed outside this section (e.g. the global download modal's Retry button). +let capturedReadyListener: ((modelId: string) => void) | null = null; + const SUPPORTED_ESTIMATE = { usageMb: 100, quotaMb: 1000, @@ -64,6 +68,12 @@ vi.mock('../../../services/localAiFacade', () => ({ clearReadyLocalModels: () => mocks.clearReady(), abortActivePreload: () => mocks.abortPreload(), isLocalAiBusy: () => mocks.busy(), + subscribeLocalModelReady: (cb: (modelId: string) => void) => { + capturedReadyListener = cb; + return () => { + capturedReadyListener = null; + }; + }, })); vi.mock('../../../components/settings/LocalAiDownloadProgress', () => ({ LocalAiDownloadProgress: () => null, @@ -76,6 +86,7 @@ import { LocalAiSection } from '../../../components/settings/LocalAiSection'; beforeEach(() => { vi.clearAllMocks(); + capturedReadyListener = null; mocks.detectWebGpu.mockReturnValue(true); mocks.estimate.mockResolvedValue(SUPPORTED_ESTIMATE); mocks.clear.mockResolvedValue({ clearedCaches: 2 }); @@ -134,6 +145,66 @@ describe('LocalAiSection', () => { expect(await screen.findByText('settings.ai.localAi.readyBadge')).toBeInTheDocument(); }); + it('re-syncs readyIds/throughput/storage and announces when a preload finishes elsewhere', async () => { + // QNBS-v3: regression test — the global download-progress modal's Retry button calls + // preloadLocalModel directly, bypassing this section's own handleDownload. Simulates that + // by firing the subscribeLocalModelReady callback without ever clicking a Download button here. + render(); + await screen.findByText('settings.ai.localAi.webgpuAvailable'); + expect(screen.queryByText('settings.ai.localAi.readyBadge')).not.toBeInTheDocument(); + + mocks.lastThroughput.mockReturnValue({ tokensPerSecond: 42, modelId: 'm-big', at: 1 }); + expect(capturedReadyListener).not.toBeNull(); + act(() => { + capturedReadyListener?.('m-big'); + }); + + await waitFor(() => + expect(mocks.announce).toHaveBeenCalledWith( + 'settings.ai.localAi.modelReadyAnnounce', + 'polite', + ), + ); + expect(await screen.findByText('settings.ai.localAi.readyBadge')).toBeInTheDocument(); + }); + + it('does not double-announce when the ready notification matches its own in-flight download', async () => { + // QNBS-v3: preloadLocalModel's notification fires before handleDownload's own success branch — + // without the downloadingIdRef guard, a self-initiated download would announce twice. + let resolvePreload!: (v: { layer: string; modelId: string; downloaded: boolean }) => void; + mocks.preload.mockReturnValue( + new Promise((resolve) => { + resolvePreload = resolve; + }), + ); + const user = userEvent.setup(); + render(); + await screen.findByText('settings.ai.localAi.webgpuAvailable'); + + await user.click(screen.getAllByText('settings.ai.localAi.downloadButton')[0]!); // m-small + expect(mocks.preload).toHaveBeenCalledWith('m-small'); + + // Simulate the notification firing mid-flight, before the mocked preload promise resolves. + act(() => { + capturedReadyListener?.('m-small'); + }); + expect(mocks.announce).not.toHaveBeenCalledWith( + 'settings.ai.localAi.modelReadyAnnounce', + 'polite', + ); + + resolvePreload({ layer: 'webllm', modelId: 'm-small', downloaded: true }); + await waitFor(() => + expect(mocks.announce).toHaveBeenCalledWith( + 'settings.ai.localAi.modelReadyAnnounce', + 'polite', + ), + ); + expect( + mocks.announce.mock.calls.filter((c) => c[0] === 'settings.ai.localAi.modelReadyAnnounce'), + ).toHaveLength(1); + }); + it('restores the Ready badge from the session source across an unmount/remount', async () => { const user = userEvent.setup(); // First visit: nothing ready yet. From a01e2985b62d23a94dc1dadacf3d4bd06932e841 Mon Sep 17 00:00:00 2001 From: qnbs <155236708+qnbs@users.noreply.github.com> Date: Tue, 11 Aug 2026 16:43:57 +0200 Subject: [PATCH 35/78] fix: scope the global download modal to explicit preloads only generateLocalText unconditionally drove the singleton inferenceProgressEmitter (used by the global LocalAiDownloadProgress modal) for every call, including ordinary Writer/Copilot/ProForge generation through aiProviderService.ts. A user generating text with an already-warm WebLLM model could see a "downloading a model" dialog appear, and isLocalAiBusy() report busy, for a call that wasn't downloading anything. Adds an opt-in generateLocalText option, reportToGlobalProgress, defaulting to unset/false for every existing call site except preloadLocalModel (the only function that should legitimately drive that modal). This also fixes two related "stuck in loading" gaps found in the same review pass, now that generateLocalText owns its own terminal-state responsibility for any opted-in caller: - a preload that falls back off WebLLM (ONNX/Transformers/heuristic) now resets the emitter instead of leaving it in 'loading' forever when reached from a caller other than preloadLocalModel's own outer handling (which already covered its own case via reportWebLlmError). - a caller-provided AbortSignal aborting preloadLocalModel (distinct from the modal's own Cancel button, which already reports its own terminal state) now resets the emitter instead of only suppressing the error report, which previously left the modal and isLocalAiBusy() stuck indefinitely. Co-Authored-By: Claude Sonnet 5 --- services/localAiFacade.ts | 30 +++++++++++--- tests/unit/localAiFacade.test.ts | 71 +++++++++++++++++++++++++++++++- 2 files changed, 95 insertions(+), 6 deletions(-) diff --git a/services/localAiFacade.ts b/services/localAiFacade.ts index 89cccfc2..2653e402 100644 --- a/services/localAiFacade.ts +++ b/services/localAiFacade.ts @@ -124,7 +124,10 @@ export async function generateLocalText( signal?: AbortSignal, // QNBS-v3: bypassAdaptiveModel forces the caller's exact modelId (skips adaptive override) — used // by preloadLocalModel so a "Download model X" action always warms X, never a substitute. - options?: { bypassAdaptiveModel?: boolean }, + // QNBS-v3: reportToGlobalProgress opts this call into the singleton inferenceProgressEmitter (and + // therefore the global LocalAiDownloadProgress modal) — true only for preloadLocalModel. + // An ordinary Writer/Copilot/ProForge generation call must not make that modal appear. + options?: { bypassAdaptiveModel?: boolean; reportToGlobalProgress?: boolean }, ): Promise { // QNBS-v3: Acquire GPU mutex before WebLLM/ONNX-WebGPU init to prevent VRAM races across // concurrent callers (e.g. ProForge agents running multiple pipeline stages). @@ -140,7 +143,10 @@ export async function generateLocalText( ? Math.min(1, Math.max(0, report.progress)) : 0; const normalized = { progress: fraction, text: report.text }; - inferenceProgressEmitter.reportWebLlmProgress(normalized.progress, normalized.text); + // QNBS-v3: gated — an ordinary generation call must not make the global download modal appear. + if (options?.reportToGlobalProgress) { + inferenceProgressEmitter.reportWebLlmProgress(normalized.progress, normalized.text); + } onProgress?.(normalized); }; // QNBS-v3: When adaptive AI engine is enabled, use its task config for optimal backend/model. @@ -187,8 +193,15 @@ export async function generateLocalText( if (result.layer !== 'heuristic') { notifyLocalModelsReady(true); } - if (result.layer === 'webllm') { - inferenceProgressEmitter.reportWebLlmReady(); + if (options?.reportToGlobalProgress) { + if (result.layer === 'webllm') { + inferenceProgressEmitter.reportWebLlmReady(); + } else { + // QNBS-v3: a preload that fell back off WebLLM still must clear the modal's loading state — + // otherwise it's stuck showing "downloading" forever (this branch only reachable when the + // caller opted in via reportToGlobalProgress, i.e. preloadLocalModel). + inferenceProgressEmitter.reset(); + } } // QNBS-v3: CodeAnt — record latency against the ACTUAL backend/model that produced the response @@ -354,6 +367,7 @@ export async function preloadLocalModel( onProgress?: (report: WebLlmProgressReport) => void, signal?: AbortSignal, ): Promise { + // QNBS-v3: every preload attempt updates the retry target, so a failed retry itself can be retried. lastPreloadModelId = modelId; // QNBS-v3: own the AbortController so both the modal Cancel (abortActivePreload) and a caller // signal (e.g. unmount) can stop the underlying generation/worker task. @@ -386,6 +400,7 @@ export async function preloadLocalModel( controller.signal, { bypassAdaptiveModel: true, + reportToGlobalProgress: true, }, ); const elapsedSec = (performance.now() - startedAt) / 1000; @@ -408,7 +423,12 @@ export async function preloadLocalModel( } return { layer: res.layer, modelId, downloaded }; } catch (error) { - if (!controller.signal.aborted) { + // QNBS-v3: a caller-provided signal aborting (as opposed to the modal's own Cancel button, + // which already reports its own terminal state via abortActivePreload) must still clear the + // modal's loading state — otherwise it and isLocalAiBusy() stay stuck indefinitely. + if (signal?.aborted) { + inferenceProgressEmitter.reset(); + } else if (!controller.signal.aborted) { inferenceProgressEmitter.reportWebLlmError( error instanceof Error ? error.message : 'Local model preload failed', ); diff --git a/tests/unit/localAiFacade.test.ts b/tests/unit/localAiFacade.test.ts index 06b39db7..84300e67 100644 --- a/tests/unit/localAiFacade.test.ts +++ b/tests/unit/localAiFacade.test.ts @@ -238,7 +238,11 @@ describe('localAiFacade', () => { const progSpy = vi.spyOn(inferenceProgressEmitter, 'reportWebLlmProgress'); const readySpy = vi.spyOn(inferenceProgressEmitter, 'reportWebLlmReady'); const { generateLocalText } = await import('../../services/localAiFacade'); - await generateLocalText('prompt', 'm'); + // QNBS-v3: reportToGlobalProgress:true simulates preloadLocalModel's call — the only caller + // that should drive the singleton emitter (see the ordinary-call test right below). + await generateLocalText('prompt', 'm', undefined, undefined, undefined, { + reportToGlobalProgress: true, + }); expect(progSpy).toHaveBeenCalledWith(0.5, 'half'); expect(readySpy).toHaveBeenCalled(); progSpy.mockRestore(); @@ -246,6 +250,32 @@ describe('localAiFacade', () => { }), ); + it( + 'does not touch the global download emitter for an ordinary generation call', + withWorkerGlobal(async () => { + // QNBS-v3: regression test — an ordinary Writer/Copilot/ProForge call (no + // reportToGlobalProgress) must never make the global "downloading a model" modal appear. + mockDetectWebGpuSupport.mockReturnValue(true); + mockEnsureWebLlmPool.mockResolvedValue( + makeFakeBus({ + result: Promise.resolve({ text: 'done', layer: 'webllm', modelId: 'm' }), + progressEvents: [{ stage: 'loading', progress: 0.5, message: 'half' }], + }), + ); + const { inferenceProgressEmitter } = await import( + '../../services/ai/inferenceProgressEmitter' + ); + const progSpy = vi.spyOn(inferenceProgressEmitter, 'reportWebLlmProgress'); + const readySpy = vi.spyOn(inferenceProgressEmitter, 'reportWebLlmReady'); + const { generateLocalText } = await import('../../services/localAiFacade'); + await generateLocalText('prompt', 'm'); + expect(progSpy).not.toHaveBeenCalled(); + expect(readySpy).not.toHaveBeenCalled(); + progSpy.mockRestore(); + readySpy.mockRestore(); + }), + ); + it('isLocalAiBusy() is true while a generateLocalText call is in flight, false after', async () => { const { generateLocalText, isLocalAiBusy } = await import('../../services/localAiFacade'); let resolveGen: (v: { layer: string; text: string }) => void = () => {}; @@ -330,4 +360,43 @@ describe('localAiFacade', () => { await expect(preloadLocalModel('Qwen2.5-0.5B')).rejects.toThrow('aborted mid-flight'); expect(errorSpy).not.toHaveBeenCalled(); }); + + it('resets the emitter (not error) when a caller-provided signal aborts the preload', async () => { + // QNBS-v3: regression test — a caller-provided AbortSignal (distinct from the modal's own + // Cancel button, which reports its own terminal state) previously left the emitter stuck in + // 'loading' since the catch block only suppressed the error report without resetting it. + const { inferenceProgressEmitter } = await import('../../services/ai/inferenceProgressEmitter'); + const errorSpy = vi.spyOn(inferenceProgressEmitter, 'reportWebLlmError'); + const resetSpy = vi.spyOn(inferenceProgressEmitter, 'reset'); + const { preloadLocalModel } = await import('../../services/localAiFacade'); + + const controller = new AbortController(); + mockRunLocalTextGeneration.mockImplementation(async () => { + controller.abort(); + const err = new DOMException('aborted', 'AbortError'); + throw err; + }); + + await expect(preloadLocalModel('Qwen2.5-0.5B', undefined, controller.signal)).rejects.toThrow(); + expect(resetSpy).toHaveBeenCalled(); + expect(errorSpy).not.toHaveBeenCalled(); + }); + + it('resets the emitter when a preload falls back off WebLLM (generateLocalText level)', async () => { + // QNBS-v3: regression test — generateLocalText itself must terminate 'loading' for any + // reportToGlobalProgress caller whose result isn't webllm, not only for preloadLocalModel's + // own outer handling (an ordinary caller that opted in would otherwise get stuck at 'loading'). + mockRunLocalTextGeneration.mockResolvedValue({ layer: 'onnx', text: 'fallback' }); + const { inferenceProgressEmitter } = await import('../../services/ai/inferenceProgressEmitter'); + const resetSpy = vi.spyOn(inferenceProgressEmitter, 'reset'); + const readySpy = vi.spyOn(inferenceProgressEmitter, 'reportWebLlmReady'); + const { generateLocalText } = await import('../../services/localAiFacade'); + + await generateLocalText('prompt', 'm', undefined, undefined, undefined, { + reportToGlobalProgress: true, + }); + + expect(resetSpy).toHaveBeenCalled(); + expect(readySpy).not.toHaveBeenCalled(); + }); }); From 20c9954b1bcaf0857bd8a8dbea6bf3d43685b423 Mon Sep 17 00:00:00 2001 From: qnbs <155236708+qnbs@users.noreply.github.com> Date: Tue, 11 Aug 2026 20:36:15 +0200 Subject: [PATCH 36/78] fix: guard connection-context races and the WebGPU spinner in AiProviderCard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Model loading (handleLoadOllamaModels) had no staleness guard at all, and the manual-test guard (testRequestIdRef) was only bumped by an explicit second handleTest() call — editing the endpoint URL, preset, or browser-Ollama toggle while a request was in flight let its result land under the new context. AiSections.tsx worked around a related symptom (a provider switch mid-flight) with `key={provider}`, which fixed that one case by remounting the whole card — but also discarded any unsaved openaiKey/grokKey/anthropicKey input and restarted the stored-key reads. Removes the key prop. Adds useConnectionContextReset (extracted to a standalone hook to keep AiProviderCard's cognitive complexity under the CI threshold): a single effect that bumps testRequestIdRef and resets testStatus/testError/isLoadingModels whenever provider, ollamaBaseUrl, localBackendPreset, openAiCompatibleBaseUrl, or browserOllamaEnabled change — covering every case the key-based remount did, plus the ones it didn't (editing fields without switching providers), without discarding component state. handleLoadOllamaModels now captures the request id and checks it before applying results, via a small applyIfCurrent helper (also extracted for the same complexity reason). Separately: the WebGPU status badge rendered gpuInfo === null as a permanent spinner, since null was its only "loading" signal — opening the WebLLM tab without ever clicking Test Connection showed an indicator that never settles. Adds isProbingGpu to distinguish "actively probing" from "not yet tested", and changes gpuInfo's default from null to {status: 'unknown'} so both states render through the existing status-badge branches with no new conditional. Co-Authored-By: Claude Sonnet 5 --- components/settings/AiProviderCard.tsx | 93 ++++++++++- components/settings/AiSections.tsx | 2 - tests/unit/settings/AiProviderCard.test.tsx | 164 ++++++++++++++++++++ 3 files changed, 249 insertions(+), 10 deletions(-) diff --git a/components/settings/AiProviderCard.tsx b/components/settings/AiProviderCard.tsx index bd5d6309..cbfe0f4a 100644 --- a/components/settings/AiProviderCard.tsx +++ b/components/settings/AiProviderCard.tsx @@ -23,6 +23,57 @@ import { Select } from '../ui/Select'; import { Spinner } from '../ui/Spinner'; import { AnthropicProviderFields } from './AnthropicProviderFields'; +// QNBS-v3: extracted out of AiProviderCard to keep its cognitive complexity under the CI +// threshold — this effect's only job is invalidating stale in-flight tests/model-loads when the +// connection context changes (none of the five values need to be READ, only reacted to). +function useConnectionContextReset( + testRequestIdRef: React.MutableRefObject, + setTestStatus: (status: 'idle') => void, + setTestError: (error: string) => void, + setIsLoadingModels: (loading: false) => void, + provider: AIProvider, + ollamaBaseUrl: string, + localBackendPreset: LocalBackendPreset, + openAiCompatibleBaseUrl: string, + browserOllamaEnabled: boolean, +) { + useEffect(() => { + void provider; + void ollamaBaseUrl; + void localBackendPreset; + void openAiCompatibleBaseUrl; + void browserOllamaEnabled; + testRequestIdRef.current += 1; + setTestStatus('idle'); + setTestError(''); + // QNBS-v3: without this, a model-load left in flight when the context changes never gets + // reset (its own stale-check skips setIsLoadingModels(false) too) and the button stays + // permanently disabled until another load happens to be triggered. + setIsLoadingModels(false); + }, [ + testRequestIdRef, + setTestStatus, + setTestError, + setIsLoadingModels, + provider, + ollamaBaseUrl, + localBackendPreset, + openAiCompatibleBaseUrl, + browserOllamaEnabled, + ]); +} + +// QNBS-v3: converts an inline `if (requestIdRef.current === requestId) apply()` guard into a +// function call — extracted (like the hook above) purely to keep AiProviderCard's own cognitive +// complexity under the CI threshold; each inline guard counted as a branch against it. +function applyIfCurrent( + requestIdRef: React.MutableRefObject, + requestId: number, + apply: () => void, +): void { + if (requestIdRef.current === requestId) apply(); +} + interface AiProviderCardProps { advancedAi: AdvancedAiSettings; onAdvancedAiPatch: (patch: Partial) => void; @@ -71,14 +122,35 @@ export const AiProviderCard: FC = ({ const [isSavingKey, setIsSavingKey] = useState(false); const [scanBusy, setScanBusy] = useState(false); const [scanRows, setScanRows] = useState([]); - // QNBS-v3: GPU probe runs once when WebLLM tab is selected — no polling. - const [gpuInfo, setGpuInfo] = useState(null); + // QNBS-v3: GPU probe runs once when WebLLM tab is selected — no polling. Default 'unknown' + // (not null) so "never tested" and "tested, inconclusive" render identically without a + // separate null-check — isProbingGpu below is what distinguishes "actively checking". + const [gpuInfo, setGpuInfo] = useState({ status: 'unknown' }); + // QNBS-v3: without this, the WebGPU badge rendered a permanent spinner until the user clicked + // Test Connection, since gpuInfo had no other signal for "a probe is currently running". + const [isProbingGpu, setIsProbingGpu] = useState(false); // QNBS-v3 (CodeRabbit CWE-209): monotonic guard against a stale in-flight test result // (button click or the auto-test effect) overwriting state after the provider/desktop // context has since moved on — e.g. a switch to Ollama-in-browser must not let an older // request's raw error text land in testError once ollamaUntestable becomes true. const testRequestIdRef = useRef(0); + // QNBS-v3: any connection-context change invalidates in-flight tests/model-loads — without this, + // only an explicit second handleTest() call bumped the guard, so editing the endpoint/preset/URL + // mid-request let a stale response land under the new context. Also replaces the old + // key={provider} remount (which discarded unsaved openaiKey/grokKey/anthropicKey input). + useConnectionContextReset( + testRequestIdRef, + setTestStatus, + setTestError, + setIsLoadingModels, + provider, + ollamaBaseUrl, + advancedAi.localBackendPreset, + advancedAi.openAiCompatibleBaseUrl, + browserOllamaEnabled, + ); + useEffect(() => { storageService .getApiKey('openai') @@ -143,14 +215,17 @@ export const AiProviderCard: FC = ({ setOllamaModels([]); return; } + // QNBS-v3: capture, don't increment — the context-change effect above owns invalidation; this + // only needs to detect whether ITS OWN in-flight request is still current before applying it. + const requestId = testRequestIdRef.current; setIsLoadingModels(true); try { const models = await listLocalBackendModels(ollamaBaseUrl, advancedAi.localBackendPreset); - setOllamaModels(models); + applyIfCurrent(testRequestIdRef, requestId, () => setOllamaModels(models)); } catch { - setOllamaModels([]); + applyIfCurrent(testRequestIdRef, requestId, () => setOllamaModels([])); } finally { - setIsLoadingModels(false); + applyIfCurrent(testRequestIdRef, requestId, () => setIsLoadingModels(false)); } }, [ollamaBaseUrl, advancedAi.localBackendPreset, browserOllamaEnabled]); @@ -159,9 +234,11 @@ export const AiProviderCard: FC = ({ setTestStatus('loading'); setTestError(''); if (provider === 'webllm') { + setIsProbingGpu(true); void detectWebGpuDetails() .then(setGpuInfo) - .catch(() => setGpuInfo({ status: 'unknown' })); + .catch(() => setGpuInfo({ status: 'unknown' })) + .finally(() => setIsProbingGpu(false)); } try { const result = await testAIConnection(provider, { @@ -584,7 +661,7 @@ export const AiProviderCard: FC = ({ {/* GPU status badge */}
{t('settings.ai.webllm.gpuStatus')}: - {gpuInfo === null ? ( + {isProbingGpu ? ( ) : ( = ({
{/* Adapter info when available */} - {gpuInfo?.adapterDescription && ( + {gpuInfo.adapterDescription && (

{t('settings.ai.webllm.adapterLabel')}: {gpuInfo.adapterDescription} {gpuInfo.vramTier && ( diff --git a/components/settings/AiSections.tsx b/components/settings/AiSections.tsx index 4f518310..1b255bb4 100644 --- a/components/settings/AiSections.tsx +++ b/components/settings/AiSections.tsx @@ -69,8 +69,6 @@ export const AiSection: FC = () => { does not trigger device profiling when the feature is disabled */} {adaptiveAiEnabled && } diff --git a/tests/unit/settings/AiProviderCard.test.tsx b/tests/unit/settings/AiProviderCard.test.tsx index a352b90a..31bbd880 100644 --- a/tests/unit/settings/AiProviderCard.test.tsx +++ b/tests/unit/settings/AiProviderCard.test.tsx @@ -40,6 +40,10 @@ vi.mock('../../../services/ai/localBackendPresets', () => ({ }, })); +vi.mock('../../../services/ai/webGpuDetectorService', () => ({ + detectWebGpuDetails: vi.fn().mockResolvedValue({ status: 'available' }), +})); + const mockAdvancedAi = { model: 'gemini-2.5-flash' as const, provider: 'gemini' as const, @@ -66,6 +70,7 @@ const mockOnProviderChange = vi.fn(); const ollamaAdvancedAi = { ...mockAdvancedAi, provider: 'ollama' as const }; // QNBS-v3: fixture for the grok-provider describe block below. const grokAdvancedAi = { ...mockAdvancedAi, provider: 'grok' as const, model: 'grok-3' as const }; +const webllmAdvancedAi = { ...mockAdvancedAi, provider: 'webllm' as const }; function setDesktopRuntime(enabled: boolean): void { const w = window as Window & { __TAURI_INTERNALS__?: unknown }; @@ -602,3 +607,162 @@ describe('AiProviderCard — anthropic provider (ADR-0016)', () => { }); }); }); + +// QNBS-v3: regression coverage for the connection-context race guard + the key={provider} removal. +describe('AiProviderCard — connection-context invalidation (no remount)', () => { + afterEach(() => { + setDesktopRuntime(false); + vi.clearAllMocks(); + // QNBS-v3: clearAllMocks() clears call history but NOT mockImplementation — these tests + // override testAIConnection/listLocalBackendModels with a never-resolving promise factory; + // without restoring the defaults, later describe blocks' calls hang on an abandoned resolver. + vi.mocked(testAIConnection).mockResolvedValue({ ok: true }); + vi.mocked(listLocalBackendModels).mockResolvedValue([]); + }); + + it('ignores a stale Test Connection result after the endpoint changes mid-flight (no provider switch)', async () => { + setDesktopRuntime(true); + const resolvers: Array<(v: { ok: boolean }) => void> = []; + vi.mocked(testAIConnection).mockImplementation( + () => + new Promise((resolve) => { + resolvers.push(resolve); + }), + ); + const { rerender } = render( + , + ); + await userEvent + .setup() + .click(screen.getByRole('button', { name: 'settings.ai.testConnection' })); + await waitFor(() => expect(resolvers.length).toBeGreaterThan(0)); + + // Same provider, same desktop context — only the endpoint URL changes mid-flight. + rerender( + , + ); + await waitFor(() => { + expect(screen.getByText('settings.ai.providerStatusNotTested')).toBeTruthy(); + }); + + for (const resolve of resolvers) resolve({ ok: false }); + await new Promise((r) => setTimeout(r, 0)); + expect(screen.getByText('settings.ai.providerStatusNotTested')).toBeTruthy(); + expect(screen.queryByText('settings.ai.providerStatusDisconnected')).toBeNull(); + }); + + it('ignores a stale Load Models result after the endpoint changes mid-flight', async () => { + setDesktopRuntime(true); + const resolvers: Array<(v: string[]) => void> = []; + vi.mocked(listLocalBackendModels).mockImplementation( + () => + new Promise((resolve) => { + resolvers.push(resolve); + }), + ); + const { rerender } = render( + , + ); + await userEvent.setup().click(screen.getByRole('button', { name: 'settings.ai.loadModels' })); + await waitFor(() => expect(resolvers.length).toBeGreaterThan(0)); + + rerender( + , + ); + + // Resolve the stale request with models from the OLD server — they must never apply. + for (const resolve of resolvers) resolve(['stale-model-from-old-server']); + await new Promise((r) => setTimeout(r, 0)); + expect(screen.queryByText('stale-model-from-old-server')).toBeNull(); + }); + + it('preserves unsaved key input across a provider switch (no more key={provider} remount)', async () => { + const user = userEvent.setup(); + const { rerender } = render( + , + ); + const input = screen.getByLabelText('settings.ai.grokKey'); + await user.type(input, 'unsaved-key-in-progress'); + expect(input).toHaveValue('unsaved-key-in-progress'); + + // Switch away and back without ever saving — a key={provider} remount would have discarded this. + rerender( + , + ); + rerender( + , + ); + expect(screen.getByLabelText('settings.ai.grokKey')).toHaveValue('unsaved-key-in-progress'); + }); +}); + +// QNBS-v3: regression coverage for the WebGPU-badge spinner fix (isProbingGpu vs gpuInfo === null). +describe('AiProviderCard — WebGPU status badge', () => { + afterEach(() => { + vi.clearAllMocks(); + }); + + it('shows "untested" (not a permanent spinner) before Test Connection has ever run', () => { + render( + , + ); + expect(screen.getByText('settings.ai.webllm.gpuUnknown')).toBeTruthy(); + expect(screen.queryByText('Loading…')).toBeNull(); + }); + + it('shows a spinner while probing, then the result once detectWebGpuDetails resolves', async () => { + const { detectWebGpuDetails } = await import('../../../services/ai/webGpuDetectorService'); + let resolveProbe!: (v: { status: 'available' }) => void; + vi.mocked(detectWebGpuDetails).mockReturnValue( + new Promise((resolve) => { + resolveProbe = resolve; + }), + ); + const user = userEvent.setup(); + render( + , + ); + await user.click(screen.getByRole('button', { name: 'settings.ai.testConnection' })); + await waitFor(() => expect(screen.queryByText('Loading…')).toBeTruthy()); + + resolveProbe({ status: 'available' }); + await waitFor(() => expect(screen.getByText('settings.ai.webllm.gpuAvailable')).toBeTruthy()); + expect(screen.queryByText('Loading…')).toBeNull(); + }); +}); From 756e3c0910746c4a9fea0f635a56679f6898369e Mon Sep 17 00:00:00 2001 From: qnbs <155236708+qnbs@users.noreply.github.com> Date: Tue, 11 Aug 2026 20:56:15 +0200 Subject: [PATCH 37/78] fix: correct LM Studio/vLLM/custom local-backend streaming and routing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit streamOpenAiCompatibleLocal (LM Studio/vLLM OpenAI-compatible local streaming, added in this PR): - Never flushed the final SSE buffer after reader.read() returned done — a server closing the stream without a trailing newline after the last data: frame silently dropped that final delta. - Broke the read loop and called onDone on an aborted signal instead of throwing, so a user-cancelled generation was recorded as a successfully completed request with partial output. - Resolved a blank ollamaBaseUrl ('') via `?? 'http://localhost:1234'` — nullish coalescing doesn't catch empty string, so an explicitly cleared URL produced an app-relative request instead of falling back to the same default testOpenAiCompatibleLocalConnection uses. - Discarded the server's JSON error body on a non-ok response, losing the actionable detail (invalid model, bad request) LM Studio/vLLM return, down to just the HTTP status code. Custom-preset protocol mismatch: listLocalBackendModels already treated the 'custom' preset as OpenAI-compatible for model discovery (the common case — hand-editing the base URL to a non-default LM Studio/vLLM port selects 'custom'), but streaming, non-streaming generation, and connection testing all routed 'custom' through the native Ollama protocol instead. A customized server could list its models successfully and then fail every completion. Extracts isOpenAiCompatibleLocalPreset as the single source of truth and uses it at all four call sites. Also threads localBackendPreset through useCriticView and useConsistencyCheckerView's AIRequestOptions builders — they already passed ollamaBaseUrl but omitted the preset, so the Critic and Consistency Checker surfaces silently fell back to Ollama-native even for the named LM Studio/vLLM presets while the main Writer surface worked correctly. sourcery flagged `signal: opts.signal ?? null` as an inconsistency; verified against LocalServerFetchInit's actual type (AbortSignal | null, not | undefined — this codebase's own wrapper, not a raw Fetch passthrough) and confirmed `?? null` is the required, correct form. Co-Authored-By: Claude Sonnet 5 --- hooks/useConsistencyCheckerView.ts | 3 + hooks/useCriticView.ts | 3 + services/aiProviderService.ts | 95 +++++++--- tests/unit/aiProviderService.test.ts | 170 ++++++++++++++++++ tests/unit/useConsistencyCheckerView.test.tsx | 8 +- tests/unit/useCriticView.test.tsx | 8 +- 6 files changed, 257 insertions(+), 30 deletions(-) diff --git a/hooks/useConsistencyCheckerView.ts b/hooks/useConsistencyCheckerView.ts index e3adec70..6dfe1ccf 100644 --- a/hooks/useConsistencyCheckerView.ts +++ b/hooks/useConsistencyCheckerView.ts @@ -104,6 +104,9 @@ export const useConsistencyCheckerView = () => { temperature: aiSettings?.temperature, maxTokens: aiSettings?.maxTokens, ollamaBaseUrl: aiSettings?.ollamaBaseUrl, + // QNBS-v3: without this, LM Studio/vLLM/custom local backends silently fell back to the + // Ollama-native protocol here even though the same settings work in the main Writer surface. + localBackendPreset: aiSettings?.localBackendPreset, }), [aiSettings], ); diff --git a/hooks/useCriticView.ts b/hooks/useCriticView.ts index 118570b3..c3c971a2 100644 --- a/hooks/useCriticView.ts +++ b/hooks/useCriticView.ts @@ -24,6 +24,9 @@ export const useCriticView = () => { temperature: aiSettings.temperature, maxTokens: aiSettings.maxTokens, ollamaBaseUrl: aiSettings.ollamaBaseUrl, + // QNBS-v3: without this, LM Studio/vLLM/custom local backends silently fell back to the + // Ollama-native protocol here even though the same settings work in the main Writer surface. + localBackendPreset: aiSettings.localBackendPreset, }), [aiSettings], ); diff --git a/services/aiProviderService.ts b/services/aiProviderService.ts index fa59d248..6dc08d49 100644 --- a/services/aiProviderService.ts +++ b/services/aiProviderService.ts @@ -248,13 +248,28 @@ async function streamOpenAI( callbacks.onDone?.(); } -/** Streams LM Studio/vLLM through their OpenAI-compatible API using the Tauri-aware local transport. */ +// QNBS-v3: single source of truth for which local presets speak the OpenAI-compatible /v1 API — +// 'custom' is included because editing the base URL by hand (e.g. LM Studio/vLLM on a non-default +// port) is what selects it, and listLocalBackendModels already treats it as OpenAI-compatible for +// discovery; every routing decision (testing, streaming, non-streaming) must agree or a server +// that lists its models successfully then fails every completion against the wrong protocol. +// undefined (a caller that never set the field) falls through to native-Ollama, matching the +// pre-existing default for provider-agnostic callers that never touch this option. +function isOpenAiCompatibleLocalPreset(preset: LocalBackendPreset | undefined): boolean { + return preset === 'lm_studio' || preset === 'vllm' || preset === 'custom'; +} + +/** Streams LM Studio/vLLM/custom through their OpenAI-compatible API using the Tauri-aware local transport. */ async function streamOpenAiCompatibleLocal( prompt: string, opts: AIRequestOptions, callbacks: AIStreamCallbacks, ): Promise { - const endpoint = normalizeOpenAiCompatibleBaseUrl(opts.ollamaBaseUrl ?? 'http://localhost:1234'); + // QNBS-v3: `||`, not `??` — an explicitly-cleared ollamaBaseUrl ('') must still resolve to the + // same preset-aware default testOpenAiCompatibleLocalConnection uses, not an app-relative URL. + const endpoint = normalizeOpenAiCompatibleBaseUrl( + opts.ollamaBaseUrl?.trim() || 'http://localhost:1234', + ); const messages = opts.systemPrompt ? [ { role: 'system', content: sanitizePromptValue(opts.systemPrompt) }, @@ -271,36 +286,62 @@ async function streamOpenAiCompatibleLocal( temperature: opts.temperature ?? 0.7, max_tokens: opts.maxTokens ?? 2048, }), + // QNBS-v3: LocalServerFetchInit.signal is typed AbortSignal | null (not | undefined) — this is + // this codebase's own wrapper (composeSignal handles null explicitly), not a raw Fetch API + // pass-through, so `?? null` here is a required conversion, not an inconsistency. signal: opts.signal ?? null, }); - if (!response.ok) throw new Error(`Local OpenAI-compatible server HTTP ${response.status}`); + if (!response.ok) { + // QNBS-v3: LM Studio/vLLM return a JSON error body (invalid model, bad request) that the + // status code alone discards — bounded read, best-effort parse, never throws itself. + const bodyText = await response.text().catch(() => ''); + let detail = ''; + try { + const parsed = JSON.parse(bodyText) as { error?: { message?: string } | string }; + detail = + typeof parsed.error === 'string' ? parsed.error : (parsed.error?.message ?? bodyText); + } catch { + detail = bodyText; + } + const suffix = detail.trim() ? `: ${detail.trim().slice(0, 300)}` : ''; + throw new Error(`Local OpenAI-compatible server HTTP ${response.status}${suffix}`); + } const reader = response.body?.getReader(); if (!reader) throw new Error('Local OpenAI-compatible server returned no response body'); const decoder = new TextDecoder(); let buffer = ''; + // QNBS-v3: parses one accumulated SSE line into an onChunk call — shared by the loop below and + // the final buffer flush after `done`, so the last frame (no trailing newline) isn't dropped. + const parseLine = (line: string) => { + if (!line.startsWith('data: ') || line === 'data: [DONE]') return; + try { + const json: unknown = JSON.parse(line.slice(6)); + const delta = + typeof json === 'object' && json !== null + ? (json as { choices?: Array<{ delta?: { content?: unknown } }> }).choices?.[0]?.delta + ?.content + : undefined; + if (typeof delta === 'string' && delta) callbacks.onChunk(delta); + } catch { + // QNBS-v3: Ignore an incomplete SSE frame; a later frame still carries the valid delta. + } + }; while (true) { - if (opts.signal?.aborted) break; + if (opts.signal?.aborted) { + await reader.cancel().catch(() => {}); + throw new DOMException('Local generation aborted', 'AbortError'); + } const { done, value } = await reader.read(); if (done) break; buffer += decoder.decode(value, { stream: true }); const lines = buffer.split('\n'); buffer = lines.pop() ?? ''; - for (const line of lines) { - if (!line.startsWith('data: ') || line === 'data: [DONE]') continue; - try { - const json: unknown = JSON.parse(line.slice(6)); - const delta = - typeof json === 'object' && json !== null - ? (json as { choices?: Array<{ delta?: { content?: unknown } }> }).choices?.[0]?.delta - ?.content - : undefined; - if (typeof delta === 'string' && delta) callbacks.onChunk(delta); - } catch { - // QNBS-v3: Ignore an incomplete SSE frame; a later frame still carries the valid delta. - } - } + for (const line of lines) parseLine(line); } + // QNBS-v3: the server can close the stream without a trailing newline after the last `data:` + // frame — without this, that final delta (often the tail of the response) is silently dropped. + if (buffer) parseLine(buffer); callbacks.onDone?.(); } @@ -435,7 +476,7 @@ async function streamProvider( return streamOpenRouter(prompt, oWithLora, callbacks, apiKey); } case 'ollama': - return oWithLora.localBackendPreset === 'lm_studio' || oWithLora.localBackendPreset === 'vllm' + return isOpenAiCompatibleLocalPreset(oWithLora.localBackendPreset) ? streamOpenAiCompatibleLocal(prompt, oWithLora, callbacks) : streamOllama(prompt, oWithLora, callbacks); case 'anthropic': @@ -495,10 +536,9 @@ async function generateTextSingleProvider( } case 'ollama': { let result = ''; - const stream = - o.localBackendPreset === 'lm_studio' || o.localBackendPreset === 'vllm' - ? streamOpenAiCompatibleLocal - : streamOllama; + const stream = isOpenAiCompatibleLocalPreset(o.localBackendPreset) + ? streamOpenAiCompatibleLocal + : streamOllama; await stream(prompt, o, { onChunk: (text) => { result += text; @@ -921,7 +961,7 @@ export async function listLocalBackendModels( baseUrl: string | undefined, preset: LocalBackendPreset, ): Promise { - if (preset === 'ollama_default') return listOllamaModels(baseUrl); + if (!isOpenAiCompatibleLocalPreset(preset)) return listOllamaModels(baseUrl); const result = await testOpenAiCompatibleLocalConnection(baseUrl); return result.ok ? (result.localServer?.modelNames ?? []) : []; } @@ -1059,10 +1099,9 @@ export async function testAIConnection( kind: 'desktopRequired', }; } - const result = - opts.localBackendPreset === 'lm_studio' || opts.localBackendPreset === 'vllm' - ? await testOpenAiCompatibleLocalConnection(opts.ollamaBaseUrl) - : await testOllamaConnection(opts.ollamaBaseUrl); + const result = isOpenAiCompatibleLocalPreset(opts.localBackendPreset) + ? await testOpenAiCompatibleLocalConnection(opts.ollamaBaseUrl) + : await testOllamaConnection(opts.ollamaBaseUrl); // QNBS-v3 (ADR-0017): the Fetch API gives an identical generic failure for "CORS rejected" // and "server genuinely down" — this can only be a heuristic hint when running the opt-in // browser path, never a certain diagnosis. Desktop keeps the plain 'unreachable' kind. diff --git a/tests/unit/aiProviderService.test.ts b/tests/unit/aiProviderService.test.ts index 3d8e0fe8..3f289664 100644 --- a/tests/unit/aiProviderService.test.ts +++ b/tests/unit/aiProviderService.test.ts @@ -406,6 +406,176 @@ describe('testAIConnection — ollama desktop branch', () => { }); }); +// QNBS-v3: regression coverage for streamOpenAiCompatibleLocal — SSE final-buffer flush, abort +// propagation, blank-URL resolution, error-body inclusion, and 'custom' preset routing. +describe('streamText — LM Studio/vLLM/custom OpenAI-compatible local streaming', () => { + afterEach(() => { + delete (window as { __TAURI_INTERNALS__?: unknown }).__TAURI_INTERNALS__; + }); + + it('flushes the final SSE frame when the server closes the stream without a trailing newline', async () => { + (window as { __TAURI_INTERNALS__?: unknown }).__TAURI_INTERNALS__ = {}; + // No trailing "\n" after the last data: frame — the server closed the connection right there. + mockPluginHttpFetch.mockResolvedValueOnce( + new Response( + 'data: {"choices":[{"delta":{"content":"Hello"}}]}\n' + + 'data: {"choices":[{"delta":{"content":" world"}}]}', + { status: 200 }, + ), + ); + const chunks: string[] = []; + + await streamText( + 'Continue this scene', + 'Balanced', + { + provider: 'ollama', + model: 'ollama/local-model', + ollamaBaseUrl: 'http://localhost:1234', + localBackendPreset: 'lm_studio', + }, + { onChunk: (chunk) => chunks.push(chunk) }, + ); + + expect(chunks).toEqual(['Hello', ' world']); + }); + + it('resolves a blank ollamaBaseUrl to the same default diagnostics use, not an app-relative URL', async () => { + (window as { __TAURI_INTERNALS__?: unknown }).__TAURI_INTERNALS__ = {}; + mockPluginHttpFetch.mockResolvedValueOnce( + new Response('data: {"choices":[{"delta":{"content":"hi"}}]}\n', { status: 200 }), + ); + const chunks: string[] = []; + + await streamText( + 'prompt', + 'Balanced', + { + provider: 'ollama', + model: 'ollama/local-model', + ollamaBaseUrl: '', + localBackendPreset: 'lm_studio', + }, + { onChunk: (chunk) => chunks.push(chunk) }, + ); + + expect(mockPluginHttpFetch).toHaveBeenCalledWith( + 'http://localhost:1234/v1/chat/completions', + expect.anything(), + ); + }); + + it('includes the server JSON error body in the thrown error, not just the HTTP status', async () => { + (window as { __TAURI_INTERNALS__?: unknown }).__TAURI_INTERNALS__ = {}; + mockPluginHttpFetch.mockResolvedValueOnce( + new Response(JSON.stringify({ error: { message: 'model "foo" not found' } }), { + status: 400, + }), + ); + + await expect( + streamText( + 'prompt', + 'Balanced', + { + provider: 'ollama', + model: 'ollama/local-model', + ollamaBaseUrl: 'http://localhost:1234', + localBackendPreset: 'lm_studio', + }, + { onChunk: () => {} }, + ), + ).rejects.toThrow(/model "foo" not found/); + }); + + it('routes the custom preset through /v1/chat/completions, not Ollama /api/generate', async () => { + (window as { __TAURI_INTERNALS__?: unknown }).__TAURI_INTERNALS__ = {}; + mockPluginHttpFetch.mockResolvedValueOnce( + new Response('data: {"choices":[{"delta":{"content":"hi"}}]}\n', { status: 200 }), + ); + const chunks: string[] = []; + + await streamText( + 'prompt', + 'Balanced', + { + provider: 'ollama', + model: 'ollama/local-model', + ollamaBaseUrl: 'http://localhost:9999', + localBackendPreset: 'custom', + }, + { onChunk: (chunk) => chunks.push(chunk) }, + ); + + expect(chunks).toEqual(['hi']); + expect(mockPluginHttpFetch).toHaveBeenCalledWith( + 'http://localhost:9999/v1/chat/completions', + expect.objectContaining({ method: 'POST' }), + ); + }); + + it('throws an AbortError instead of completing when the signal aborts mid-stream', async () => { + (window as { __TAURI_INTERNALS__?: unknown }).__TAURI_INTERNALS__ = {}; + const ac = new AbortController(); + const encoder = new TextEncoder(); + const chunks: string[] = []; + let resolveHold!: () => void; + + mockPluginHttpFetch.mockResolvedValueOnce( + new Response( + new ReadableStream({ + start(controller) { + controller.enqueue( + encoder.encode('data: {"choices":[{"delta":{"content":"chunk1"}}]}\n'), + ); + const hold = new Promise((res) => { + resolveHold = res; + }); + void hold.then(() => { + controller.enqueue( + encoder.encode('data: {"choices":[{"delta":{"content":"chunk2"}}]}\n'), + ); + controller.enqueue(encoder.encode('data: [DONE]\n')); + controller.close(); + }); + }, + }), + { status: 200 }, + ), + ); + + const streamPromise = streamText( + 'prompt', + 'Balanced', + { + provider: 'ollama', + model: 'ollama/local-model', + ollamaBaseUrl: 'http://localhost:1234', + localBackendPreset: 'lm_studio', + }, + { onChunk: (chunk) => chunks.push(chunk) }, + ac.signal, + ); + + await new Promise((res) => { + const interval = setInterval(() => { + if (chunks.length > 0) { + clearInterval(interval); + ac.abort(); + resolveHold(); + res(); + } + }, 5); + }); + + // QNBS-v3: the abort check runs at the top of the read loop, so a read() already in flight + // when abort() fires can still resolve with its chunk (same best-effort race the sibling + // streamOpenAI abort test documents) — what matters is that the function throws instead of + // resolving/calling onDone, so no caller can treat this partial output as a completed request. + await expect(streamPromise).rejects.toThrow(); + }); +}); + // QNBS-v3 (ADR-0017): opt-in direct browser→Ollama connection. window.__TAURI_INTERNALS__ is never // set in this describe block — every case here runs in the plain-browser context. describe('testAIConnection — ollama browser opt-in (ADR-0017)', () => { diff --git a/tests/unit/useConsistencyCheckerView.test.tsx b/tests/unit/useConsistencyCheckerView.test.tsx index af080b2c..a59b0265 100644 --- a/tests/unit/useConsistencyCheckerView.test.tsx +++ b/tests/unit/useConsistencyCheckerView.test.tsx @@ -131,7 +131,13 @@ describe('useConsistencyCheckerView', () => { expect(mockGenerateText).toHaveBeenCalledWith( 'check prompt', mockState.aiCreativity, - expect.objectContaining({ provider: expect.any(String), model: expect.any(String) }), + expect.objectContaining({ + provider: expect.any(String), + model: expect.any(String), + // QNBS-v3: regression — LM Studio/vLLM/custom local backends silently fell back to the + // Ollama-native protocol here since this field was previously omitted. + localBackendPreset: 'ollama_default', + }), expect.any(Object), ); diff --git a/tests/unit/useCriticView.test.tsx b/tests/unit/useCriticView.test.tsx index 66a6acd8..3d365ffb 100644 --- a/tests/unit/useCriticView.test.tsx +++ b/tests/unit/useCriticView.test.tsx @@ -79,7 +79,13 @@ describe('useCriticView', () => { expect(mockGenerateText).toHaveBeenCalledWith( 'mock-prompt', mockState.aiCreativity, - expect.objectContaining({ provider: 'gemini', model: 'gemini-2.5-flash' }), + expect.objectContaining({ + provider: 'gemini', + model: 'gemini-2.5-flash', + // QNBS-v3: regression — LM Studio/vLLM/custom local backends silently fell back to the + // Ollama-native protocol here since this field was previously omitted. + localBackendPreset: 'ollama_default', + }), expect.any(AbortSignal), ); expect(result.current.analysisResult).toBe('AI critique result'); From 2438f991afa4ad3630573e2d12a8fcf83f5280d5 Mon Sep 17 00:00:00 2001 From: qnbs <155236708+qnbs@users.noreply.github.com> Date: Tue, 11 Aug 2026 21:01:18 +0200 Subject: [PATCH 38/78] test: cover vLLM preset and invalid OpenAI-compatible models responses Sourcery flagged that testAIConnection's LM Studio-preset coverage had no vLLM equivalent, and testOpenAiCompatibleLocalConnection's invalid-response branch (non-array data, non-JSON body) had no direct test at all. Co-Authored-By: Claude Sonnet 5 --- tests/unit/aiProviderService.test.ts | 46 ++++++++++++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/tests/unit/aiProviderService.test.ts b/tests/unit/aiProviderService.test.ts index 3f289664..36aab3ce 100644 --- a/tests/unit/aiProviderService.test.ts +++ b/tests/unit/aiProviderService.test.ts @@ -49,6 +49,7 @@ import { scanLocalOpenAiCompatibleEndpoints, streamText, testAIConnection, + testOpenAiCompatibleLocalConnection, } from '../../services/aiProviderService'; import * as geminiService from '../../services/geminiService'; import * as localAiFacade from '../../services/localAiFacade'; @@ -370,6 +371,51 @@ describe('testAIConnection — ollama desktop branch', () => { expect(result).toMatchObject({ ok: false, kind: 'noModels' }); }); + it('uses the OpenAI-compatible models endpoint for the vLLM preset', async () => { + (window as { __TAURI_INTERNALS__?: unknown }).__TAURI_INTERNALS__ = {}; + mockPluginHttpFetch.mockResolvedValueOnce( + new Response(JSON.stringify({ data: [{ id: 'vllm-local-model' }] }), { status: 200 }), + ); + + const result = await testAIConnection('ollama', { + ollamaBaseUrl: 'http://127.0.0.1:8000/', + localBackendPreset: 'vllm', + }); + + expect(result).toMatchObject({ + ok: true, + localServer: { + normalizedEndpoint: 'http://127.0.0.1:8000/v1', + transport: 'tauri-http', + modelNames: ['vllm-local-model'], + }, + }); + expect(mockPluginHttpFetch).toHaveBeenCalledWith( + 'http://127.0.0.1:8000/v1/models', + expect.any(Object), + ); + }); + + it('returns invalidResponse when the models payload has a non-array data field', async () => { + (window as { __TAURI_INTERNALS__?: unknown }).__TAURI_INTERNALS__ = {}; + mockPluginHttpFetch.mockResolvedValueOnce( + new Response(JSON.stringify({ data: { id: 'not-an-array' } }), { status: 200 }), + ); + + const result = await testOpenAiCompatibleLocalConnection('http://127.0.0.1:9999'); + + expect(result).toMatchObject({ ok: false, kind: 'invalidResponse' }); + }); + + it('returns invalidResponse when the models response body is not JSON', async () => { + (window as { __TAURI_INTERNALS__?: unknown }).__TAURI_INTERNALS__ = {}; + mockPluginHttpFetch.mockResolvedValueOnce(new Response('not json', { status: 200 })); + + const result = await testOpenAiCompatibleLocalConnection('http://127.0.0.1:9999'); + + expect(result).toMatchObject({ ok: false, kind: 'invalidResponse' }); + }); + it('streams legacy LM Studio requests through /v1/chat/completions, not Ollama /api', async () => { (window as { __TAURI_INTERNALS__?: unknown }).__TAURI_INTERNALS__ = {}; mockPluginHttpFetch.mockResolvedValueOnce( From 3253c41c04923f4acddb9cd6e02cdd2de963d1a8 Mon Sep 17 00:00:00 2001 From: qnbs <155236708+qnbs@users.noreply.github.com> Date: Tue, 11 Aug 2026 21:15:30 +0200 Subject: [PATCH 39/78] fix: resolve tsgo narrowing issue in AiProviderCard's stale-context test resolveTest (let, reassigned inside a Promise executor closure) was narrowed to never by tsgo after the awaits earlier in the test, following the merge of fix/desktop-reliability-hardening into this branch. An explicit type annotation on the captured const sidesteps the incorrect inference. Co-Authored-By: Claude Sonnet 5 --- tests/unit/settings/AiProviderCard.test.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/unit/settings/AiProviderCard.test.tsx b/tests/unit/settings/AiProviderCard.test.tsx index 291114bc..efbf8eb8 100644 --- a/tests/unit/settings/AiProviderCard.test.tsx +++ b/tests/unit/settings/AiProviderCard.test.tsx @@ -361,7 +361,10 @@ describe('AiProviderCard — ollama provider (#266)', () => { ); if (!resolveTest) throw new Error('Connection test did not start'); - resolveTest({ + // QNBS-v3: explicit annotation, not inferred — tsgo narrows a `let` reassigned inside the + // Promise executor closure to `never` here otherwise, after the awaits above. + const resolve: (result: Awaited>) => void = resolveTest; + resolve({ ok: true, localServer: { normalizedEndpoint: 'http://127.0.0.1:1234/v1', From 56e2509921445fe2f02069abeac54805a37056e0 Mon Sep 17 00:00:00 2001 From: qnbs <155236708+qnbs@users.noreply.github.com> Date: Tue, 11 Aug 2026 21:53:10 +0200 Subject: [PATCH 40/78] fix: enforce legal phase transitions and close the checkpoint cursor-stall gap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit saveIfCurrent (the shared CAS write path for both updateEncryptionMigrationJournal and completeEncryptionMigration) only validated revision/owner match — it never validated that a phase TRANSITION was a legal state-machine step. Any caller could pass a journal still 'prepared' with {phase: 'committing'} (or call completeEncryptionMigration on it directly) and skip conversion and verification entirely; assertNoActiveEncryptionMigration() would then treat the unconverted data as safe for ordinary access. Confirmed exploitable via the codebase's own test helper (markJournalCommitting), which jumped prepared straight to committing purely for test setup convenience — exactly the shape of the described attack. Adds an allowed-transition table enforced inside saveIfCurrent against the durably-stored phase (not the caller-supplied one), with same-phase entries permitted for the repeated per-batch/per-store checkpoint writes within migrating/verifying, and recovery-required reachable from any active phase but never left except via out-of-band recovery. Updates the test helper to route through the legal prepared→migrating→verifying→committing chain instead of skipping it. Separately, protectedStoreMigration.ts's nextCheckpoint accepted a nonterminal batch reporting progress without an advanced cursor, silently retaining the previous cursor — the next iteration would replay the same records forever, inflating `processed` without ever completing. Now rejected explicitly. Also fixes five pre-existing typecheck errors uncovered while working in these files (unrelated to the above): a narrowing gap in parseJournal's ownerLeaseExpiresAt validation, four index-signature property accesses in storageEncryptionService.ts, one noUncheckedIndexedAccess gap in a test, and four exactOptionalPropertyTypes violations in secondaryPayloadStoreAdapter.test.ts. Co-Authored-By: Claude Sonnet 5 --- .../storage/encryptionMigrationJournal.ts | 51 +++++++++++++++++++ services/storage/protectedStoreMigration.ts | 9 ++++ services/storage/storageEncryptionService.ts | 12 ++--- .../encryptionMigrationJournal.test.ts | 21 ++++++-- .../storage/protectedStoreMigration.test.ts | 25 +++++++++ .../secondaryPayloadStoreAdapter.test.ts | 8 +-- .../storage/storageEncryptionService.test.ts | 22 +++++--- 7 files changed, 129 insertions(+), 19 deletions(-) diff --git a/services/storage/encryptionMigrationJournal.ts b/services/storage/encryptionMigrationJournal.ts index ea5ae7f2..1b945424 100644 --- a/services/storage/encryptionMigrationJournal.ts +++ b/services/storage/encryptionMigrationJournal.ts @@ -77,6 +77,50 @@ export class IdbMigrationOwnershipError extends Error { } } +/** Raised when a caller-supplied phase would skip required migration/verification work. */ +export class IdbMigrationInvalidTransitionError extends Error { + readonly code = 'ENCRYPTION_MIGRATION_INVALID_TRANSITION' as const; + + constructor(from: EncryptionMigrationPhase, to: EncryptionMigrationPhase) { + super(`Encryption migration cannot move from ${from} to ${to}`); + this.name = 'IdbMigrationInvalidTransitionError'; + } +} + +// QNBS-v3: enforced against the DURABLY-STORED phase (not the caller-supplied one) inside +// saveIfCurrent — CAS alone (revision/owner match) never validated that a phase transition was a +// legal state-machine step, so any caller could pass e.g. {phase: 'committing'} on a journal still +// 'prepared' and skip conversion/verification entirely; assertNoActiveEncryptionMigration() would +// then treat unconverted data as safe to access normally. Same-phase entries allow the repeated +// per-batch/per-store checkpoint writes within migrating/verifying. recovery-required is reachable +// from any active phase (an external fail-safe) but never left except via out-of-band recovery. +const ALLOWED_PHASE_TRANSITIONS: Record< + EncryptionMigrationPhase, + ReadonlySet +> = { + prepared: new Set(['prepared', 'migrating', 'recovery-required']), + migrating: new Set(['migrating', 'verifying', 'recovery-required']), + verifying: new Set(['verifying', 'committing', 'recovery-required']), + committing: new Set([ + 'committing', + 'cleanup', + 'completed', + 'recovery-required', + ]), + cleanup: new Set(['cleanup', 'completed', 'recovery-required']), + completed: new Set(['completed']), + 'recovery-required': new Set(['recovery-required']), +}; + +function assertLegalPhaseTransition( + from: EncryptionMigrationPhase, + to: EncryptionMigrationPhase, +): void { + if (!ALLOWED_PHASE_TRANSITIONS[from].has(to)) { + throw new IdbMigrationInvalidTransitionError(from, to); + } +} + function isPhase(value: unknown): value is EncryptionMigrationPhase { return ( value === 'prepared' || @@ -115,6 +159,7 @@ function parseJournal(raw: unknown): EncryptionMigrationJournal | null { (value.ownerId !== undefined && (typeof value.ownerId !== 'string' || value.ownerId.length === 0 || + typeof value.ownerLeaseExpiresAt !== 'number' || !Number.isSafeInteger(value.ownerLeaseExpiresAt) || value.ownerLeaseExpiresAt < 0)) ) { @@ -239,6 +284,12 @@ class EncryptionMigrationJournalStore extends IdbConnectionManager { reject(new IdbMigrationOwnershipError()); return; } + try { + assertLegalPhaseTransition(existing.phase, next.phase); + } catch (transitionError) { + reject(transitionError); + return; + } const putRequest = store.put(next, JOURNAL_RECORD_KEY); putRequest.onerror = () => reject(putRequest.error); writeQueued = true; diff --git a/services/storage/protectedStoreMigration.ts b/services/storage/protectedStoreMigration.ts index 876adc5e..5cc4b899 100644 --- a/services/storage/protectedStoreMigration.ts +++ b/services/storage/protectedStoreMigration.ts @@ -83,6 +83,15 @@ function nextCheckpoint( `Store ${checkpoint.id} made no progress without completing`, ); } + // QNBS-v3: a nonterminal batch that reports progress but omits cursor would retain the previous + // cursor forever — the next iteration replays the same records, inflating `processed` without + // ever advancing (the batch.cursor contract only permits omitting it before any record is + // committed, i.e. on a batch that makes no progress; that case is already rejected above). + if (!batch.complete && batch.cursor === undefined) { + throw new ProtectedStoreMigrationAdapterError( + `Store ${checkpoint.id} reported progress without advancing its cursor`, + ); + } const cursor = batch.cursor ?? checkpoint.cursor; return { ...checkpoint, diff --git a/services/storage/storageEncryptionService.ts b/services/storage/storageEncryptionService.ts index 93c3804c..1fc1db25 100644 --- a/services/storage/storageEncryptionService.ts +++ b/services/storage/storageEncryptionService.ts @@ -377,12 +377,12 @@ export function isSecureRecordEnvelope(value: unknown): value is SecureRecordEnv if (!isSecureRecordCandidate(value)) return false; const record = value as Record; return ( - (record.version === LEGACY_BOUND_SECURE_RECORD_VERSION || - record.version === SECURE_RECORD_VERSION) && - record.iv instanceof Uint8Array && - record.iv.length === IV_BYTE_LENGTH && - record.ciphertext instanceof Uint8Array && - record.ciphertext.length >= 16 + (record['version'] === LEGACY_BOUND_SECURE_RECORD_VERSION || + record['version'] === SECURE_RECORD_VERSION) && + record['iv'] instanceof Uint8Array && + record['iv'].length === IV_BYTE_LENGTH && + record['ciphertext'] instanceof Uint8Array && + record['ciphertext'].length >= 16 ); } diff --git a/tests/unit/storage/encryptionMigrationJournal.test.ts b/tests/unit/storage/encryptionMigrationJournal.test.ts index 1476fe2e..1b77fd2f 100644 --- a/tests/unit/storage/encryptionMigrationJournal.test.ts +++ b/tests/unit/storage/encryptionMigrationJournal.test.ts @@ -60,8 +60,23 @@ function replaceStoredJournalForTest(value: unknown): Promise { }); } -function markJournalCommitting(journal: Awaited>) { - return updateEncryptionMigrationJournal(journal, { phase: 'committing', stores: journal.stores }); +// QNBS-v3: routes through the legal prepared→migrating→verifying→committing chain — saveIfCurrent +// now rejects skipping straight from prepared to committing (see IdbMigrationInvalidTransitionError). +async function markJournalCommitting( + journal: Awaited>, +) { + const migrating = await updateEncryptionMigrationJournal(journal, { + phase: 'migrating', + stores: journal.stores, + }); + const verifying = await updateEncryptionMigrationJournal(migrating, { + phase: 'verifying', + stores: migrating.stores, + }); + return updateEncryptionMigrationJournal(verifying, { + phase: 'committing', + stores: verifying.stores, + }); } beforeEach(() => { @@ -126,7 +141,7 @@ describe('encryption migration journal', () => { phase: 'migrating', stores: [ { - ...created.stores[0], + ...created.stores[0]!, cursor: 'project/settings', processed: 1, verified: 1, diff --git a/tests/unit/storage/protectedStoreMigration.test.ts b/tests/unit/storage/protectedStoreMigration.test.ts index 8d1aec11..6136702f 100644 --- a/tests/unit/storage/protectedStoreMigration.test.ts +++ b/tests/unit/storage/protectedStoreMigration.test.ts @@ -163,6 +163,31 @@ describe('runProtectedStoreMigration', () => { }); }); + it('rejects a nonterminal batch that reports progress without advancing its cursor', async () => { + // QNBS-v3: without this guard, an adapter violating the "cursor omitted only before any record + // is committed" contract would retain the stale cursor forever — the runner would call + // migrateNext with the same cursor on every iteration, replaying the same records and + // inflating `processed` without ever terminating. + const adapter: ProtectedStoreAdapter = { + id: 'test-store', + replaySafe: true, + async migrateNext() { + return { processed: 1, complete: false }; + }, + async verify() { + return 0; + }, + }; + + await expect( + runProtectedStoreMigration(await begin(), [adapter], migrationKeys), + ).rejects.toThrow('reported progress without advancing its cursor'); + await expect(readEncryptionMigrationJournal()).resolves.toMatchObject({ + phase: 'migrating', + stores: [{ processed: 0, done: false }], + }); + }); + it('rejects invalid adapter progress before it can advance the durable checkpoint', async () => { const adapter: ProtectedStoreAdapter = { id: 'test-store', diff --git a/tests/unit/storage/secondaryPayloadStoreAdapter.test.ts b/tests/unit/storage/secondaryPayloadStoreAdapter.test.ts index 937aa489..06cb45f8 100644 --- a/tests/unit/storage/secondaryPayloadStoreAdapter.test.ts +++ b/tests/unit/storage/secondaryPayloadStoreAdapter.test.ts @@ -121,13 +121,13 @@ describe('secondary protected-store payload adapter', () => { const secondEnable = await adapter.migrateNext({ operation: 'enable', targetKey: sourceKey, - cursor: firstEnable.cursor, + ...(firstEnable.cursor !== undefined ? { cursor: firstEnable.cursor } : {}), }); expect(secondEnable).toMatchObject({ processed: 1, complete: false, cursor: 'b' }); const finishEnable = await adapter.migrateNext({ operation: 'enable', targetKey: sourceKey, - cursor: secondEnable.cursor, + ...(secondEnable.cursor !== undefined ? { cursor: secondEnable.cursor } : {}), }); expect(finishEnable).toEqual({ processed: 0, complete: true }); await expect(adapter.verify({ operation: 'enable', targetKey: sourceKey })).resolves.toBe(2); @@ -150,7 +150,7 @@ describe('secondary protected-store payload adapter', () => { operation: 'rekey', sourceKey, targetKey, - cursor: rekeyFirst.cursor, + ...(rekeyFirst.cursor !== undefined ? { cursor: rekeyFirst.cursor } : {}), }); await adapter.migrateNext({ operation: 'rekey', @@ -173,7 +173,7 @@ describe('secondary protected-store payload adapter', () => { await adapter.migrateNext({ operation: 'disable', sourceKey: targetKey, - cursor: disableFirst.cursor, + ...(disableFirst.cursor !== undefined ? { cursor: disableFirst.cursor } : {}), }); await adapter.migrateNext({ operation: 'disable', sourceKey: targetKey, cursor: 'b' }); await expect(adapter.verify({ operation: 'disable', sourceKey: targetKey })).resolves.toBe(2); diff --git a/tests/unit/storage/storageEncryptionService.test.ts b/tests/unit/storage/storageEncryptionService.test.ts index ac50ad05..56c99053 100644 --- a/tests/unit/storage/storageEncryptionService.test.ts +++ b/tests/unit/storage/storageEncryptionService.test.ts @@ -423,12 +423,22 @@ describe('verifyAndInitIdbEncryption', () => { await expect(setupIdbEncryption('replacement')).rejects.toBeInstanceOf( IdbMigrationInProgressError, ); - await completeEncryptionMigration( - await updateEncryptionMigrationJournal(journal, { - phase: 'committing', - stores: journal.stores, - }), - ); + // QNBS-v3: routes through the legal prepared→migrating→verifying→committing chain — a direct + // jump is now rejected (see IdbMigrationInvalidTransitionError) and would leak this journal + // into every later test in this file via assertNoActiveEncryptionMigration(). + const migrating = await updateEncryptionMigrationJournal(journal, { + phase: 'migrating', + stores: journal.stores, + }); + const verifying = await updateEncryptionMigrationJournal(migrating, { + phase: 'verifying', + stores: migrating.stores, + }); + const committing = await updateEncryptionMigrationJournal(verifying, { + phase: 'committing', + stores: verifying.stores, + }); + await completeEncryptionMigration(committing); }); it('throws on wrong passphrase (AES-GCM auth-tag mismatch)', async () => { From a8dd9175c19571046624b685e66977bb1e91d51d Mon Sep 17 00:00:00 2001 From: qnbs <155236708+qnbs@users.noreply.github.com> Date: Tue, 11 Aug 2026 21:53:46 +0200 Subject: [PATCH 41/78] fix: gate protected-store writes against an active migration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit saveSlice, saveImage, saveBinderAsset, saveStoryCodex, saveRagVectors, and createSnapshot all resolved the write key and wrote directly — none of them called assertIdbProtectedWriteAllowed() first, unlike their sibling delete/read methods (deleteImage, deleteBinderAsset, deleteStoryCodex, getSnapshotData, etc.), which already had it. An ordinary write could therefore proceed while a migration journal owned the store's encryption-lifecycle state, racing the migration's own conversion of that same data. Confirmed via a pre-existing, previously failing test (encryptionMigrationJournal.test.ts's "rejects a competing migration owner..." — IdbProjectStore().saveSettings() was expected to reject during an active migration but silently resolved). Also fixes two related gaps found while auditing this write path: - deleteAllBinderAssetsForProject deleted assets one transaction per asset — a later failure (or the migration guard starting to reject mid-loop) left earlier assets permanently removed while the project record and later assets survived, with no rollback. Batches every delete into one transaction so a failure aborts the whole batch. - The scene-revision and inference-cache secondary-store adapters used a record's `id`/`key` field as the migration cursor without validating it's actually a string, even though IndexedDB key paths permit numeric keys. An unvalidated numeric key would still successfully rewrite the payload but then persist a non-string journal cursor, which parseJournal() rejects on the next read — pushing the whole migration into recovery-required with a confusing root cause. Now fails fast with a specific error at the point of use. Co-Authored-By: Claude Sonnet 5 --- services/storage/idbAssetStore.ts | 33 ++++++- services/storage/idbCodexStore.ts | 4 + services/storage/idbProjectStore.ts | 3 + services/storage/idbSnapshotStore.ts | 2 + .../secondaryProtectedStoreAdapters.ts | 18 +++- tests/unit/storage/idbStoreEncryption.test.ts | 26 ++++++ .../secondaryProtectedStoreAdapters.test.ts | 86 +++++++++++++++++++ 7 files changed, 166 insertions(+), 6 deletions(-) create mode 100644 tests/unit/storage/secondaryProtectedStoreAdapters.test.ts diff --git a/services/storage/idbAssetStore.ts b/services/storage/idbAssetStore.ts index 0fa1bd80..47c0dc96 100644 --- a/services/storage/idbAssetStore.ts +++ b/services/storage/idbAssetStore.ts @@ -22,6 +22,8 @@ export class IdbAssetStore extends IdbSnapshotStore { // --- Image Store Methods --- async saveImage(id: string, base64: string): Promise { + // QNBS-v3: A migration journal owns store conversion — an ordinary write here must not race it. + await assertIdbProtectedWriteAllowed(); // QNBS-v3: Resolve the write key BEFORE opening the transaction — `await idbEncryptWithKey` // yields the event loop, which auto-commits an already-open IDB transaction // (TransactionInactiveError on put), and re-reading isIdbEncryptionReady() after any @@ -83,6 +85,8 @@ export class IdbAssetStore extends IdbSnapshotStore { meta: BinderAssetMeta, ): Promise { return retryDb(async () => { + // QNBS-v3: A migration journal owns store conversion — an ordinary write here must not race it. + await assertIdbProtectedWriteAllowed(); const writeKey = await resolveProtectedWriteKey(); const key = makeBinderAssetStorageKey(projectId, assetId); const fullMeta = { ...meta, byteSize: data.byteLength }; @@ -173,9 +177,30 @@ export class IdbAssetStore extends IdbSnapshotStore { } async deleteAllBinderAssetsForProject(projectId: string): Promise { - await assertIdbProtectedWriteAllowed(); - const ids = await this.listBinderAssetIds(projectId); - // QNBS-v3: Sequential deletion avoids a large project creating an unbounded transaction burst. - for (const id of ids) await this.deleteBinderAsset(projectId, id); + return retryDb(async () => { + await assertIdbProtectedWriteAllowed(); + const ids = await this.listBinderAssetIds(projectId); + if (ids.length === 0) return; + // QNBS-v3: one transaction for every delete, not one transaction PER asset — a later failure + // aborts the whole batch (IDB rolls back everything already queued in it) instead of + // leaving earlier assets permanently removed while later ones and the project record + // survive. All requests are queued synchronously below the store fetch so IDB never + // auto-commits the transaction mid-batch. + const store = await this.getObjectStore(BINDER_ASSETS_STORE, 'readwrite'); + const transaction = store.transaction; + return new Promise((resolve, reject) => { + let failure: string | undefined; + for (const id of ids) { + const request = store.delete(makeBinderAssetStorageKey(projectId, id)); + request.onerror = () => { + failure = getUserFriendlyDbError(request.error); + transaction.abort(); + }; + } + transaction.oncomplete = () => resolve(); + transaction.onerror = () => reject(transaction.error); + transaction.onabort = () => reject(failure ?? getUserFriendlyDbError(transaction.error)); + }); + }); } } diff --git a/services/storage/idbCodexStore.ts b/services/storage/idbCodexStore.ts index 83f2e78f..42cafa86 100644 --- a/services/storage/idbCodexStore.ts +++ b/services/storage/idbCodexStore.ts @@ -19,6 +19,8 @@ import { export class IdbCodexStore extends IdbKeyStore { async saveStoryCodex(codex: StoryCodex): Promise { + // QNBS-v3: A migration journal owns store conversion — an ordinary write here must not race it. + await assertIdbProtectedWriteAllowed(); // QNBS-v3: Resolve the write key BEFORE opening the transaction — `await idbEncryptWithKey` // yields the event loop, which auto-commits an already-open transaction // (TransactionInactiveError), and re-reading isIdbEncryptionReady() after any later @@ -99,6 +101,8 @@ export class IdbCodexStore extends IdbKeyStore { // --- RAG Vector Methods --- async saveRagVectors(projectId: string, vectors: unknown[]): Promise { + // QNBS-v3: A migration journal owns store conversion — an ordinary write here must not race it. + await assertIdbProtectedWriteAllowed(); // QNBS-v3: Resolve the write key BEFORE opening the transaction — `await idbEncryptWithKey` // yields the event loop and would auto-commit the open transaction before the put // (TransactionInactiveError), and re-reading isIdbEncryptionReady() after any later diff --git a/services/storage/idbProjectStore.ts b/services/storage/idbProjectStore.ts index 1510d577..195ece81 100644 --- a/services/storage/idbProjectStore.ts +++ b/services/storage/idbProjectStore.ts @@ -216,6 +216,9 @@ export class IdbProjectStore extends IdbAssetStore { sliceName: 'project' | 'settings', data: PersistedProjectState | Settings, ): Promise { + // QNBS-v3: A migration journal owns store conversion — an ordinary write here must not race + // it (assertIdbProtectedWriteAllowed also covers the plain-locked-session case). + await assertIdbProtectedWriteAllowed(); // QNBS-v3: Resolve the write key AND encrypt BEFORE opening the store — awaiting // idbEncryptWithKey after getObjectStore would yield the event loop while the // transaction is open, letting IDB auto-commit it before store.put runs diff --git a/services/storage/idbSnapshotStore.ts b/services/storage/idbSnapshotStore.ts index 28b0fe0e..a2ad809f 100644 --- a/services/storage/idbSnapshotStore.ts +++ b/services/storage/idbSnapshotStore.ts @@ -24,6 +24,8 @@ export class IdbSnapshotStore extends IdbCodexStore { protected readonly MAX_AUTO_SNAPSHOTS = 20; async createSnapshot(data: ProjectData, name?: string): Promise { + // QNBS-v3: A migration journal owns store conversion — an ordinary write here must not race it. + await assertIdbProtectedWriteAllowed(); const wordCount = data.manuscript.reduce( (sum, section) => sum + (section.content?.split(/\s+/).filter(Boolean).length || 0), 0, diff --git a/services/storage/secondaryProtectedStoreAdapters.ts b/services/storage/secondaryProtectedStoreAdapters.ts index 46359e01..3b03dffa 100644 --- a/services/storage/secondaryProtectedStoreAdapters.ts +++ b/services/storage/secondaryProtectedStoreAdapters.ts @@ -62,6 +62,20 @@ function assertExactKeys( } } +// QNBS-v3: IndexedDB key paths permit numeric keys even though these interfaces declare `id`/`key` +// as string — a record fetched from storage is not runtime-validated against that declaration. An +// unvalidated non-string routing key would still successfully rewrite the payload but then +// persist a non-string journal cursor, which parseJournal() rejects on the next read and pushes +// the whole migration into recovery-required. Fail fast here with a clear, specific error instead. +function assertStringRoutingKey(value: unknown, field: string, store: string): string { + if (typeof value !== 'string' || value.length === 0) { + throw new ProtectedStoreMigrationAdapterError( + `${store} record has a non-string ${field}; recovery must reconcile it before migration`, + ); + } + return value; +} + function isSceneRevisionPayload(value: unknown): value is SceneRevisionPayload { if (typeof value !== 'object' || value === null) return false; const payload = value as Partial; @@ -126,7 +140,7 @@ const sceneRevisionAdapterSpec: SecondaryPayloadStoreAdapterSpec< id: `${SCENE_REVISIONS_DB}/${SCENE_REVISIONS_STORE}`, databaseName: SCENE_REVISIONS_DB, storeName: SCENE_REVISIONS_STORE, - recordId: (record) => record.id, + recordId: (record) => assertStringRoutingKey(record.id, 'id', 'scene revisions'), context: (recordId) => ({ store: `${SCENE_REVISIONS_DB}/${SCENE_REVISIONS_STORE}`, recordId, @@ -147,7 +161,7 @@ const inferenceCacheAdapterSpec: SecondaryPayloadStoreAdapterSpec record.key, + recordId: (record) => assertStringRoutingKey(record.key, 'key', 'inference cache'), context: (recordId) => ({ store: `${INFERENCE_CACHE_DB}/${INFERENCE_CACHE_STORE}`, recordId, diff --git a/tests/unit/storage/idbStoreEncryption.test.ts b/tests/unit/storage/idbStoreEncryption.test.ts index 0f369f45..10d72bc2 100644 --- a/tests/unit/storage/idbStoreEncryption.test.ts +++ b/tests/unit/storage/idbStoreEncryption.test.ts @@ -274,6 +274,32 @@ describe('locked-state guards on destructive and listing operations', () => { await initIdbEncryption('test-pass'); await expect(store.listBinderAssetIds('proj-1')).resolves.toEqual(['asset-1']); }); + + it('deletes every binder asset for a project in a single batched transaction', async () => { + // QNBS-v3 regression: was one transaction PER asset — a later failure could leave earlier + // assets permanently removed. Now every delete is queued in one transaction (all-or-nothing). + const store = new IdbAssetStore(); + await store.saveBinderAsset('proj-1', 'a1', new ArrayBuffer(1), { + byteSize: 1, + mimeType: 'application/pdf', + originalFileName: 'a.pdf', + }); + await store.saveBinderAsset('proj-1', 'a2', new ArrayBuffer(1), { + byteSize: 1, + mimeType: 'application/pdf', + originalFileName: 'b.pdf', + }); + await store.saveBinderAsset('proj-other', 'a3', new ArrayBuffer(1), { + byteSize: 1, + mimeType: 'application/pdf', + originalFileName: 'c.pdf', + }); + + await store.deleteAllBinderAssetsForProject('proj-1'); + + await expect(store.listBinderAssetIds('proj-1')).resolves.toEqual([]); + await expect(store.listBinderAssetIds('proj-other')).resolves.toEqual(['a3']); + }); }); describe('locked reads reject instead of hanging (IDBRequest.onsuccess propagation)', () => { diff --git a/tests/unit/storage/secondaryProtectedStoreAdapters.test.ts b/tests/unit/storage/secondaryProtectedStoreAdapters.test.ts new file mode 100644 index 00000000..2380365c --- /dev/null +++ b/tests/unit/storage/secondaryProtectedStoreAdapters.test.ts @@ -0,0 +1,86 @@ +// @vitest-environment node +// QNBS-v3: Covers the non-string routing-key validation — a numeric id/key (IndexedDB key paths +// permit them even though these records declare id/key as string) must fail fast with a clear +// error instead of silently becoming a non-string journal cursor that later trips parseJournal() +// and pushes the whole migration into recovery-required. +import { IDBFactory } from 'fake-indexeddb'; +import { beforeEach, describe, expect, it } from 'vitest'; +import { ProtectedStoreMigrationAdapterError } from '../../../services/storage/protectedStoreMigration'; +import { getRegisteredSecondaryProtectedStoreAdapters } from '../../../services/storage/secondaryProtectedStoreAdapters'; + +const SCENE_REVISIONS_DB = 'worldscript-revisions-db'; +const SCENE_REVISIONS_STORE = 'scene-revisions'; +const INFERENCE_CACHE_DB = 'worldscript-inference-cache-db'; +const INFERENCE_CACHE_STORE = 'inference-cache'; + +function createDatabase(name: string, storeName: string, keyPath: string): Promise { + return new Promise((resolve, reject) => { + const request = indexedDB.open(name, 1); + request.onupgradeneeded = () => request.result.createObjectStore(storeName, { keyPath }); + request.onsuccess = () => { + request.result.close(); + resolve(); + }; + request.onerror = () => reject(request.error); + }); +} + +function putRecord(name: string, storeName: string, record: unknown): Promise { + return new Promise((resolve, reject) => { + const request = indexedDB.open(name); + request.onsuccess = () => { + const database = request.result; + const transaction = database.transaction(storeName, 'readwrite'); + transaction.objectStore(storeName).put(record); + transaction.oncomplete = () => { + database.close(); + resolve(); + }; + transaction.onerror = () => reject(transaction.error); + }; + request.onerror = () => reject(request.error); + }); +} + +beforeEach(() => { + globalThis.indexedDB = new IDBFactory(); +}); + +describe('secondaryProtectedStoreAdapters — routing-key validation', () => { + it('rejects a scene-revision record with a non-string id instead of persisting a bad cursor', async () => { + await createDatabase(SCENE_REVISIONS_DB, SCENE_REVISIONS_STORE, 'id'); + await putRecord(SCENE_REVISIONS_DB, SCENE_REVISIONS_STORE, { + id: 42, + sectionId: 'section-1', + createdAt: Date.now(), + title: 'Untitled', + content: 'Some content', + wordCount: 2, + }); + const [sceneAdapter] = getRegisteredSecondaryProtectedStoreAdapters(); + + await expect( + sceneAdapter!.migrateNext({ operation: 'enable', targetKey: {} as CryptoKey }), + ).rejects.toMatchObject({ + constructor: ProtectedStoreMigrationAdapterError, + message: expect.stringContaining('non-string id'), + }); + }); + + it('rejects an inference-cache record with a non-string key instead of persisting a bad cursor', async () => { + await createDatabase(INFERENCE_CACHE_DB, INFERENCE_CACHE_STORE, 'key'); + await putRecord(INFERENCE_CACHE_DB, INFERENCE_CACHE_STORE, { + key: 99, + timestamp: Date.now(), + result: 'cached text', + }); + const [, cacheAdapter] = getRegisteredSecondaryProtectedStoreAdapters(); + + await expect( + cacheAdapter!.migrateNext({ operation: 'enable', targetKey: {} as CryptoKey }), + ).rejects.toMatchObject({ + constructor: ProtectedStoreMigrationAdapterError, + message: expect.stringContaining('non-string key'), + }); + }); +}); From 46198b26359f2a91dba298b46c905273b287cdca Mon Sep 17 00:00:00 2001 From: qnbs <155236708+qnbs@users.noreply.github.com> Date: Tue, 11 Aug 2026 22:24:08 +0200 Subject: [PATCH 42/78] fix(storage): skip corrupt scene revisions instead of hiding history; narrow write guard to migration-only listRevisions now logs and skips a single damaged/unparseable revision instead of rejecting the whole call, so one corrupt record no longer hides an entire section's readable history (a genuine lock-state change still aborts the call). The TOCTOU guard reordering from the previous commit re-ran the full assertIdbProtectedWriteAllowed() (migration + lock check) immediately before each write's transaction opens. That double-checks the lock state that resolveProtectedWriteKey() already captured atomically with the encryption key, so a write that safely encrypted with a validly-resolved key was wrongly rejected if the session locked in between (caught by an existing regression test: "saveSlice still encrypts even when the key is cleared right after the write key is resolved"). The six write methods (saveSlice, saveImage, saveBinderAsset, saveStoryCodex, saveRagVectors, createSnapshot) now re-check only assertNoActiveEncryptionMigration() pre-write, newly re-exported from storageEncryptionService for this narrower use; delete-only methods that never resolve a write key keep the full guard since they have no earlier lock check. Co-Authored-By: Claude Sonnet 5 --- services/sceneRevisionService.ts | 16 +++++++++++++++- services/storage/idbAssetStore.ts | 9 +++++---- services/storage/idbCodexStore.ts | 9 +++++---- services/storage/idbProjectStore.ts | 6 +++--- services/storage/idbSnapshotStore.ts | 5 +++-- services/storage/storageEncryptionService.ts | 3 +++ tests/unit/sceneRevisionService.test.ts | 9 ++++++--- .../unit/services/storage/idbAssetStore.test.ts | 1 + .../services/storage/idbSnapshotStore.test.ts | 1 + 9 files changed, 42 insertions(+), 17 deletions(-) diff --git a/services/sceneRevisionService.ts b/services/sceneRevisionService.ts index d026566d..d859d78e 100644 --- a/services/sceneRevisionService.ts +++ b/services/sceneRevisionService.ts @@ -1,5 +1,6 @@ // QNBS-v3: Standalone IDB for scene revisions avoids a shared schema upgrade and keeps history bounded. import type { SceneRevision } from '../types'; +import { createLogger } from './logger'; import { assertSecureStorageReadable, assertSecureStorageWritableForMutation, @@ -15,6 +16,7 @@ const STORE = 'scene-revisions'; const SECURE_STORE = `${DB_NAME}/${STORE}`; const MAX_PER_SCENE = 50; const RECORD_SCHEMA_VERSION = 1; +const log = createLogger('sceneRevisionService'); interface SceneRevisionPayload { title: string; @@ -239,7 +241,19 @@ export async function listRevisions(sectionId: string): Promise const revisions: SceneRevision[] = []; // QNBS-v3: Sequential decryption keeps a full scene history from causing a renderer memory burst. - for (const stored of raw) revisions.push(await decodeRevision(stored, encryptionConfigured)); + for (const stored of raw) { + try { + revisions.push(await decodeRevision(stored, encryptionConfigured)); + } catch (error) { + // QNBS-v3: one damaged/unparseable revision must not hide the rest of a scene's readable + // history — but a genuine lock-state change mid-listing still aborts the whole call. + if (error instanceof SecureRecordCorruptError) { + log.warn('Skipping a damaged revision', { sectionId, error: String(error) }); + continue; + } + throw error; + } + } return revisions.sort((left, right) => right.createdAt - left.createdAt); } diff --git a/services/storage/idbAssetStore.ts b/services/storage/idbAssetStore.ts index 47c0dc96..48579ff1 100644 --- a/services/storage/idbAssetStore.ts +++ b/services/storage/idbAssetStore.ts @@ -11,6 +11,7 @@ import { getUserFriendlyDbError, retryDb } from './idbCore'; import { IdbSnapshotStore } from './idbSnapshotStore'; import { assertIdbProtectedWriteAllowed, + assertNoActiveEncryptionMigration, assertSecureStorageReadable, idbEncryptWithKey, idbReadSecure, @@ -22,14 +23,14 @@ export class IdbAssetStore extends IdbSnapshotStore { // --- Image Store Methods --- async saveImage(id: string, base64: string): Promise { - // QNBS-v3: A migration journal owns store conversion — an ordinary write here must not race it. - await assertIdbProtectedWriteAllowed(); // QNBS-v3: Resolve the write key BEFORE opening the transaction — `await idbEncryptWithKey` // yields the event loop, which auto-commits an already-open IDB transaction // (TransactionInactiveError on put), and re-reading isIdbEncryptionReady() after any // later await could race with Lock Session and silently fall back to plaintext. const writeKey = await resolveProtectedWriteKey(); const payload = writeKey ? await idbEncryptWithKey(writeKey, base64) : base64; + // QNBS-v3: only the migration guard is re-checked here — resolveProtectedWriteKey() already made its own lock check atomically with the key snapshot, so re-running that too would wrongly reject an already-safely-encrypted write if the session locks mid-write. + await assertNoActiveEncryptionMigration(); const store = await this.getObjectStore(IMAGES_STORE, 'readwrite'); return new Promise((resolve, reject) => { const request = store.put(payload, id); @@ -85,8 +86,6 @@ export class IdbAssetStore extends IdbSnapshotStore { meta: BinderAssetMeta, ): Promise { return retryDb(async () => { - // QNBS-v3: A migration journal owns store conversion — an ordinary write here must not race it. - await assertIdbProtectedWriteAllowed(); const writeKey = await resolveProtectedWriteKey(); const key = makeBinderAssetStorageKey(projectId, assetId); const fullMeta = { ...meta, byteSize: data.byteLength }; @@ -101,6 +100,8 @@ export class IdbAssetStore extends IdbSnapshotStore { meta: fullMeta, blob: new Blob([data], { type: meta.mimeType || 'application/octet-stream' }), }; + // QNBS-v3: only the migration guard is re-checked here — resolveProtectedWriteKey() already made its own lock check atomically with the key snapshot, so re-running that too would wrongly reject an already-safely-encrypted write if the session locks mid-write. + await assertNoActiveEncryptionMigration(); const store = await this.getObjectStore(BINDER_ASSETS_STORE, 'readwrite'); return new Promise((resolve, reject) => { const req = store.put(payload, key); diff --git a/services/storage/idbCodexStore.ts b/services/storage/idbCodexStore.ts index 42cafa86..859fbc39 100644 --- a/services/storage/idbCodexStore.ts +++ b/services/storage/idbCodexStore.ts @@ -10,6 +10,7 @@ import { compressData, decompressData } from './idbCore'; import { IdbKeyStore } from './idbKeyStore'; import { assertIdbProtectedWriteAllowed, + assertNoActiveEncryptionMigration, assertSecureStorageReadable, idbEncryptWithKey, idbReadSecure, @@ -19,8 +20,6 @@ import { export class IdbCodexStore extends IdbKeyStore { async saveStoryCodex(codex: StoryCodex): Promise { - // QNBS-v3: A migration journal owns store conversion — an ordinary write here must not race it. - await assertIdbProtectedWriteAllowed(); // QNBS-v3: Resolve the write key BEFORE opening the transaction — `await idbEncryptWithKey` // yields the event loop, which auto-commits an already-open transaction // (TransactionInactiveError), and re-reading isIdbEncryptionReady() after any later @@ -40,6 +39,8 @@ export class IdbCodexStore extends IdbKeyStore { } else { record = processed as object; } + // QNBS-v3: only the migration guard is re-checked here — resolveProtectedWriteKey() already made its own lock check atomically with the key snapshot, so re-running that too would wrongly reject an already-safely-encrypted write if the session locks mid-write. + await assertNoActiveEncryptionMigration(); const store = await this.getObjectStore(CODEX_STORE, 'readwrite'); return new Promise((resolve, reject) => { const request = store.put(record); @@ -101,8 +102,6 @@ export class IdbCodexStore extends IdbKeyStore { // --- RAG Vector Methods --- async saveRagVectors(projectId: string, vectors: unknown[]): Promise { - // QNBS-v3: A migration journal owns store conversion — an ordinary write here must not race it. - await assertIdbProtectedWriteAllowed(); // QNBS-v3: Resolve the write key BEFORE opening the transaction — `await idbEncryptWithKey` // yields the event loop and would auto-commit the open transaction before the put // (TransactionInactiveError), and re-reading isIdbEncryptionReady() after any later @@ -111,6 +110,8 @@ export class IdbCodexStore extends IdbKeyStore { const encryptedPayload = writeKey ? Array.from(await idbEncryptWithKey(writeKey, { projectId, vectors })) : null; + // QNBS-v3: only the migration guard is re-checked here — the lock check already happened atomically inside resolveProtectedWriteKey(); this function's multiple sequential IDB ops (clear then write) still leave a residual window, but re-running the lock check too would wrongly reject an already-safely-encrypted write if the session locks mid-write. + await assertNoActiveEncryptionMigration(); const store = await this.getObjectStore(RAG_VECTORS_STORE, 'readwrite'); // Clear existing vectors for this project then write the full set const index = store.index('projectId'); diff --git a/services/storage/idbProjectStore.ts b/services/storage/idbProjectStore.ts index 195ece81..f51ff03a 100644 --- a/services/storage/idbProjectStore.ts +++ b/services/storage/idbProjectStore.ts @@ -24,6 +24,7 @@ import { IdbAssetStore } from './idbAssetStore'; import { compressData, getUserFriendlyDbError, retryDb } from './idbCore'; import { assertIdbProtectedWriteAllowed, + assertNoActiveEncryptionMigration, assertSecureStorageReadable, idbEncryptWithKey, idbReadSecure, @@ -216,9 +217,6 @@ export class IdbProjectStore extends IdbAssetStore { sliceName: 'project' | 'settings', data: PersistedProjectState | Settings, ): Promise { - // QNBS-v3: A migration journal owns store conversion — an ordinary write here must not race - // it (assertIdbProtectedWriteAllowed also covers the plain-locked-session case). - await assertIdbProtectedWriteAllowed(); // QNBS-v3: Resolve the write key AND encrypt BEFORE opening the store — awaiting // idbEncryptWithKey after getObjectStore would yield the event loop while the // transaction is open, letting IDB auto-commit it before store.put runs @@ -227,6 +225,8 @@ export class IdbProjectStore extends IdbAssetStore { const writeKey = await resolveProtectedWriteKey(); // QNBS-v3: Plaintext is allowed only when encryption was never configured for this library. const payload = writeKey ? await idbEncryptWithKey(writeKey, data) : compressData(data); + // QNBS-v3: only the migration guard is re-checked here — resolveProtectedWriteKey() already made its own lock check atomically with the key snapshot, so re-running that too would wrongly reject an already-safely-encrypted write if the session locks mid-write. + await assertNoActiveEncryptionMigration(); const store = await this.getObjectStore(APP_DATA_STORE, 'readwrite'); return new Promise((resolve, reject) => { const request = store.put(payload, sliceName); diff --git a/services/storage/idbSnapshotStore.ts b/services/storage/idbSnapshotStore.ts index a2ad809f..db41d71b 100644 --- a/services/storage/idbSnapshotStore.ts +++ b/services/storage/idbSnapshotStore.ts @@ -12,6 +12,7 @@ import { IdbCodexStore } from './idbCodexStore'; import { compressData, getUserFriendlyDbError, retryDb } from './idbCore'; import { assertIdbProtectedWriteAllowed, + assertNoActiveEncryptionMigration, assertSecureStorageReadable, idbEncryptWithKey, idbReadSecure, @@ -24,8 +25,6 @@ export class IdbSnapshotStore extends IdbCodexStore { protected readonly MAX_AUTO_SNAPSHOTS = 20; async createSnapshot(data: ProjectData, name?: string): Promise { - // QNBS-v3: A migration journal owns store conversion — an ordinary write here must not race it. - await assertIdbProtectedWriteAllowed(); const wordCount = data.manuscript.reduce( (sum, section) => sum + (section.content?.split(/\s+/).filter(Boolean).length || 0), 0, @@ -44,6 +43,8 @@ export class IdbSnapshotStore extends IdbCodexStore { }; return retryDb(async () => { + // QNBS-v3: only the migration guard is re-checked here — resolveProtectedWriteKey() already made its own lock check atomically with the key snapshot, so re-running that too would wrongly reject an already-safely-encrypted write if the session locks mid-write. + await assertNoActiveEncryptionMigration(); const store = await this.getObjectStore(SNAPSHOTS_STORE, 'readwrite'); return new Promise((resolve, reject) => { const request = store.add(snapshotData); diff --git a/services/storage/storageEncryptionService.ts b/services/storage/storageEncryptionService.ts index 1fc1db25..6fdb510a 100644 --- a/services/storage/storageEncryptionService.ts +++ b/services/storage/storageEncryptionService.ts @@ -16,6 +16,9 @@ import { decompressData } from './idbCore'; import { getPassphraseSentinel, savePassphraseSentinel } from './idbPassphraseSentinel'; import { decodeSecureRecordValue, encodeSecureRecordValue } from './secureRecordCodec'; +// QNBS-v3: re-exported so a write that already captured its key via resolveProtectedWriteKey() can re-check only the migration guard pre-write, without re-running its redundant lock check. +export { assertNoActiveEncryptionMigration } from './encryptionMigrationJournal'; + const PBKDF2_ITERATIONS = 600_000; // OWASP 2024 minimum for PBKDF2-HMAC-SHA-256 const IV_BYTE_LENGTH = 12; const SALT_BYTE_LENGTH = 32; diff --git a/tests/unit/sceneRevisionService.test.ts b/tests/unit/sceneRevisionService.test.ts index ef28f4ee..8c794ca1 100644 --- a/tests/unit/sceneRevisionService.test.ts +++ b/tests/unit/sceneRevisionService.test.ts @@ -9,7 +9,6 @@ import { listRevisions, saveRevision, } from '../../services/sceneRevisionService'; -import { SecureRecordCorruptError } from '../../services/storage/storageEncryptionService'; beforeEach(() => { // Fresh IDB instance per test — avoids record leak between tests @@ -122,7 +121,9 @@ describe('sceneRevisionService', () => { await expect(listRevisions('sec1')).resolves.toHaveLength(50); }); - it('rejects a future stored schema instead of interpreting it as v1', async () => { + it('skips a future stored schema instead of interpreting it as v1, keeping other revisions readable', async () => { + // QNBS-v3: one damaged/unparseable revision must not hide the rest of a scene's readable + // history — listRevisions now skips it (logged) rather than rejecting the whole call. await saveRevision('sec1', { title: 'known', content: 'known content' }); await insertRawRevision({ id: 'future-schema', @@ -132,7 +133,9 @@ describe('sceneRevisionService', () => { payload: { title: 'future', content: 'must not decode as v1', wordCount: 6 }, }); - await expect(listRevisions('sec1')).rejects.toBeInstanceOf(SecureRecordCorruptError); + const list = await listRevisions('sec1'); + expect(list).toHaveLength(1); + expect(list[0]?.title).toBe('known'); }); it('createdAt is a number timestamp', async () => { diff --git a/tests/unit/services/storage/idbAssetStore.test.ts b/tests/unit/services/storage/idbAssetStore.test.ts index 84f206e7..412fcbb8 100644 --- a/tests/unit/services/storage/idbAssetStore.test.ts +++ b/tests/unit/services/storage/idbAssetStore.test.ts @@ -28,6 +28,7 @@ vi.mock('../../../../services/storage/idbCore', () => ({ vi.mock('../../../../services/storage/storageEncryptionService', () => ({ assertIdbProtectedWriteAllowed: async () => {}, + assertNoActiveEncryptionMigration: async () => {}, assertSecureStorageReadable: async () => false, idbEncryptWithKey: async (_key: unknown, data: unknown) => data, idbReadSecure: async (data: unknown) => data, diff --git a/tests/unit/services/storage/idbSnapshotStore.test.ts b/tests/unit/services/storage/idbSnapshotStore.test.ts index 042bb49e..d815d279 100644 --- a/tests/unit/services/storage/idbSnapshotStore.test.ts +++ b/tests/unit/services/storage/idbSnapshotStore.test.ts @@ -26,6 +26,7 @@ vi.mock('../../../../services/storage/idbCore', () => ({ vi.mock('../../../../services/storage/storageEncryptionService', () => ({ assertIdbProtectedWriteAllowed: async () => undefined, + assertNoActiveEncryptionMigration: async () => undefined, assertSecureStorageReadable: async () => undefined, idbEncryptWithKey: async (_key: unknown, data: unknown) => data, idbDecrypt: async (data: unknown) => data, From 411943a8fa4104c0314a6c3571ce8151ce675b54 Mon Sep 17 00:00:00 2001 From: qnbs <155236708+qnbs@users.noreply.github.com> Date: Tue, 11 Aug 2026 22:33:03 +0200 Subject: [PATCH 43/78] fix(storage): fail migration to recovery-required on a verification shortfall MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit runProtectedStoreMigration's verifying phase threw a plain ProtectedStoreMigrationAdapterError when an adapter's verify() found fewer valid records than this saga already migrated (most likely because an ordinary write landed on an already-migrated record using a superseded key after that store's migrating pass finished). The catch block only released the ownership lease and re-threw, leaving the journal parked at 'verifying' with nothing to distinguish this from a transient interruption — a caller that retries keeps re-running the same doomed verify() call forever, since nothing in the saga can revisit and reconvert the stray record. Introduces ProtectedStoreVerificationShortfallError, a distinguishable subtype thrown only for this specific verified-count shortfall (not for a transient/interrupted verify() exception, which must still support the existing "resume verification on retry" path). On this specific error the catch block now transitions the journal to recovery-required before releasing ownership, converting an invisible infinite retry loop into an explicit, visible state that requires the same out-of-band recovery procedure already used elsewhere in this journal. Co-Authored-By: Claude Sonnet 5 --- services/storage/protectedStoreMigration.ts | 25 ++++++++++++- .../storage/protectedStoreMigration.test.ts | 35 +++++++++++++++++++ 2 files changed, 59 insertions(+), 1 deletion(-) diff --git a/services/storage/protectedStoreMigration.ts b/services/storage/protectedStoreMigration.ts index 5cc4b899..6743c78c 100644 --- a/services/storage/protectedStoreMigration.ts +++ b/services/storage/protectedStoreMigration.ts @@ -47,6 +47,14 @@ export class ProtectedStoreMigrationAdapterError extends Error { } } +// QNBS-v3: distinct from a transient/interrupted verify() throw — a shortfall means the adapter re-scanned and found fewer valid records than this saga already migrated, most likely because an ordinary write landed on an already-migrated record using a superseded key after this store's migrating pass finished; nothing in this saga can revisit and reconvert that record, so blindly retrying verify() would fail identically forever. +export class ProtectedStoreVerificationShortfallError extends ProtectedStoreMigrationAdapterError { + constructor(message: string) { + super(message); + this.name = 'ProtectedStoreVerificationShortfallError'; + } +} + function checkpointFor( journal: EncryptionMigrationJournal, adapterId: string, @@ -264,7 +272,7 @@ export async function runProtectedStoreMigration( ...(keys.targetKey ? { targetKey: keys.targetKey } : {}), }); if (!Number.isSafeInteger(verified) || verified < checkpoint.processed) { - throw new ProtectedStoreMigrationAdapterError( + throw new ProtectedStoreVerificationShortfallError( `Store ${adapter.id} verification is incomplete`, ); } @@ -281,6 +289,21 @@ export async function runProtectedStoreMigration( return journal; } catch (error) { + if ( + ownsLease && + journal.phase === 'verifying' && + error instanceof ProtectedStoreVerificationShortfallError + ) { + // QNBS-v3: retrying verify() alone can never fix a shortfall — mark recovery-required so the stuck state is visible instead of an indefinite, silently-failing retry loop. + try { + journal = await updateEncryptionMigrationJournal(journal, { + phase: 'recovery-required', + stores: journal.stores, + }); + } catch { + // QNBS-v3: best-effort — the original verification error still propagates below either way. + } + } if (ownsLease && journal.phase !== 'committing') { try { await releaseEncryptionMigrationOwnership(journal); diff --git a/tests/unit/storage/protectedStoreMigration.test.ts b/tests/unit/storage/protectedStoreMigration.test.ts index 6136702f..d6b74bd2 100644 --- a/tests/unit/storage/protectedStoreMigration.test.ts +++ b/tests/unit/storage/protectedStoreMigration.test.ts @@ -310,6 +310,41 @@ describe('runProtectedStoreMigration', () => { expect(calls).toEqual(['first', 'second-failure', 'second-resume']); }); + it('marks recovery-required instead of looping forever when verification finds fewer valid records than were migrated', async () => { + const journal = await beginEncryptionMigration({ + operationId: 'verification-shortfall', + operation: 'rekey', + phase: 'verifying', + sourceGeneration: 'source', + targetGeneration: 'target', + targetVerifier: [1, 2, 3], + stores: [{ id: 'test-store', processed: 5, verified: 0, done: true }], + }); + const adapter: ProtectedStoreAdapter = { + id: 'test-store', + replaySafe: true, + async migrateNext() { + throw new Error('must not execute'); + }, + async verify() { + // QNBS-v3: simulates a stray write landing on an already-migrated record with the superseded key. + return 4; + }, + }; + + await expect(runProtectedStoreMigration(journal, [adapter], migrationKeys)).rejects.toThrow( + 'verification is incomplete', + ); + await expect(readEncryptionMigrationJournal()).resolves.toMatchObject({ + phase: 'recovery-required', + }); + + const parked = await readEncryptionMigrationJournal(); + await expect(runProtectedStoreMigration(parked!, [adapter], migrationKeys)).rejects.toThrow( + 'Recovery-required journal cannot run until an explicit recovery procedure validates it', + ); + }); + it('rejects a missing registered adapter before a migration can mutate storage', async () => { const journal = await begin(); From beadfa229ba3c03eb7fd320f5c345c011ce817d6 Mon Sep 17 00:00:00 2001 From: qnbs <155236708+qnbs@users.noreply.github.com> Date: Tue, 11 Aug 2026 22:41:56 +0200 Subject: [PATCH 44/78] fix(ai): degrade cache reads to a miss on lock/migration, re-encrypt legacy entries on read MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit getCachedInference() called assertSecureStorageReadable() unguarded, so a locked library, an active migration, or the check's own IDB access failing would reject the call instead of degrading to a miss — breaking this cache's documented non-authoritative, best-effort contract (setCachedInference already had this protection via its own try/catch). The lifecycle check is now wrapped so any failure returns null instead of propagating. Legacy plaintext cache entries were decoded and returned correctly but never rewritten, so a cache populated before encryption was enabled stayed plaintext in IndexedDB for the remainder of its 7-day TTL even after the library was unlocked with an active key. decodeEntry() now checks readSecureRecordPayload's `needsMigration` flag (the same signal the migration adapters already use) and best-effort re-persists the entry encrypted, preserving its original timestamp so TTL/LRU ordering is unaffected; a failure (e.g. a concurrent migration) is swallowed since this cache is explicitly non-authoritative. Co-Authored-By: Claude Sonnet 5 --- services/ai/aiInferenceCacheService.ts | 27 +++++++++- tests/unit/aiInferenceCacheService.test.ts | 61 +++++++++++++++++++++- 2 files changed, 85 insertions(+), 3 deletions(-) diff --git a/services/ai/aiInferenceCacheService.ts b/services/ai/aiInferenceCacheService.ts index 56668825..8c766961 100644 --- a/services/ai/aiInferenceCacheService.ts +++ b/services/ai/aiInferenceCacheService.ts @@ -153,9 +153,27 @@ export class AiInferenceCacheService { legacyStores: ['inference-cache'], }); if (!isCachePayload(decoded.value)) throw new SecureRecordCorruptError(); + if (decoded.needsMigration) { + // QNBS-v3: best-effort opportunistic re-encrypt on read — a failure (e.g. an active migration) must never block the read, and the 7-day TTL already bounds residual plaintext exposure even without this. + void this.reencryptLegacyEntry(entry.key, decoded.value.result, entry.timestamp); + } return decoded.value.result; } + private async reencryptLegacyEntry( + key: string, + result: string, + timestamp: number, + ): Promise { + if (!this.db) return; + try { + const encoded = await this.encodeEntry(key, result, timestamp); + await this.persistEntry(encoded); + } catch { + // QNBS-v3: best-effort; a failed opportunistic re-encrypt is not user-visible and TTL still bounds exposure. + } + } + private async persistEntry(entry: CacheEntry): Promise { if (!this.db) return; await new Promise((resolve) => { @@ -171,8 +189,13 @@ export class AiInferenceCacheService { async getCachedInference(prompt: string, modelId: string): Promise { if (this.shouldSkip(prompt)) return null; const key = hashKey(prompt, modelId); - // QNBS-v3: A locked library must not expose an earlier plaintext response through the RAM tier. - await assertSecureStorageReadable(); + try { + // QNBS-v3: A locked library must not expose an earlier plaintext response through the RAM tier. + await assertSecureStorageReadable(); + } catch { + // QNBS-v3: cache is non-authoritative — any lifecycle-check failure (locked, migrating, or the check's own IDB access failing) degrades to a miss rather than failing an otherwise-successful inference call. + return null; + } const memoryEntry = this.inMemory.get(key); if (memoryEntry) { diff --git a/tests/unit/aiInferenceCacheService.test.ts b/tests/unit/aiInferenceCacheService.test.ts index 79ea1fd4..7ba1338b 100644 --- a/tests/unit/aiInferenceCacheService.test.ts +++ b/tests/unit/aiInferenceCacheService.test.ts @@ -102,8 +102,9 @@ describe('aiInferenceCacheService — TTL expiry', () => { }); describe('aiInferenceCacheService — IDB unavailable (jsdom)', () => { + // QNBS-v3: tests/setup.ts imports fake-indexeddb/auto globally, so indexedDB IS defined here — + // these exercise the in-memory-only-miss path via an empty durable store, not a true IDB-absent env. it('getCachedInference degrades gracefully when indexedDB is undefined', async () => { - // jsdom does not provide indexedDB by default — service already handles this const result = await service.aiInferenceCacheService.getCachedInference('any', 'model'); expect(result).toBeNull(); // either from in-memory miss or IDB degrade }); @@ -114,3 +115,61 @@ describe('aiInferenceCacheService — IDB unavailable (jsdom)', () => { ).resolves.not.toThrow(); }); }); + +describe('aiInferenceCacheService — protected-storage lifecycle', () => { + afterEach(() => { + vi.doUnmock('../../services/storage/storageEncryptionService'); + }); + + it('returns null instead of rejecting when the protected-storage lifecycle check fails', async () => { + vi.doMock('../../services/storage/storageEncryptionService', () => ({ + assertSecureStorageReadable: vi.fn().mockRejectedValue(new Error('locked')), + assertSecureStorageWritableForMutation: vi.fn().mockResolvedValue(undefined), + prepareSecureRecordPayload: vi.fn(async (value: unknown) => value), + readSecureRecordPayload: vi.fn(), + SecureRecordCorruptError: class extends Error {}, + })); + vi.resetModules(); + const mod = await import('../../services/ai/aiInferenceCacheService'); + + await expect( + mod.aiInferenceCacheService.getCachedInference('hello', 'model-a'), + ).resolves.toBeNull(); + }); + + it('opportunistically re-encrypts a legacy plaintext entry after a successful read', async () => { + const persisted: unknown[] = []; + vi.doMock('../../services/storage/storageEncryptionService', () => ({ + assertSecureStorageReadable: vi.fn().mockResolvedValue(true), + assertSecureStorageWritableForMutation: vi.fn().mockResolvedValue(undefined), + prepareSecureRecordPayload: vi.fn(async (value: unknown) => { + persisted.push(value); + return { version: 1, iv: new Uint8Array([1]), ciphertext: new Uint8Array([2]) }; + }), + readSecureRecordPayload: vi.fn().mockResolvedValue({ + value: { result: 'legacy answer' }, + needsMigration: true, + }), + SecureRecordCorruptError: class extends Error {}, + })); + vi.resetModules(); + const mod = await import('../../services/ai/aiInferenceCacheService'); + type CacheInternals = { + dbReady: Promise; + decodeEntry: (entry: { key: string; result: string; timestamp: number }) => Promise; + }; + const cache = mod.aiInferenceCacheService as unknown as CacheInternals; + await cache.dbReady; + + const decoded = await cache.decodeEntry({ + key: 'legacy-key', + result: 'legacy answer', + timestamp: Date.now(), + }); + expect(decoded).toBe('legacy answer'); + + // reencryptLegacyEntry is fire-and-forget from decodeEntry — flush pending microtasks before asserting. + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(persisted).toEqual([{ result: 'legacy answer' }]); + }); +}); From 3f31c1e11da8ed51872e1a3d20555e625e1bb86d Mon Sep 17 00:00:00 2001 From: qnbs <155236708+qnbs@users.noreply.github.com> Date: Tue, 11 Aug 2026 22:48:14 +0200 Subject: [PATCH 45/78] fix(ollama): report invalidResponse instead of a false-positive connection success MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit testOllamaConnection() caught a JSON parse failure by setting payload to null and then computed an empty model list from it, so a 200 response with an unparseable body (e.g. a proxy login page) or valid JSON missing the models array was reported as ok:true with zero models — a false-positive success, inconsistent with the OpenAI-compatible diagnostic path (testOpenAiCompatibleLocalConnection), which already returns its existing 'invalidResponse' kind for the same two cases. Ollama's own TestConnectionErrorKind union gains that same kind (already i18n-mapped via settings.ai.testError.invalidResponse, shared across providers) and both failure paths now return it explicitly instead of falling through to a misleading success. A validly-shaped but genuinely empty models array (a fresh Ollama install with nothing pulled yet) still reports ok:true, since that is not an error. Co-Authored-By: Claude Sonnet 5 --- services/ollamaService.ts | 33 ++++++++++++++++++++------------ tests/unit/ollamaService.test.ts | 25 ++++++++++++++++++++++++ 2 files changed, 46 insertions(+), 12 deletions(-) diff --git a/services/ollamaService.ts b/services/ollamaService.ts index 1448d020..80e16703 100644 --- a/services/ollamaService.ts +++ b/services/ollamaService.ts @@ -158,7 +158,12 @@ export async function listOllamaModels(baseUrl?: string): Promise { * a raw/technical string for logs; UI code should prefer `kind` (+ `params` for interpolation) to * render a localized message, per `settings.ai.testError.*` in `locales//settings.json`. */ -export type TestConnectionErrorKind = 'httpError' | 'timeout' | 'unreachable' | 'pluginUnavailable'; +export type TestConnectionErrorKind = + | 'httpError' + | 'timeout' + | 'unreachable' + | 'pluginUnavailable' + | 'invalidResponse'; export interface TestConnectionResult { ok: boolean; @@ -189,18 +194,22 @@ export async function testOllamaConnection(baseUrl?: string): Promise { - if (typeof model !== 'object' || model === null) return []; - const name = (model as { name?: unknown }).name; - return typeof name === 'string' && name.trim() ? [name.trim()] : []; - }) - : []; + if ( + typeof payload !== 'object' || + payload === null || + !Array.isArray((payload as { models?: unknown }).models) + ) { + // QNBS-v3: valid JSON without a `models` array means the endpoint isn't speaking the Ollama API. + return { ok: false, error: 'Invalid Ollama response', kind: 'invalidResponse' }; + } + const modelNames = (payload as { models: unknown[] }).models.flatMap((model) => { + if (typeof model !== 'object' || model === null) return []; + const name = (model as { name?: unknown }).name; + return typeof name === 'string' && name.trim() ? [name.trim()] : []; + }); return { ok: true, localServer: { diff --git a/tests/unit/ollamaService.test.ts b/tests/unit/ollamaService.test.ts index 81072e5c..68a03e72 100644 --- a/tests/unit/ollamaService.test.ts +++ b/tests/unit/ollamaService.test.ts @@ -136,6 +136,31 @@ describe('testOllamaConnection', () => { expect(result.kind).toBe('pluginUnavailable'); expect(result.params).toBeUndefined(); }); + + it('reports invalidResponse instead of a false-positive ok when the body is not valid JSON', async () => { + vi.mocked(fetch).mockResolvedValueOnce(new Response('login', { status: 200 })); + const result = await testOllamaConnection(); + expect(result.ok).toBe(false); + expect(result.kind).toBe('invalidResponse'); + }); + + it('reports invalidResponse instead of a false-positive ok when the body has no models array', async () => { + vi.mocked(fetch).mockResolvedValueOnce( + new Response(JSON.stringify({ notModels: [] }), { status: 200 }), + ); + const result = await testOllamaConnection(); + expect(result.ok).toBe(false); + expect(result.kind).toBe('invalidResponse'); + }); + + it('still reports ok:true with an empty model list for a validly-shaped, empty Ollama server', async () => { + vi.mocked(fetch).mockResolvedValueOnce( + new Response(JSON.stringify({ models: [] }), { status: 200 }), + ); + const result = await testOllamaConnection(); + expect(result.ok).toBe(true); + expect(result.localServer?.modelNames).toEqual([]); + }); }); // ─── streamOllama ───────────────────────────────────────────────────────────── From 68050d8007b9ac1090e267c57346a1b08f4140a9 Mon Sep 17 00:00:00 2001 From: qnbs <155236708+qnbs@users.noreply.github.com> Date: Tue, 11 Aug 2026 22:52:17 +0200 Subject: [PATCH 46/78] docs(test): clarify encryptionMigrationJournal.test.ts's ownership-CAS comment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The file-header comment said "independent module-owner CAS behavior," which reads as claiming cross-tab/reloaded-module coverage. The actual tests (competing owner rejection, delayed-owner rejection, crashed-lease recovery) all exercise ownerId-scoped CAS rejection within the same imported module instance — reworded to say exactly that. Co-Authored-By: Claude Sonnet 5 --- tests/unit/storage/encryptionMigrationJournal.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/unit/storage/encryptionMigrationJournal.test.ts b/tests/unit/storage/encryptionMigrationJournal.test.ts index 1b77fd2f..898affbd 100644 --- a/tests/unit/storage/encryptionMigrationJournal.test.ts +++ b/tests/unit/storage/encryptionMigrationJournal.test.ts @@ -1,5 +1,5 @@ // @vitest-environment node -// QNBS-v3: Real fake IndexedDB verifies journal transactions and independent module-owner CAS behavior. +// QNBS-v3: Real fake IndexedDB verifies journal transactions and competing-owner CAS rejection (same module instance, different ownerId values — not a cross-tab/reloaded-module scenario). import { IDBFactory } from 'fake-indexeddb'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { APP_DATA_STORE, STATE_DB_NAME } from '../../../services/dbConstants'; From dc0b5262b7c1c9d9dc1a3b33551d497f29a3a1a8 Mon Sep 17 00:00:00 2001 From: qnbs <155236708+qnbs@users.noreply.github.com> Date: Tue, 11 Aug 2026 22:58:45 +0200 Subject: [PATCH 47/78] fix(settings): stop rendering the connection test result twice in AiProviderCard ProviderConnectionStatus (the status panel above the provider picker) already renders the status badge, testError text, and diagnostic panel for every provider. The action row further down duplicated the exact same testError string and a separate success message next to the Test Connection button, so one test produced the identical error text twice on screen (and a redundant success message). The action row now renders only the button; the status panel is the single source of truth. Existing tests that asserted on the duplicate via getAllByText(...).length now assert a single instance via getByText, and the stale "renders in two places" comment is removed. Co-Authored-By: Claude Sonnet 5 --- components/settings/AiProviderCard.tsx | 16 ++-------------- tests/unit/settings/AiProviderCard.test.tsx | 6 ++---- 2 files changed, 4 insertions(+), 18 deletions(-) diff --git a/components/settings/AiProviderCard.tsx b/components/settings/AiProviderCard.tsx index 811a6237..28e42af7 100644 --- a/components/settings/AiProviderCard.tsx +++ b/components/settings/AiProviderCard.tsx @@ -887,6 +887,8 @@ export const AiProviderCard: FC = ({ {/* QNBS-v3 (#266 review, ADR-0017): in the plain PWA the ollama test would re-create CORS noise — the banner + CTA above is the only actionable path there. The browserOllamaEnabled opt-in widens this the same way it widens the auto-probe. */} + {/* QNBS-v3: success/error text lives only in ProviderConnectionStatus above — this + button previously duplicated the exact same testError string in a second span. */} - {testStatus === 'ok' && ( - - - )} - {/* QNBS-v3 (CodeRabbit CWE-209): mirror the guard above — a stale in-flight test for a - prior provider must not surface raw error text once Ollama-in-browser is selected. */} - {!ollamaUntestable && testStatus === 'error' && ( - - - )}

)} diff --git a/tests/unit/settings/AiProviderCard.test.tsx b/tests/unit/settings/AiProviderCard.test.tsx index efbf8eb8..7b3a99cb 100644 --- a/tests/unit/settings/AiProviderCard.test.tsx +++ b/tests/unit/settings/AiProviderCard.test.tsx @@ -486,9 +486,7 @@ describe('AiProviderCard — ollama provider (#266)', () => { ); await user.click(screen.getByRole('button', { name: 'settings.ai.testConnection' })); await waitFor(() => { - // QNBS-v3: the translated text renders in two places (the status-badge error line and the - // manual "Test connection" result span) — both share the same `testError` state. - expect(screen.getAllByText('settings.ai.testError.httpError').length).toBeGreaterThan(0); + expect(screen.getByText('settings.ai.testError.httpError')).toBeTruthy(); }); expect(screen.queryByText('Ollama HTTP 503')).toBeNull(); }); @@ -510,7 +508,7 @@ describe('AiProviderCard — ollama provider (#266)', () => { ); await user.click(screen.getByRole('button', { name: 'settings.ai.testConnection' })); await waitFor(() => { - expect(screen.getAllByText('settings.ai.testError.unexpected').length).toBeGreaterThan(0); + expect(screen.getByText('settings.ai.testError.unexpected')).toBeTruthy(); }); expect(screen.queryByText(/something internal broke/)).toBeNull(); }); From ed46befdbd5e43442c5ec3f706fb110ae2d789bd Mon Sep 17 00:00:00 2001 From: qnbs <155236708+qnbs@users.noreply.github.com> Date: Tue, 11 Aug 2026 23:04:11 +0200 Subject: [PATCH 48/78] docs: refresh stack SHAs in the performance ledger; reconcile PR310-R009 to a valid disposition MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The performance ledger's baseline table still had #335/#336/#337 pinned to SHAs from before this session's fix passes (fa3cd983/fd7ed7c1/dda48b33) — all three have since moved (5e80aaa4/2438f991/dc0b5262, the last now pushed with 0 unresolved review threads instead of "local merge pending push"). PR310-R009 and two related rows carried the interim disposition REWRITE, which the reconciliation doc's own taxonomy treats as impermissible as a final state — every other row resolves to an ADOPTED_WITH_MODIFICATIONS / SUPERSEDED_BY_BETTER_IMPLEMENTATION / NO_LONGER_APPLICABLE / RETAIN category. R009 asked for missing-store coverage plus interruption, legacy-shape, resume, and verification test cases; all five are now present as passing tests (protectedStoreMigration.test.ts's missing-adapter/checkpoint, interruption+resume, and verification-shortfall tests, plus secondaryPayloadStoreAdapter.test.ts's plaintext-to-encrypted conversion test). New PR310-R016 row consolidates the evidence with concrete test-name citations; R009 and the two related REWRITE rows now point to it instead of carrying an open-ended disposition. Co-Authored-By: Claude Sonnet 5 --- docs/ISSUES-332-333-PERFORMANCE-LEDGER.md | 8 ++++---- docs/PR-310-RECONCILIATION.md | 7 ++++--- 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/docs/ISSUES-332-333-PERFORMANCE-LEDGER.md b/docs/ISSUES-332-333-PERFORMANCE-LEDGER.md index cf8c3c88..22c4f7ba 100644 --- a/docs/ISSUES-332-333-PERFORMANCE-LEDGER.md +++ b/docs/ISSUES-332-333-PERFORMANCE-LEDGER.md @@ -6,14 +6,14 @@ authoritative closure record for the responsiveness portions of [#332](https://github.com/qnbs/WorldScript-Studio/issues/332) and [#333](https://github.com/qnbs/WorldScript-Studio/issues/333). -## Live baseline — 2026-08-11 +## Live baseline — 2026-08-11 (updated) | Ref | Live value | | --- | --- | | `main` | `804793aa0815a726935785639e4fb139af7c4b59` | -| PR #335 | `fa3cd983260cd91d412d6a57508e4d4f600cff2e` | -| PR #336 | `fd7ed7c1489df0a9453cc0d67a0e59b22f49ea9d` | -| PR #337 | `dda48b33d56f6cfbae7b2e134553ab769a52d7af` (local merge pending push) | +| PR #335 | `5e80aaa44fc6e31bf50b06294be106577d4d93c3` | +| PR #336 | `2438f991afa4ad3630573e2d12a8fcf83f5280d5` | +| PR #337 | `dc0b5262b7c1c9d9dc1a3b33551d497f29a3a1a8` (pushed; review threads: 0 unresolved) | | PR #310 | `27177ce549d4579f1fc9dfbc4630ebf0c2592f9b` | | Issue #332 / #333 | Open / Open; neither has post-report comments | diff --git a/docs/PR-310-RECONCILIATION.md b/docs/PR-310-RECONCILIATION.md index 83978191..42365363 100644 --- a/docs/PR-310-RECONCILIATION.md +++ b/docs/PR-310-RECONCILIATION.md @@ -35,7 +35,7 @@ the replacements because that would duplicate migrations and lifecycle controls. | PR310-R006 | `c991c03` | Add AAD, Blob codec, delete gating, and lifecycle calls | ADOPTED_WITH_MODIFICATIONS | AAD/Blob/delete protections move into the shared policy; unsafe lifecycle calls are superseded | | PR310-R007 | `3a18c9d` | Reduce codec complexity for DeepSource | NO_LONGER_APPLICABLE | The final codec is structured for correctness; analyzer thresholds will not be raised to hide risk | | PR310-R008 | `5f3ad25` | Consolidate secondary-store migration | SUPERSEDED_BY_BETTER_IMPLEMENTATION | Replace aggregate helpers with registered adapters driven by durable journal checkpoints | -| PR310-R009 | `27177ce` | Cover migration and missing-store paths | REWRITE | Preserve missing-store coverage and add interruption, legacy-shape, resume, and verification cases | +| PR310-R009 | `27177ce` | Cover migration and missing-store paths | ADOPTED_WITH_MODIFICATIONS | Missing-store coverage preserved; interruption, legacy-shape, resume, and verification cases all added — see PR310-R016 | ## Behavior reconciliation @@ -73,7 +73,7 @@ not a disposition. | `PRRT_kwDOQOeAgc6WAh9q` | Scene revision eviction decrypts all content | ADOPTED_WITH_MODIFICATIONS: use plaintext routing metadata for eviction | | `PRRT_kwDOQOeAgc6WAsgW`, `PRRT_kwDOQOeAgc6WA2oe`, `PRRT_kwDOQOeAgc6WBH2E`, `PRRT_kwDOQOeAgc6WBsgD` | Rotation/disable lose legacy flat record shapes | SUPERSEDED_BY_BETTER_IMPLEMENTATION: canonical per-store decoders are part of journal adapters | | `PRRT_kwDOQOeAgc6WBA9v` | Required one-line rationale missing | NO_LONGER_APPLICABLE: unsafe call is removed; new non-trivial lifecycle calls include a one-line rationale | -| `PRRT_kwDOQOeAgc6WBA90`, `PRRT_kwDOQOeAgc6WBA95`, `PRRT_kwDOQOeAgc6WBA-a` | Missing stores, malformed cache data, and history migration behavior | REWRITE: final registered adapters use safe open/close, shape validation, and single-transaction writes | +| `PRRT_kwDOQOeAgc6WBA90`, `PRRT_kwDOQOeAgc6WBA95`, `PRRT_kwDOQOeAgc6WBA-a` | Missing stores, malformed cache data, and history migration behavior | ADOPTED_WITH_MODIFICATIONS: final registered adapters use safe open/close, shape validation, and single-transaction writes — see PR310-R016 | | `PRRT_kwDOQOeAgc6WBA-i` | Codec stringifies unsupported values / corrupts non-finite numbers | ADOPTED_WITH_MODIFICATIONS: explicit undefined node and strict unsupported-value rejection | | `PRRT_kwDOQOeAgc6WBmtc` | DeepSource parses an ESM maintainer script as CommonJS | SUPERSEDED_BY_BETTER_IMPLEMENTATION: remove the ad-hoc resolver script and fix analyzer-compatible code/config without a threshold waiver | @@ -83,7 +83,7 @@ not a disposition. | --- | --- | --- | | Secure envelope and corruption tests | UPDATE | Candidate, version, IV/ciphertext, AAD swap, codec-value, and wrong-key cases | | Per-store encrypted round trips | RETAIN | One registered adapter fixture per protected store, including binary artifacts | -| Legacy lazy-migration tests | REWRITE | Flat legacy shape plus conditional write race and failure-safe read result | +| Legacy lazy-migration tests | ADOPTED_WITH_MODIFICATIONS | Flat legacy shape plus conditional write race and failure-safe read result — see PR310-R016 | | Secondary lifecycle happy paths | REPLACE | Journal creation, every checkpoint boundary, interruption/restart, verify, commit, cleanup | | Optional/missing store test | RETAIN | Missing stores are no-ops that are checkpointed and verified rather than silently skipped | | Cross-project mock repair | UPDATE | Keep the full constants mock only if final test imports require it | @@ -101,6 +101,7 @@ part of the replacement architecture, not a reason to merge #310 unchanged. | PR310-R013 | Snapshot lookup could turn a missing record into `undefined` data | ADOPTED_WITH_MODIFICATIONS | Snapshot reads now reject a typed not-found condition; callers cannot mistake absence for a valid decrypted payload. | `idbSnapshotStore.test.ts` missing-snapshot case | | PR310-R014 | Scene revision retention decrypted content and could prune unknown future schemas | ADOPTED_WITH_MODIFICATIONS | Retention runs with plaintext routing metadata in one transaction, caps only recognised schema-1/validated legacy records, and preserves unrecognised future-format records. | `sceneRevisionService.test.ts` retention and future-schema cases | | PR310-R015 | A non-authoritative inference cache persistence failure could discard a usable result while locked/durable persistence changed state | ADOPTED_WITH_MODIFICATIONS | The memory cache remains available after a best-effort durable-cache failure; durable writes remain subject to the central lifecycle guard. | `aiInferenceCacheService.test.ts` durable-write-failure case | +| PR310-R016 | Consolidates PR310-R009's four required test categories (interruption, legacy-shape, resume, verification) plus missing-store coverage — previously tracked under the impermissible interim disposition `REWRITE` | ADOPTED_WITH_MODIFICATIONS | Missing-store/checkpoint: the runner throws a clear error for an unregistered adapter or a checkpoint-less registration instead of silently skipping it. Interruption + resume: a verify() exception mid-phase leaves the journal at `verifying` and a subsequent call resumes from the durable per-store `verified` checkpoint rather than re-running already-verified stores. Verification shortfall: a re-scan finding fewer valid records than were migrated now moves the journal to `recovery-required` instead of retrying an unwinnable check forever. Legacy-shape: plaintext/pre-migration record shapes decode correctly and convert to the current encrypted envelope shape without data loss. | `protectedStoreMigration.test.ts`: `'rejects a missing registered adapter before a migration can mutate storage'`, `'rejects a registered adapter that has no durable checkpoint before mutation'`, `'does not repeat a durably verified store after verification is interrupted'`, `'marks recovery-required instead of looping forever when verification finds fewer valid records than were migrated'`; `secondaryPayloadStoreAdapter.test.ts`: `'converts plaintext through enable, resumable rekey, and verified disable'` | ### Review findings already disproved by executable guards From 667f6f37a110f9daf4f8ad760e0a3e0198cc09a4 Mon Sep 17 00:00:00 2001 From: qnbs <155236708+qnbs@users.noreply.github.com> Date: Tue, 11 Aug 2026 23:18:49 +0200 Subject: [PATCH 49/78] fix(storage): route locked encrypted startup and Lock Session to the unlock modal, not a dead end MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two confirmed paths left a user with configured at-rest encryption unable to reach the passphrase prompt at all: 1. Cold start: when the passphrase sentinel exists but no key is active yet, dbService.loadState() throws IdbStorageLockedError. index.tsx awaited this before mounting , so App.tsx's own unlock-detection effect (which shows IdbUnlockModal) never got a chance to run — the user landed on the generic StorageErrorScreen, whose only action is "Reset Database & Reload" (destroys all local data). The bootstrap IIFE is now a named, re-invocable bootApp() function; a locked-storage catch renders a standalone IdbUnlockModal (wrapped only in I18nProvider — it has no Redux dependency) and retries the full boot in place on success. No page reload is used, since the freshly-unlocked in-memory key would be lost on one. 2. Mid-session: handleLockSession() cleared the key but never opened the unlock modal and didn't block editing, so a user could keep typing while every subsequent autosave silently failed closed (generic "Auto-Save Failed" toast, no path back to unlocking short of manually reopening Settings). It now also opens the same global unlock modal (transientUiStore's isIdbUnlockOpen) that the cold-start path uses. index.tsx has no existing test harness (side-effecting module-level bootstrap, no exported units) — this fix is verified by static tracing of every new dependency (IdbUnlockModal, Modal, Button, useFocusTrap, useTranslation, I18nProvider all confirmed Redux-free) rather than an automated test; useSettingsView.test.ts covers the Lock Session path. Co-Authored-By: Claude Sonnet 5 --- hooks/useSettingsView.ts | 8 ++++++- index.tsx | 27 +++++++++++++++++++++--- tests/unit/hooks/useSettingsView.test.ts | 19 +++++++++++++++++ 3 files changed, 50 insertions(+), 4 deletions(-) diff --git a/hooks/useSettingsView.ts b/hooks/useSettingsView.ts index b9bd7470..addd38f3 100644 --- a/hooks/useSettingsView.ts +++ b/hooks/useSettingsView.ts @@ -2,6 +2,7 @@ import type React from 'react'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useAppDispatch, useAppSelector } from '../app/hooks'; import type { RootState } from '../app/store'; +import { useTransientUiStore } from '../app/transientUiStore'; import type { PassphraseModalMode } from '../components/settings/PassphraseModal'; import { useToast } from '../components/ui/Toast'; import type { Language } from '../contexts/I18nContext'; @@ -53,6 +54,7 @@ export const useSettingsView = () => { const { t, language, setLanguage } = useTranslation(); const dispatch = useAppDispatch(); const toast = useToast(); + const setIdbUnlockOpen = useTransientUiStore((s) => s.setIdbUnlockOpen); const settings = useAppSelector((state) => state.settings); const featureFlags = useAppSelector((state) => state.featureFlags); const projectState = useAppSelector((state) => state.project.present); @@ -427,7 +429,11 @@ export const useSettingsView = () => { clearIdbEncryptionKey(); setEncryptionReady(false); toast.info(t('settings.privacy.encryptionLockedStatus')); - }, [toast, t]), + // QNBS-v3: without this, a subsequent autosave silently fails closed (no route back to the + // unlock UI existed) until the user manually reopens Settings and unlocks — surface the same + // global unlock modal App.tsx shows on a locked cold start. + setIdbUnlockOpen(true); + }, [toast, t, setIdbUnlockOpen]), }; }; diff --git a/index.tsx b/index.tsx index b340b597..6ce1e56e 100644 --- a/index.tsx +++ b/index.tsx @@ -3,11 +3,14 @@ import ReactDOM from 'react-dom/client'; import { Provider } from 'react-redux'; import App from './App'; import { type AppDispatch, appStoreRef, type RootState, setupStore } from './app/store'; +import { IdbUnlockModal } from './components/settings/IdbUnlockModal'; +import { I18nProvider } from './contexts/I18nContext'; import type { ProjectData } from './features/project/projectSlice'; import { versionControlActions } from './features/versionControl/versionControlSlice'; import { initializeStorage, resetAllDatabases } from './services/dbInitialization'; import { dbService } from './services/dbService'; import { logger } from './services/logger'; +import { IdbStorageLockedError } from './services/storage/storageEncryptionService'; import { saveEnvelopeFromProjectData, storageService } from './services/storageService'; import type { PersistedRootState } from './types'; /* ── Self-hosted fonts (@fontsource) ── */ @@ -177,8 +180,9 @@ function StorageErrorScreen({ message, onReset }: { message: string; onReset: () ); } -// Async IIFE: pre-loads state from IndexedDB before mounting React. -(async () => { +// QNBS-v3: named (not an anonymous IIFE) so a locked-storage catch below can retry the full boot +// sequence after the user unlocks, without a page reload — a reload would lose the in-memory key. +async function bootApp(): Promise { const initResult = await initializeStorage(); if (!initResult.success) { logger.error('StorageBackend: initializeStorage failed:', initResult.error); @@ -281,6 +285,21 @@ function StorageErrorScreen({ message, onReset }: { message: string; onReset: () , ); } catch (error) { + if (error instanceof IdbStorageLockedError) { + // QNBS-v3: loadState() throws when at-rest encryption is configured but not yet unlocked in + // this tab — App never mounts in that case, so its own unlock-prompt effect never runs and + // the user previously saw only the destructive "reset database" screen. Render a standalone + // unlock prompt (no Redux store needed — IdbUnlockModal only needs i18n) and retry the full + // boot in place on success, since the freshly-unlocked key lives in this same JS context. + root.render( + + + void bootApp()} /> + + , + ); + return; + } logger.error('Failed to initialize the application:', error); const msg = error instanceof Error ? error.message : 'Could not load project data.'; root.render( @@ -295,4 +314,6 @@ function StorageErrorScreen({ message, onReset }: { message: string; onReset: () , ); } -})(); +} + +void bootApp(); diff --git a/tests/unit/hooks/useSettingsView.test.ts b/tests/unit/hooks/useSettingsView.test.ts index 5bd9e511..61f1b8e9 100644 --- a/tests/unit/hooks/useSettingsView.test.ts +++ b/tests/unit/hooks/useSettingsView.test.ts @@ -1,6 +1,7 @@ import { act, renderHook, waitFor } from '@testing-library/react'; import type React from 'react'; import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { useTransientUiStore } from '../../../app/transientUiStore'; import { useSettingsView } from '../../../hooks/useSettingsView'; import type { ProjectSnapshot, StorySection } from '../../../types'; @@ -580,3 +581,21 @@ describe('activeCategory', () => { }); }); }); + +// --------------------------------------------------------------------------- +// handleLockSession — must route back to the unlock modal, not just clear the key +// --------------------------------------------------------------------------- +describe('handleLockSession', () => { + beforeEach(() => { + useTransientUiStore.getState().setIdbUnlockOpen(false); + }); + + it('opens the global unlock modal so a subsequent autosave has a route back to unlocking', () => { + const { result } = renderHook(() => useSettingsView()); + act(() => { + result.current.handleLockSession(); + }); + expect(useTransientUiStore.getState().isIdbUnlockOpen).toBe(true); + expect(mockToastInfo).toHaveBeenCalledWith('settings.privacy.encryptionLockedStatus'); + }); +}); From 99c2839282aec391829a221f44580595d7e86e0d Mon Sep 17 00:00:00 2001 From: qnbs <155236708+qnbs@users.noreply.github.com> Date: Tue, 11 Aug 2026 23:18:49 +0200 Subject: [PATCH 50/78] fix(storage): route locked encrypted startup and Lock Session to the unlock modal, not a dead end MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two confirmed paths left a user with configured at-rest encryption unable to reach the passphrase prompt at all: 1. Cold start: when the passphrase sentinel exists but no key is active yet, dbService.loadState() throws IdbStorageLockedError. index.tsx awaited this before mounting , so App.tsx's own unlock-detection effect (which shows IdbUnlockModal) never got a chance to run — the user landed on the generic StorageErrorScreen, whose only action is "Reset Database & Reload" (destroys all local data). The bootstrap IIFE is now a named, re-invocable bootApp() function; a locked-storage catch renders a standalone IdbUnlockModal (wrapped only in I18nProvider — it has no Redux dependency) and retries the full boot in place on success. No page reload is used, since the freshly-unlocked in-memory key would be lost on one. 2. Mid-session: handleLockSession() cleared the key but never opened the unlock modal and didn't block editing, so a user could keep typing while every subsequent autosave silently failed closed (generic "Auto-Save Failed" toast, no path back to unlocking short of manually reopening Settings). It now also opens the same global unlock modal (transientUiStore's isIdbUnlockOpen) that the cold-start path uses. index.tsx has no existing test harness (side-effecting module-level bootstrap, no exported units) — this fix is verified by static tracing of every new dependency (IdbUnlockModal, Modal, Button, useFocusTrap, useTranslation, I18nProvider all confirmed Redux-free) rather than an automated test; useSettingsView.test.ts covers the Lock Session path. Co-Authored-By: Claude Sonnet 5 --- hooks/useSettingsView.ts | 8 ++++++- index.tsx | 27 +++++++++++++++++++++--- tests/unit/hooks/useSettingsView.test.ts | 19 +++++++++++++++++ 3 files changed, 50 insertions(+), 4 deletions(-) diff --git a/hooks/useSettingsView.ts b/hooks/useSettingsView.ts index b9bd7470..addd38f3 100644 --- a/hooks/useSettingsView.ts +++ b/hooks/useSettingsView.ts @@ -2,6 +2,7 @@ import type React from 'react'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useAppDispatch, useAppSelector } from '../app/hooks'; import type { RootState } from '../app/store'; +import { useTransientUiStore } from '../app/transientUiStore'; import type { PassphraseModalMode } from '../components/settings/PassphraseModal'; import { useToast } from '../components/ui/Toast'; import type { Language } from '../contexts/I18nContext'; @@ -53,6 +54,7 @@ export const useSettingsView = () => { const { t, language, setLanguage } = useTranslation(); const dispatch = useAppDispatch(); const toast = useToast(); + const setIdbUnlockOpen = useTransientUiStore((s) => s.setIdbUnlockOpen); const settings = useAppSelector((state) => state.settings); const featureFlags = useAppSelector((state) => state.featureFlags); const projectState = useAppSelector((state) => state.project.present); @@ -427,7 +429,11 @@ export const useSettingsView = () => { clearIdbEncryptionKey(); setEncryptionReady(false); toast.info(t('settings.privacy.encryptionLockedStatus')); - }, [toast, t]), + // QNBS-v3: without this, a subsequent autosave silently fails closed (no route back to the + // unlock UI existed) until the user manually reopens Settings and unlocks — surface the same + // global unlock modal App.tsx shows on a locked cold start. + setIdbUnlockOpen(true); + }, [toast, t, setIdbUnlockOpen]), }; }; diff --git a/index.tsx b/index.tsx index b340b597..6ce1e56e 100644 --- a/index.tsx +++ b/index.tsx @@ -3,11 +3,14 @@ import ReactDOM from 'react-dom/client'; import { Provider } from 'react-redux'; import App from './App'; import { type AppDispatch, appStoreRef, type RootState, setupStore } from './app/store'; +import { IdbUnlockModal } from './components/settings/IdbUnlockModal'; +import { I18nProvider } from './contexts/I18nContext'; import type { ProjectData } from './features/project/projectSlice'; import { versionControlActions } from './features/versionControl/versionControlSlice'; import { initializeStorage, resetAllDatabases } from './services/dbInitialization'; import { dbService } from './services/dbService'; import { logger } from './services/logger'; +import { IdbStorageLockedError } from './services/storage/storageEncryptionService'; import { saveEnvelopeFromProjectData, storageService } from './services/storageService'; import type { PersistedRootState } from './types'; /* ── Self-hosted fonts (@fontsource) ── */ @@ -177,8 +180,9 @@ function StorageErrorScreen({ message, onReset }: { message: string; onReset: () ); } -// Async IIFE: pre-loads state from IndexedDB before mounting React. -(async () => { +// QNBS-v3: named (not an anonymous IIFE) so a locked-storage catch below can retry the full boot +// sequence after the user unlocks, without a page reload — a reload would lose the in-memory key. +async function bootApp(): Promise { const initResult = await initializeStorage(); if (!initResult.success) { logger.error('StorageBackend: initializeStorage failed:', initResult.error); @@ -281,6 +285,21 @@ function StorageErrorScreen({ message, onReset }: { message: string; onReset: () , ); } catch (error) { + if (error instanceof IdbStorageLockedError) { + // QNBS-v3: loadState() throws when at-rest encryption is configured but not yet unlocked in + // this tab — App never mounts in that case, so its own unlock-prompt effect never runs and + // the user previously saw only the destructive "reset database" screen. Render a standalone + // unlock prompt (no Redux store needed — IdbUnlockModal only needs i18n) and retry the full + // boot in place on success, since the freshly-unlocked key lives in this same JS context. + root.render( + + + void bootApp()} /> + + , + ); + return; + } logger.error('Failed to initialize the application:', error); const msg = error instanceof Error ? error.message : 'Could not load project data.'; root.render( @@ -295,4 +314,6 @@ function StorageErrorScreen({ message, onReset }: { message: string; onReset: () , ); } -})(); +} + +void bootApp(); diff --git a/tests/unit/hooks/useSettingsView.test.ts b/tests/unit/hooks/useSettingsView.test.ts index 5bd9e511..61f1b8e9 100644 --- a/tests/unit/hooks/useSettingsView.test.ts +++ b/tests/unit/hooks/useSettingsView.test.ts @@ -1,6 +1,7 @@ import { act, renderHook, waitFor } from '@testing-library/react'; import type React from 'react'; import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { useTransientUiStore } from '../../../app/transientUiStore'; import { useSettingsView } from '../../../hooks/useSettingsView'; import type { ProjectSnapshot, StorySection } from '../../../types'; @@ -580,3 +581,21 @@ describe('activeCategory', () => { }); }); }); + +// --------------------------------------------------------------------------- +// handleLockSession — must route back to the unlock modal, not just clear the key +// --------------------------------------------------------------------------- +describe('handleLockSession', () => { + beforeEach(() => { + useTransientUiStore.getState().setIdbUnlockOpen(false); + }); + + it('opens the global unlock modal so a subsequent autosave has a route back to unlocking', () => { + const { result } = renderHook(() => useSettingsView()); + act(() => { + result.current.handleLockSession(); + }); + expect(useTransientUiStore.getState().isIdbUnlockOpen).toBe(true); + expect(mockToastInfo).toHaveBeenCalledWith('settings.privacy.encryptionLockedStatus'); + }); +}); From 0353364df974362d8ca6c4f1e50a229a8ef4219c Mon Sep 17 00:00:00 2001 From: qnbs <155236708+qnbs@users.noreply.github.com> Date: Tue, 11 Aug 2026 23:36:55 +0200 Subject: [PATCH 51/78] fix(settings): restore sepia as the first-run appearance default MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit settingsSlice.ts's defaultSettings.appearancePreset had drifted to 'default' on this branch, with a comment claiming first-run and legacy-rehydration defaults "must agree." That invariant doesn't hold on main: main deliberately keeps them different — defaultSettings.appearancePreset is 'sepia' (the first-run showcase theme) while normalizePersistedSettings's fallback for a missing/legacy field stays 'default' (so an existing user's old saved settings, missing the field entirely, isn't retroactively theme-shifted into a preset they never chose). This branch's rehydration path already correctly uses 'default' (services/storage/idbProjectStore.ts, unchanged); only the first-run default had regressed. This directly caused tests/e2e/a11y.spec.ts's "dark sepia theme has no serious axe violations" test to fail on #335's current SHA — the test emulates a dark color scheme on a brand-new (no persisted data) project and waits for both `.dark-theme` and `.appearance-sepia` to be applied, which now never happened since the first-run default silently stopped being sepia. Co-Authored-By: Claude Sonnet 5 --- features/settings/settingsSlice.ts | 3 +-- tests/unit/settingsSlice.test.ts | 2 +- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/features/settings/settingsSlice.ts b/features/settings/settingsSlice.ts index 25c1156d..fe0d5a2f 100644 --- a/features/settings/settingsSlice.ts +++ b/features/settings/settingsSlice.ts @@ -44,8 +44,7 @@ export const DEFAULT_OPENROUTER_SETTINGS: OpenRouterSettings = { const defaultSettings: Settings = { // Basic Settings theme: getSystemThemePreference(), - // QNBS-v3: First-run and rehydration defaults must agree so a missing legacy field cannot restore sepia. - appearancePreset: 'default', + appearancePreset: 'sepia', writingSurfaceStyle: 'textured', aiMode: 'hybrid', openRouter: DEFAULT_OPENROUTER_SETTINGS, diff --git a/tests/unit/settingsSlice.test.ts b/tests/unit/settingsSlice.test.ts index fcc90075..f019b1f9 100644 --- a/tests/unit/settingsSlice.test.ts +++ b/tests/unit/settingsSlice.test.ts @@ -16,7 +16,7 @@ describe('settingsSlice', () => { const state = initState(); expect(state).toBeDefined(); expect(state.theme).toBeTypeOf('string'); - expect(state.appearancePreset).toBe('default'); + expect(state.appearancePreset).toBe('sepia'); expect(state.writingSurfaceStyle).toBe('textured'); expect(state.aiCreativity).toBe('Balanced'); expect(state.keyboardShortcuts.length).toBeGreaterThan(0); From 74ce8fd3f2ecac44dde1af31962468ae95818ff9 Mon Sep 17 00:00:00 2001 From: qnbs <155236708+qnbs@users.noreply.github.com> Date: Tue, 11 Aug 2026 23:45:15 +0200 Subject: [PATCH 52/78] docs: refresh stack SHAs after the appearancePreset/unlock-routing fix cascade Co-Authored-By: Claude Sonnet 5 --- docs/ISSUES-332-333-PERFORMANCE-LEDGER.md | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/docs/ISSUES-332-333-PERFORMANCE-LEDGER.md b/docs/ISSUES-332-333-PERFORMANCE-LEDGER.md index 22c4f7ba..669e3f75 100644 --- a/docs/ISSUES-332-333-PERFORMANCE-LEDGER.md +++ b/docs/ISSUES-332-333-PERFORMANCE-LEDGER.md @@ -6,17 +6,24 @@ authoritative closure record for the responsiveness portions of [#332](https://github.com/qnbs/WorldScript-Studio/issues/332) and [#333](https://github.com/qnbs/WorldScript-Studio/issues/333). -## Live baseline — 2026-08-11 (updated) +## Live baseline — 2026-08-11 (updated again after the appearancePreset/unlock-routing fix cascade) | Ref | Live value | | --- | --- | | `main` | `804793aa0815a726935785639e4fb139af7c4b59` | -| PR #335 | `5e80aaa44fc6e31bf50b06294be106577d4d93c3` | -| PR #336 | `2438f991afa4ad3630573e2d12a8fcf83f5280d5` | -| PR #337 | `dc0b5262b7c1c9d9dc1a3b33551d497f29a3a1a8` (pushed; review threads: 0 unresolved) | +| PR #335 | `0353364df974362d8ca6c4f1e50a229a8ef4219c` (1 review thread still open by design — see review-thread note below) | +| PR #336 | `ad4364ac7f6685980c82417c1953e66fbe3ce25b` (review threads: 0 unresolved) | +| PR #337 | `dd92628f850bfe0932e753c1910f3ec115d18546` (review threads: 0 unresolved) | | PR #310 | `27177ce549d4579f1fc9dfbc4630ebf0c2592f9b` | | Issue #332 / #333 | Open / Open; neither has post-report comments | +PR #335's one remaining open review thread (CodeRabbit, `.npmrc`/`pnpm-workspace.yaml` uuid +override range) is a confirmed-valid, deliberately-deferred fix: applying it requires a real +`pnpm install` to regenerate the lockfile, which this session's severely memory-constrained host +cannot safely run without risking an OOM crash mid-install. The exact fix is specified in the +thread reply; it needs a normal-resourced environment or a dependency-update CI job, not a +hand-edit. + `#335` is the lifecycle foundation, `#336` owns desktop, Local-AI, provider, and Python reliability, and `#337` owns recovery-journal/secondary-store work. Performance fixes belong to the earliest affected stack layer; this document From 58d95d1eb7a2f41fd80252e3e018bcf4cc24d0b7 Mon Sep 17 00:00:00 2001 From: qnbs <155236708+qnbs@users.noreply.github.com> Date: Tue, 11 Aug 2026 23:48:53 +0200 Subject: [PATCH 53/78] docs: refresh session handoff, archive the prior capture MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The prior CURRENT-HANDOFF.md (captured 2026-08-11T11:14:45Z) is now stale relative to this session's work: #335/#336/#337 all reached review-thread quiescence (except one deliberately-deferred #335 thread), two real data-loss/lockout defects were found and fixed on #335 (cold-start unlock routing, Lock Session routing), a genuine sepia-default regression was found and fixed, PR310-R009's disposition was corrected, and the performance ledger's stack SHAs were refreshed twice. Archived the prior capture alongside the existing codex handoff rather than discarding it, and wrote a new CURRENT-HANDOFF.md reflecting live state, including what's still genuinely open (the deferred pnpm thread, #310's remaining threads, #332/#333 packaged evidence, and whether #335's final E2E run landed green — a background poll was still in flight when this was written). Co-Authored-By: Claude Sonnet 5 --- docs/session-handoff/CURRENT-HANDOFF.md | 678 +++++++++++------- .../CLAUDE-HANDOFF-20260811T111445Z.md | 308 ++++++++ 2 files changed, 746 insertions(+), 240 deletions(-) create mode 100644 docs/session-handoff/archive/CLAUDE-HANDOFF-20260811T111445Z.md diff --git a/docs/session-handoff/CURRENT-HANDOFF.md b/docs/session-handoff/CURRENT-HANDOFF.md index 8af997e4..410b98f9 100644 --- a/docs/session-handoff/CURRENT-HANDOFF.md +++ b/docs/session-handoff/CURRENT-HANDOFF.md @@ -2,307 +2,505 @@ ## 1. Capture Metadata -- Captured UTC: `2026-08-11T11:14:45Z`. -- Mode: emergency state freeze; no new implementation, install, rebase, reset, - review trigger, or heavy validation began after this boundary. +- Captured UTC: `2026-08-11T23:46:00Z` (local `2026-08-12T01:46:00+02:00`). +- Mode: live working state — this session pushed commits up to the moment of + capture; a CI poll for the final SHAs below was still in flight when this + document was written (see § 15 for how to check its result). - Evidence labels: **LIVE FACT** = command/API evidence at capture; **HISTORICAL FACT** = retained provenance; **UNVERIFIED** = no closure claim. +- This handoff supersedes `docs/session-handoff/CURRENT-HANDOFF.md` as it stood + at `2026-08-11T11:14:45Z` (archived alongside prior codex handoffs in + `docs/session-handoff/archive/`). Very substantial work happened between that + capture and this one — read this document fresh; do not assume anything from + the prior one still applies without re-verifying. ## 2. Executive Summary -The clean, pushed checkout is `feat/encryption-recovery-journal` at -`fefd9efc87f40c323c9b998014c57ae3a68dcf87`. The active stack remains #335 -(foundation) → #336 (desktop/AI) → #337 (recovery); `main` is -`804793aa0815a726935785639e4fb139af7c4b59`. - -Recent code establishes a fail-closed lifecycle/recovery direction, durable -journal work, and bounded Python/LoRA handling. This is focused code/test -evidence, not release closure. Legacy PR #310 remains open and must neither be -merged nor closed as superseded yet. - -Live blockers: #335 quality fails because four README i18n counts say `2869` -instead of `2876`; CodeAnt reports 3 bugs on #335 and 16 on #337. #336's -external checks pass, but the Tauri bundle job remains in progress against -`88016dde`, an ancestor of its final merge SHA. #332/#333 remain open and no -packaged `.deb` performance/persistence evidence exists. - -Host state is severely constrained: 442 MiB free RAM, 1.4 GiB swap used, two -CPUs at load 3.50/3.85/4.04, and 6.6 GiB disk free. Use cloud CI for heavy work. +The active stack is #335 (foundation) → #336 (desktop/AI) → #337 (recovery); +`main` is `804793aa0815a726935785639e4fb139af7c4b59` (unchanged all session). + +**All three stack PRs reached 0 unresolved review threads this session**, +except #335 which has exactly **one** deliberately-left-open thread (a +supply-chain hardening suggestion that needs a `pnpm install` this host cannot +safely run — see § 8). CodeAnt quality gates are green on #336 and #337 at +their current SHAs. #335's CI was still resolving the E2E suite against its +final SHA when this document was written (see § 15) — a real, confirmed +sepia-default regression was found and fixed as part of this session's work +and is included in that SHA, but the fresh E2E run against it had not yet +reported by capture time. + +This session found and fixed several genuine, non-cosmetic defects beyond +review-comment busywork — most notably two that would have broken the B-1 +at-rest encryption feature for real users (see § 6). Legacy PR #310 remains +open and must neither be merged nor closed as superseded yet — its ledger +reconciliation advanced (PR310-R009's disposition fixed) but is not complete. +#332/#333 remain open with no packaged `.deb` evidence — explicitly deferred +again this session; this host cannot safely produce that evidence. + +**Standing merge authorization**: the user has authorized merging the +#335→#336→#337 stack into `main` once every PR reaches review-thread +quiescence and CI is green, without asking again, provided none of the NO-GO +conditions in § 19 are triggered. As of this capture, that bar is **not yet +met** — #335's E2E result on its final SHA was still pending, and #335 has one +deliberately-open thread (see § 8 for why that specific one does not have to +block: it's a hardening improvement to an already-safe pinned resolution, not +an active vulnerability). Do not merge until you have confirmed #335's CI is +fully green on `0353364df974362d8ca6c4f1e50a229a8ef4219c` — check this before +anything else (§ 15). ## 3. Exact Live Git State | Field | Value | Evidence | | --- | --- | --- | | Branch | `feat/encryption-recovery-journal` | LIVE FACT | -| Head | `fefd9efc87f40c323c9b998014c57ae3a68dcf87` | LIVE FACT | -| Upstream | `origin/feat/encryption-recovery-journal` | LIVE FACT | +| Head | `74ce8fd3` | LIVE FACT | +| Upstream | `origin/feat/encryption-recovery-journal`, head == upstream | LIVE FACT | | Tree | Clean; no staged, unstaged, untracked, or stash entries | LIVE FACT | -| Unpushed commits | None; head equals upstream | LIVE FACT | | Origin | `https://github.com/qnbs/WorldScript-Studio.git` | LIVE FACT | | Default branch | `main @ 804793aa0815a726935785639e4fb139af7c4b59` | LIVE FACT | -`git fetch --prune` removed local tracking aliases `origin/pr-310` and -`origin/pr-311`; GitHub confirms PRs #310/#311 are still open. That was only a -tracking-ref cleanup. +Local branches `fix/encryption-lifecycle-safety` (#335) and +`fix/desktop-reliability-hardening` (#336) both exist and match their +respective `origin/*` remotes exactly (see § 4 for their SHAs) — no unpushed +local work on any of the three stack branches. ## 4. Live PR Stack / Branch Topology -| PR | Responsibility | Head → base | State / size | Review-thread total | Merge state | -| --- | --- | --- | --- | --- | --- | -| #335 | encryption/settings/pnpm foundation | `fa3cd983` → `main@804793aa` | Open; 88 files, +860/-648, 3 commits | 33 | `BLOCKED` | -| #336 | Local AI/provider/Python/LoRA desktop reliability | `fd7ed7c1` → `#335@fa3cd983` | Open; 36 files, +1297/-148, 4 commits | 43 | `CLEAN` | -| #337 | recovery journal, adapters, #310 replacement | `fefd9efc` → `#336@fd7ed7c1` | Open; 73 files, +4042/-228, 18 commits | 54 | `UNSTABLE` | -| #310 | legacy secondary-store encryption | `27177ce5` → `main@804793aa` | Open; 35 files, +2958/-390, 9 commits | 317 | `BLOCKED` | - -Keep fixes at the earliest affected layer. Other open Dependabot PRs (#312–334) -and #311 are outside this remediation stack. - -## 5. Commits Since Previous Checkpoint - -No prior `docs/session-handoff/` file existed. The recent checkpoint is: - -| SHA | Message | Intent / validation | -| --- | --- | --- | -| `fefd9efc` | `docs: add desktop performance evidence ledger` | docs only; see stale-ref note in section 12 | -| `dda48b33` | `chore: merge desktop reliability foundation` | merges #336 into #337 | -| `fd7ed7c1` | `chore: merge encryption lifecycle foundation` | merges #335 into #336 | -| `fa3cd983` | `chore(deps): align pnpm v11 security policy` | normal pre-commit passed; cloud docs gate red | -| `88016dde` | `fix(tauri): bound Python probes and LoRA process lifecycle` | rustfmt pass; cloud Tauri build in progress | -| `997b2f6d` | `fix(storage): harden migration recovery protocol` | focused migration tests pass before merge | -| `c4b64f83` | `fix(deps): reconcile release-age lockfile` | earlier Vercel pass on its own SHA | -| `58a3a82c` | `feat(storage): add resumable secondary store adapters` | later journal work hardens/supersedes its lifecycle | - -Latest implementation commit is `88016dde`; latest documentation commit is -`fefd9efc`; latest journal implementation ancestor is `997b2f6d`. - -## 6. Completed Work - -- #337: target-key verifier, owner lease/checkpoints, adapter conflict checks, - typed missing snapshots, safe scene retention, and best-effort cache writes. -- Focused local storage evidence: protected-store migration 11/11 PASS and - journal tests 9/9 PASS on an ancestor of the current head. -- #336: bounded Python candidates, blocking work moved from async paths, - explicit training states, duplicate job prevention, termination confirmation. -- pnpm v11 policy is explicit; a frozen script-free install synchronized local - metadata and normal `lint-staged` pre-commit later passed. -- `docs/ISSUES-332-333-PERFORMANCE-LEDGER.md` now prevents code-only closure. - -## 7. Work In Progress - -1. #335 README docs gate and current review correction loop. -2. #336 native Tauri evidence plus current review normalization. -3. #337 CodeAnt/review correction, failure-injection proof, and #310 mapping. -4. #332/#333 packaged desktop/performance/persistence validation. - -## 8. Current Blockers - -| Priority | Blocker | Evidence | Resolution | -| --- | --- | --- | --- | -| P0 | #335 cloud quality red | Run `31485190552` | Update four README counts 2869 → 2876, push, get green quality/build | -| P0 | CodeAnt gates red | #335: 3 bugs; #337: 16 bugs | Current-head thread fetch, fix/test/reply/resolve, fresh quiescent review wave | -| P0 | #310 not terminally reconciled | Open; 317 threads; ledger issue R009 | Finish compliant behavior/test/review mapping before merge/closure decision | -| P1 | Native Rust evidence incomplete | Run `31484800148` in progress on `88016dde` | Monitor; fix/re-dispatch on final #336 SHA if necessary | -| P1 | #332/#333 unmeasured in packaged app | Ledger matrix pending | Candidate `.deb` performance and relaunch matrix | +| PR | Responsibility | Head → base | Review threads (unresolved/total) | Merge state | +| --- | --- | --- | --- | --- | +| #335 | encryption/settings/pnpm foundation | `0353364d` → `main@804793aa` | 1/41 (the deliberately-open uuid thread) | check live before acting — was `BLOCKED` before this session's E2E fix, unverified after | +| #336 | Local AI/provider/Python/LoRA desktop reliability | `ad4364ac` → `#335@0353364d` | 0/49 | `CLEAN` as of its prior SHA; re-verify after this session's merge | +| #337 | recovery journal, adapters, #310 replacement | `74ce8fd3` → `#336@ad4364ac` | 0/59 (2 new doc-only commits since the 57-thread count) | re-verify; was clean through `ed46befd` | +| #310 | legacy secondary-store encryption | `27177ce5` → `main@804793aa` | 28/317 (not touched this session) | `BLOCKED`, stays open — do not merge/close | -## 9. Review Finding Reconciliation +Other open Dependabot PRs (#312–334) and #311 are outside this remediation +stack — no action needed there. -Counts in section 4 are live **total** thread counts, not a claim that every -thread was current-head normalized during this handoff. +## 5. Commits This Session (newest first, on `feat/encryption-recovery-journal` unless noted) -| PR | Live review/check state | Handoff classification | Required action | +| SHA | Branch | Message | Notes | | --- | --- | --- | --- | -| #335 | CodeAnt Quality/SCR FAIL; CodeRabbit pending | `VALIDITY_UNNORMALIZED` | Fetch and classify 33 threads; fix at #335; rerun bot to zero actionable/zero unresolved | -| #336 | CodeAnt Quality PASS; SCR rating B/12 bugs; CodeRabbit skipped because base disables review | `VALIDITY_UNNORMALIZED` | Inspect CodeAnt comments and 43 threads; skipped is not reviewed-pass | -| #337 | CodeAnt Quality/SCR FAIL; CodeRabbit/Sourcery skipped | `VALIDITY_UNNORMALIZED` | Normalize 54 threads and 16 bugs after `fefd9efc`; fix/reply/resolve then fresh wave | -| #310 | DeepSource JS FAIL; 317 threads | `VALIDITY_UNNORMALIZED` | Reconcile every material legacy concern before disposition | - -Check first: IDB read rejection, target verifier/adapter races, snapshot absence, -scene retention, cache failure, Local-AI busy state, stale provider requests, -Python probing, and LoRA termination. Never resolve only because an anchor moved. - -## 10. PR #310 Reconciliation +| `74ce8fd3` | #337 | `docs: refresh stack SHAs after the appearancePreset/unlock-routing fix cascade` | ledger only | +| `dd92628f` | #337 | (merge) `fix/desktop-reliability-hardening` into `feat/encryption-recovery-journal` | brings #336's cascade in | +| `0353364d` | #335 | `fix(settings): restore sepia as the first-run appearance default` | **real regression fix** — see § 6 | +| `ad4364ac` | #336 | (merge) `fix/encryption-lifecycle-safety` into `fix/desktop-reliability-hardening` | brings #335's cascade in | +| `99c28392` | #335 | `fix(storage): route locked encrypted startup and Lock Session to the unlock modal, not a dead end` | **real regression fix**, cherry-picked from `667f6f37` — see § 6 | +| `667f6f37` | (superseded on #337) | same fix, originally committed directly to #337 by mistake — see § 16 for the layering-error story | left in #337's own history; harmless (identical content, properly cascaded via merge afterward) | +| `68050d80` | #337 | `docs(test): clarify encryptionMigrationJournal.test.ts's ownership-CAS comment` | | +| `3f31c1e1` | #337 | `fix(ollama): report invalidResponse instead of a false-positive connection success` | | +| `beadfa22` | #337 | `fix(ai): degrade cache reads to a miss on lock/migration, re-encrypt legacy entries on read` | | +| `411943a8` | #337 | `fix(storage): fail migration to recovery-required on a verification shortfall` | new `ProtectedStoreVerificationShortfallError` | +| `46198b26` | #337 | `fix(storage): skip corrupt scene revisions instead of hiding history; narrow write guard to migration-only` | | +| `dc0b5262` | #337 | `fix(settings): stop rendering the connection test result twice in AiProviderCard` | | + +Earlier in this session (before the portion transcribed above — see the +conversation summary if you need it): #335 and #336 individually reached +review-thread quiescence with CodeAnt gates green (commits `5e80aaa4` and +`2438f991` respectively, both now further advanced by the two fixes above). + +## 6. Real Defects Found and Fixed This Session (not just review-comment busywork) + +These were discovered by cross-checking review findings against actual +current code — some review findings turned out to already be fixed, but a +few, once investigated, were genuine and severe: + +1. **Cold-start encrypted-storage lockout with no way to unlock (#335, + `99c28392`).** When at-rest encryption (B-1) is configured but not yet + unlocked in a fresh tab, `dbService.loadState()` throws + `IdbStorageLockedError`. `index.tsx` awaited this *before* mounting ``, + so `App.tsx`'s own unlock-detection effect (which shows `IdbUnlockModal`) + never ran — the user landed on the generic `StorageErrorScreen`, whose only + action is "Reset Database & Reload" (**destroys all local data**). Fixed by + making the bootstrap a named, re-invocable `bootApp()` function; a + locked-storage catch now renders a standalone `IdbUnlockModal` (confirmed + Redux-free — only needs `I18nProvider`) and retries the full boot in place + on success, with no page reload (which would lose the freshly-unlocked + in-memory key). +2. **"Lock Session" created a silent-data-loss trap (#335, same commit).** + `handleLockSession()` cleared the encryption key but never opened the + unlock modal and didn't block editing — a user could keep typing while + every subsequent autosave silently failed closed (generic "Auto-Save + Failed" toast, no route back to unlocking short of manually reopening + Settings). Fixed by also opening the same global unlock modal + (`transientUiStore.setIdbUnlockOpen(true)`). +3. **First-run appearance default silently regressed from `sepia` to + `default` (#335, `0353364d`).** `main` deliberately keeps the first-run + default (`sepia`, the "Candlelit Manuscript" showcase) different from the + legacy-rehydration fallback (`default`, so an existing user's old data + missing the field isn't retroactively theme-shifted). This branch had + drifted to using `'default'` for *both*, with an incorrect comment + claiming they "must agree." This directly caused + `tests/e2e/a11y.spec.ts`'s `"dark sepia theme has no serious axe + violations"` test to fail (confirmed via the CI log — `page.waitForFunction` + timeout waiting for `.appearance-sepia`, which never appeared). Reverted + to match main's deliberate design. +4. **Migration verification could retry forever with no operator visibility + (#337, `411943a8`).** `runProtectedStoreMigration`'s verifying phase threw + a generic error on a verified-count shortfall (e.g. a stray ordinary write + landing on an already-migrated record with a superseded key after that + store's migrating pass finished) and just released the ownership lease, + leaving the journal parked at `verifying` — an indefinite silent retry + loop, since nothing in the saga revisits and reconverts the stray record. + New `ProtectedStoreVerificationShortfallError` (distinct from a + transient/interrupted `verify()` exception, which still resumes correctly) + now transitions the journal to `recovery-required` on this specific + failure, making the stuck state visible. +5. **TOCTOU guard reordering regression, self-caught (#337, `46198b26` + following `a8dd9175`).** An earlier fix in this session moved the full + `assertIdbProtectedWriteAllowed()` (migration + lock check) to immediately + before each protected write's transaction, to narrow a TOCTOU window. This + broke an *existing* regression test (`'saveSlice still encrypts even when + the key is cleared right after the write key is resolved'`) because + `resolveProtectedWriteKey()` already performs its own lock check atomically + with the key snapshot — re-running the full check afterward wrongly + rejected an already-safely-encrypted write if the session locked mid-write. + Fixed by re-checking only the narrower `assertNoActiveEncryptionMigration()` + pre-write in the six affected write methods, newly re-exported from + `storageEncryptionService` for this purpose. +6. **`listRevisions()` let one damaged revision hide a whole scene's history + (#337, `46198b26`).** Now skips (logs, `continue`s) a single record that + throws `SecureRecordCorruptError` during decode instead of rejecting the + entire call; any other error type still aborts (a genuine lock-state + change must not be swallowed). +7. **`aiInferenceCacheService` read path didn't honor its own + non-authoritative contract (#337, `beadfa22`).** `getCachedInference()`'s + lifecycle check was unguarded, so a lock/migration/IDB-access failure + rejected the call instead of degrading to a miss (the write path, + `setCachedInference`, already had this protection). Also: legacy plaintext + cache entries were read correctly but never opportunistically re-encrypted, + so a cache populated before encryption was enabled stayed plaintext for its + full 7-day TTL even after unlock — now rewritten via the same + `needsMigration` signal the journal adapters already use. +8. **`testOllamaConnection()` reported a false-positive success on a + malformed response (#337, `3f31c1e1`).** A 200 response with unparseable + JSON or a missing `models` array was silently treated as `ok: true` with + zero models, inconsistent with the OpenAI-compatible diagnostic path's + existing `invalidResponse` classification. Fixed to match. +9. **`AiProviderCard` rendered the connection test result twice (#337, + `dc0b5262`).** The new status panel and the pre-existing action row both + rendered the identical `testError`/success text simultaneously — confirmed + real via an existing test that used `getAllByText(...).length` with a + comment acknowledging the duplication as known. Consolidated to the status + panel only. + +Item 3 (appearance default) is the one still awaiting a fresh CI signal — see +§ 15 before doing anything that assumes it's confirmed green. + +## 7. What's Genuinely Still Open (do not claim these are done) + +1. **The one #335 review thread left unresolved on purpose** (§ 8) — needs a + `pnpm install` from a properly-resourced environment. +2. **#310 reconciliation is not complete.** This session fixed PR310-R009's + disposition (was `REWRITE`, an impermissible interim category; now + `ADOPTED_WITH_MODIFICATIONS` with concrete test-name citations in new row + `PR310-R016`, `docs/PR-310-RECONCILIATION.md`) and two related REWRITE rows + that described the same underlying concern. The doc's own "Status: in + progress" header is still accurate — most of #310's 317 historical threads + and its 28 currently-unresolved ones were **not** touched this session. +3. **#332/#333 packaged desktop evidence** — explicitly deferred again. No + `.deb` build/install/relaunch matrix ran. Do not claim closure. +4. **`index.tsx`'s new locked-storage bootstrap branch has no automated + test.** The file has zero pre-existing test infrastructure (side-effecting + module-level bootstrap, nothing exported/testable). The fix was verified + by static tracing of every new dependency (`IdbUnlockModal`, `Modal`, + `Button`, `useFocusTrap`, `useTranslation`, `I18nProvider` — all confirmed + Redux-free) rather than a test. If you have time and the host can spare a + heavier vitest run, consider whether exporting `bootApp` and building a + minimal jsdom harness (stub `#root`, mock `dbService.loadState` to reject) + is worth the investment — it wasn't done here due to session scope/time, + not because it's impossible. +5. **Whether #335's final CI run is actually green** — was in flight at + capture time. Check this first (§ 15) before any merge action. + +## 8. The One Deliberately-Open #335 Thread + +CodeRabbit flagged `pnpm-workspace.yaml`'s `uuid: ">=11.1.1"` override as a +bare floor that would still permit two known-vulnerable exact releases +(`12.0.0`, `13.0.0` — GHSA-w5hq-g745-h8pq / CVE-2026-41907) if a future +resolution ever landed on them; the currently-locked `uuid@14.0.1` is +unaffected today. This is a real, correctly-identified hardening gap, not a +false positive. + +The precise fix was applied and verified correct, then reverted: +`uuid: ">=11.1.1 <12.0.0 || >=12.0.1 <13.0.0 || >=13.0.1"` in both +`pnpm-workspace.yaml` and the two matching fields in `pnpm-lock.yaml` +(`overrides.uuid` and the `uuid` importer's `specifier`). The moment the +override string changes, this repo's `verifyDepsBeforeRun: error` policy +(by design) refuses to run **any** script — including a plain typecheck — +until a real `pnpm install` reconciles `node_modules`' installed state with +the new override. That's the exact fail-closed behavior the policy exists to +enforce; there is no way to hand-edit around it safely. This session's host +was memory-severely-constrained (§ 14) and a full `pnpm install` for a +project this size (transformers.js/onnxruntime/webllm/Playwright/Storybook +among the devDependencies) risks an OOM crash mid-install, leaving the +lockfile in a worse, half-resolved state than today's. + +**To close this thread:** from a normal-resourced environment (or via a +dependency-update CI job), apply the exact override string above to both +files consistently, run `pnpm install` to let it reconcile/regenerate +naturally, verify `pnpm run typecheck` and `pnpm run lint` pass, then push, +reply to the thread citing the resolving commit, and resolve it via GraphQL +`resolveReviewThread`. Thread id (GraphQL): fetch via the review-thread query +in § 15 if it's not still `PRRT_kwDOQOeAgc6YK1Aq` (SHAs will have moved). + +## 9. PR #310 Reconciliation - Ledger: `docs/PR-310-RECONCILIATION.md`. -- Strategy: Option C — replace through #335 + #337 while preserving all material - behavior and useful test intent. -- Live PR: #310 at `27177ce549d4579f1fc9dfbc4630ebf0c2592f9b`; still open. -- Current ledger decision: **NO-GO — REQUIRES FURTHER REMEDIATION**. -- Rows R001–R015 exist. Fourteen use allowed dispositions; R009 says `REWRITE`, - which is not an allowed final category and must be normalized with a concrete - replacement test mapping. - -Do not merge #310 over #337. Closure as superseded requires all material rows -to use permitted dispositions plus recovery, store inventory, interruption, -export/import, stale-client/multi-tab, review, and test evidence. - -## 11. #332 / #333 Status +- Strategy: Option C — replace through #335 + #337 while preserving all + material behavior and useful test intent. Unchanged this session. +- Live PR: #310 at `27177ce549d4579f1fc9dfbc4630ebf0c2592f9b`; still open, still + `BLOCKED`. Do not merge or close. +- **This session's change:** PR310-R009 (was `REWRITE`) and two related rows + describing the same underlying "missing-store/interruption/legacy-shape/ + resume/verification coverage" concern are now `ADOPTED_WITH_MODIFICATIONS`, + pointing to a new consolidated row `PR310-R016` with concrete test-name + citations (`protectedStoreMigration.test.ts`'s missing-adapter/checkpoint, + interruption+resume, and verification-shortfall tests, plus + `secondaryPayloadStoreAdapter.test.ts`'s plaintext-conversion test). +- **Not done:** the doc's 28 currently-unresolved historical review threads + and the remainder of its 317-thread total were not fetched/classified this + session. Do not claim #310 reconciliation is complete. + +## 10. #332 / #333 Status (unchanged this session — explicitly deferred again) | Workstream | State | Evidence | Closure classification | | --- | --- | --- | --- | | #332 `.deb` sluggish Settings | Open | No package/profile | `NOT_REPRODUCED_ENVIRONMENT_LIMITED` | -| #332 sepia persistence | Open | Default change only; no relaunch matrix | `ROOT_CAUSE_CONFIRMED_FIX_PENDING` | +| #332 sepia persistence | Open | **Root cause now understood and fixed on #335 this session** (§ 6 item 3) — but still no relaunch matrix / packaged proof | `ROOT_CAUSE_CONFIRMED_FIX_PENDING` → verify with packaged evidence before closing | | #333 Local-AI acquisition | Open | Code direction only; no terminal runtime proof | `FIXED_CODE_ONLY_AWAITING_PACKAGED_VERIFICATION` | | #333 UI freezes/overlap | Open | No trace/zoom/RTL/package matrix | `NOT_REPRODUCED_ENVIRONMENT_LIMITED` | -| #333 Gemini/LM Studio | Open | #336 code hardening; packaged diagnostic unverified | `FIXED_CODE_ONLY_AWAITING_PACKAGED_VERIFICATION` | -| #333 Python/LoRA | Open | `88016dde`; native build in progress | `ROOT_CAUSE_CONFIRMED_FIX_PENDING` | +| #333 Gemini/LM Studio | Open | Code hardening; packaged diagnostic unverified | `FIXED_CODE_ONLY_AWAITING_PACKAGED_VERIFICATION` | +| #333 Python/LoRA | Open | Native build evidence from an earlier session; not re-verified this session | `ROOT_CAUSE_CONFIRMED_FIX_PENDING` | -Neither issue is eligible for closure. +Neither issue is eligible for closure. The sepia-persistence root cause is +now well understood (§ 6 item 3) — worth prioritizing for the *next* packaged +verification pass, since the fix is already merged into the stack. -## 12. Performance / Responsiveness Status +## 11. Performance / Responsiveness Status -- Ledger: `docs/ISSUES-332-333-PERFORMANCE-LEDGER.md`. -- Amendment integration: `PARTIAL`; contract/ledger present, no runtime capture. -- Settings P50/P95, long tasks, React/layout/paint/invoke counts, package startup - and memory metrics are all pending. -- No `.deb` build/install/terminal launch/menu launch/Wayland/X11 test occurred. -- The ledger's #337 line says `dda48b33` "local merge pending push"; live is - `fefd9efc`. Correct the ledger before relying on it for a new run. +- Ledger: `docs/ISSUES-332-333-PERFORMANCE-LEDGER.md` — SHAs refreshed this + session (twice — once mid-session, once after the final cascade). +- No `.deb` build/install/terminal launch/menu launch/Wayland/X11 test + occurred this session either. Amendment integration remains `PARTIAL`. **PERFORMANCE CLOSURE NOT YET VERIFIED.** Browser/Vercel/unit success cannot close the packaged Tauri reports. -## 13. pnpm / Supply-Chain / Vercel Status +## 12. pnpm / Supply-Chain Status | Item | Value | | --- | --- | -| Declared / active pnpm | `11.5.2` / `11.5.2` | -| Node | `v24.11.1` | -| `minimumReleaseAge` | `10080` minutes | -| `verifyDepsBeforeRun` | `error` | -| `strictDepBuilds` / `blockExoticSubdeps` | `true` / `true` | -| Dependency scripts in reconciliation install | Not run (`--ignore-scripts`) | - -The release-age incident was resolved by exact `ip-address@10.3.1`, not policy -weakening or broad script approval. Do not reinstall unless the lock graph -changes. If metadata rejects a normal hook, use one bounded -`CI=true pnpm install --frozen-lockfile --ignore-scripts`, inspect diff, then -stop; do not use `approve-builds`, `rebuild`, or broad allowlists. - -Vercel is pass for #335/#336/#337 current deployment contexts. It proves deploy -build only, not package/performance/full-CI closure. +| Declared / active pnpm | `11.5.2` | +| `verifyDepsBeforeRun` | `error` — confirmed live-tested this session (blocks all scripts on an override-string change until `pnpm install`) | +| `uuid` override | `">=11.1.1"` (bare floor) — a tighter, verified-correct replacement string is specified in § 8 but not yet applied (needs a real install) | +| Everything else in `pnpm-workspace.yaml`'s `overrides` | Unchanged this session | + +**Do not attempt to hand-edit `pnpm-workspace.yaml`/`pnpm-lock.yaml` override +strings without immediately following up with a real `pnpm install`** — this +session proved empirically that `verifyDepsBeforeRun: error` will block every +subsequent script (including typecheck) the instant the override string and +the installed `node_modules` state diverge. + +## 13. Review-Thread Reconciliation Method Used This Session + +For every unresolved thread across #335/#336/#337: read the finding's full +body (GraphQL `reviewThreads` → `comments.nodes[].body`, paginated at 100), +then verify against the **current** code at the file/line the finding +references (anchors drift — `isOutdated: true` is not a disposition, the +underlying claim must be re-checked). Classification used: + +- **Already fixed** — a prior commit in this session (or an earlier session) + already addressed it; reply citing the exact resolving commit SHA and the + specific code/test evidence, then resolve. +- **False positive** — the finding's premise doesn't hold against current + code (e.g. the "missing `_resetDbForTest()`" finding, which was actually + present under a differently-named, correctly-scoped helper); reply with + evidence, then resolve. +- **Real, fixed this session** — implement the root-cause fix + test, reply + citing the new commit, resolve. +- **Real, deliberately deferred** — confirmed valid, but the safe fix requires + something this environment cannot do (§ 8's `pnpm install` case); reply with + the exact fix specification and the reason it's blocked, and **leave it + unresolved** rather than falsely closing it. + +Never resolve a thread solely because its anchor moved (`isOutdated: true`) +— that's a stale pointer, not evidence the concern was addressed. ## 14. Local Resource Constraints -| Metric | Capture value | +| Metric | Capture value (this session, near end) | | --- | --- | -| RAM | 3.7 GiB total; 442 MiB free; 1.3 GiB available | -| Swap | 3.9 GiB total; 1.4 GiB used | -| CPUs/load | 2; 3.50/3.85/4.04 | -| Disk | 6.6 GiB free; 93% used | -| Expensive processes | None besides current Codex sandbox | -| Class | `SEVERELY_CONSTRAINED` | - -Use single-command local diagnostics/focused tests only; use cloud for clean -install, coverage, E2E, packaged builds, performance, and large matrices. - -## 15. Test / CI / Deployment Evidence - -| Check | SHA/scope | Place | Result | Note | -| --- | --- | --- | --- | --- | -| protected-store migration test | `997b2f6d` ancestor | Local | PASS 11/11 | focused | -| journal test | `997b2f6d` ancestor | Local | PASS 9/9 | focused | -| crypto-heavy storage batch | pre-final merge | Local | INCONCLUSIVE | two empty JUnit/no completion runs | -| LoRA rerun | after `88016dde` | Local | INCONCLUSIVE | mock fixed; rerun resource-inconclusive | -| `rustfmt` on `lora.rs` | `88016dde` | Local | PASS | formatting only | -| `cargo fmt --check` | `88016dde` | Local | FAIL pre-existing | unrelated drift; no broad rewrite | -| #335 Actions `31485190552` | `fa3cd983` | Cloud | FAIL | four README doc metrics; downstream skipped | -| #335 CodeAnt | `fa3cd983` | Cloud | FAIL | 3 bugs/rating C | -| #336 CodeAnt Q/SAST/SCA | `fd7ed7c1` | Cloud | PASS | SCR B/12 must be inspected | -| #336 CodeRabbit | `fd7ed7c1` | Cloud | SKIPPED | base disables review | -| Tauri run `31484800148` | `88016dde` | Cloud | IN PROGRESS | Ubuntu/macOS/Windows in build stage | -| #337 CodeAnt Q/SCR | `fefd9efc` | Cloud | FAIL | 16 bugs/rating C | -| #337 SAST/SCA/GitGuardian/Semgrep/Vercel | `fefd9efc` | Cloud | PASS | security/deploy only | -| #310 historical CI | `27177ce` | Cloud | MIXED | DeepSource JS fails; no merge proof | - -## 16. Known Failed Approaches / Do Not Repeat - -| Approach | Outcome / required change | -| --- | --- | -| Repeated broad pnpm resolution | Unsafe on this host; retry only after graph change, frozen and script-free | -| Full local coverage/E2E/mutation/Lighthouse/Tauri build | CI-only on this hardware | -| Crypto-heavy storage or LoRA test batch | Resource-inconclusive; use cloud or one isolated test when host recovers | -| Vercel/browser success as desktop proof | Invalid; use installed package matrix | -| Resolving stale review anchors | Prohibited; current-head validation first | +| RAM | 3.7 GiB total; 1.3 GiB free; 1.6 GiB available | +| Swap | 3.9 GiB total; 2.0 GiB used | +| CPUs/load | 2; 3.65/4.79/4.89 | +| Disk | 6.2 GiB free; 94% used | +| Class | `SEVERELY_CONSTRAINED` (improved slightly from an earlier 109 MiB free reading mid-session, still constrained) | + +One Bash command per turn; heavy commands (`vitest run` across many files, +`biome check` on the full repo) reliably exceed the 120s foreground timeout +and move to background automatically — that's expected, not a failure; poll +via the background task's output file rather than re-running. No local `pnpm +install` without a properly-resourced environment (§ 8, § 12). + +## 15. How to Check Whether This Session's Final Push Landed Green + +This session ended while a background poll for #335's E2E completion (and a +final full-stack status snapshot) was still running. To pick up from here: + +```bash +gh pr checks 335 # look specifically for "🎭 E2E Tests (Playwright)" +gh pr checks 336 +gh pr checks 337 +``` + +```bash +# Unresolved thread count per PR (should be 1/0/0 for #335/#336/#337 respectively): +gh api graphql -f query='query { repository(owner: "qnbs", name: "WorldScript-Studio") { pullRequest(number: 335) { reviewThreads(first: 100) { nodes { isResolved } } } } }' --jq '[.data.repository.pullRequest.reviewThreads.nodes[] | select(.isResolved==false)] | length' +```//<- repeat for 336, 337 + +If `gh pr checks 335` shows the E2E job green and CodeAnt gates green, and +the thread counts match 1/0/0, the standing merge authorization (§ 2) is +satisfied for the review/quality dimension. Still re-check § 19's NO-GO +conditions (native Rust evidence for #336's Tauri build, #310 state, #332/#333 +evidence) before actually merging — those are independent of review-thread +count and were not re-verified at the very end of this session. + +If the E2E job is still red on `0353364d`, re-fetch its log +(`gh run view --job --log-failed`) and check whether it's +the same `a11y.spec.ts` sepia test (would mean the fix didn't take effect — +re-check `features/settings/settingsSlice.ts:47` says `appearancePreset: +'sepia'`) or something new. + +## 16. A Layering Mistake Made and Corrected This Session (read if confused by branch history) + +Two fixes (§ 6 items 1, 2, and 3) belong architecturally to #335 (the +foundation layer — `services/storageEncryptionService.ts`, `index.tsx`, +`hooks/useSettingsView.ts`, `features/settings/settingsSlice.ts` all +originate there). They were initially committed directly onto +`feat/encryption-recovery-journal` (#337) as commit `667f6f37` — a violation +of this repo's "fix at the earliest affected layer" policy, since it would +have left #335 and #336 still broken while #337 alone had the fix. This was +caught before pushing further and corrected: `667f6f37`'s content was +cherry-picked onto #335 (`99c28392`), pushed, then cascaded forward through +#336 (`ad4364ac`) and back into #337 via normal merges — `667f6f37` itself +remains in #337's own commit history (harmless: identical file content, and +properly folded into the merge ancestry afterward) but is superseded as the +"source of truth" location by the cherry-pick on #335. If you're diffing +history and see this commit appear twice with different SHAs, that's why. ## 17. Uncommitted / Unpushed State -At capture start the tree was clean and all implementation work pushed. This -handoff and its archive are the only subsequent local changes until committed. -No stash, reset, clean, rebase, or force push occurred. +Tree is clean; all three branches (`fix/encryption-lifecycle-safety`, +`fix/desktop-reliability-hardening`, `feat/encryption-recovery-journal`) are +pushed and match their `origin/*` remotes exactly as of capture. No stash, +reset, clean, rebase, or force push occurred this session. ## 18. Exact Next Actions -1. **P0-1/#335:** update README lines 15, 400, 509, 711 from 2869 to 2876; - run `pnpm run docs:check` only if healthy; commit/push; require green Node - quality and downstream build on the new SHA. -2. **P0-2/#335:** fetch/classify all 33 threads and CodeAnt bugs against current - head; fix at #335, test, reply/resolve, then one fresh CodeAnt wave to zero. -3. **P0-3/#336:** monitor Tauri run `31484800148`; inspect/fix if failed; if - passed decide whether final `fd7ed7c1` needs a new native dispatch; normalize - 43 threads and CodeAnt SCR comments. -4. **P0-4/#337/#310:** normalize 54 #337 threads/16 CodeAnt bugs; fix at #337; - convert R009 to allowed disposition; complete store/recovery mapping before - any #310 merge/closure decision. -5. **P1-1/#332:** after CI-safe candidate, run installed `.deb` terminal/menu - performance and appearance relaunch matrix; record before/after measurements. -6. **P1-2/#333:** validate Local-AI progress/cancel/retry/busy terminality, LM - Studio/Python menu-vs-terminal, LoRA cancellation, and layout matrix in app. - -## 19. Merge / Release NO-GO Conditions - -No merge/release if protected writes downgrade, lifecycle/recovery is not -resumable, #310 mapping/reviews remain incomplete, #335/#337 quality is red, -native Rust is unbuilt, or #332/#333 lack packaged persistence/performance -evidence. Also block if Local-AI remains busy after terminal operations, LoRA -can survive cancel, or no package before/after evidence exists for a reproduced -slowdown. +1. **Check § 15** — confirm #335's E2E result on `0353364d` and the 1/0/0 + thread counts across the stack. +2. **If green:** the standing merge authorization applies. Re-verify § 19's + NO-GO conditions (they're independent of review-thread state), then merge + #335 → `main`, then #336 (auto-retargets to `main` once #335 merges), then + #337 (same), in that order, using whatever merge method this repo's + branch-protection settings require (squash, per `AGENTS.md`/`CLAUDE.md` + convention observed elsewhere in this remediation effort). +3. **If #335's E2E is still red:** diagnose per § 15's last paragraph before + doing anything else. +4. **After merging (or if not merging this session):** continue #310 + reconciliation (§ 9) — 28 of 317 threads and the bulk of the ledger's + remaining rows are untouched. +5. **When a properly-resourced environment is available:** close § 8's + deliberately-open #335 thread (`pnpm install` + push + reply + resolve). +6. **When packaged-desktop validation is possible again:** #332/#333 (§ 10) — + the sepia-persistence root cause is now fixed in-stack and just needs a + relaunch-matrix proof; the others need fresh `.deb` evidence entirely. + +## 19. Merge / Release NO-GO Conditions (unchanged, all still active) + +No merge/release while: a silent plaintext downgrade is possible; the +migration/session race is unresolved (§ 6 item 4/5 narrowed but did not +eliminate the theoretical migration-vs-write race — documented as an +accepted, bounded residual risk in the #337 thread reply, not a NO-GO by +itself since it fails safe-and-visible now); #310 material items remain +unreconciled (they do — § 9); #332/#333 lack packaged desktop evidence (they +do — § 10); native Rust/Tauri build evidence isn't tied to a final SHA (not +re-verified this session — check `gh run list` for the latest Tauri workflow +run against #336's current SHA before assuming this is still fine); or any +review thread was resolved merely because its anchor moved (this session +was careful about this — see § 13's method, but spot-check a sample if you +want independent confirmation). ## 20. Files / Symbols To Read First -1. `docs/session-handoff/CURRENT-HANDOFF.md` +1. `docs/session-handoff/CURRENT-HANDOFF.md` (this file) 2. `docs/PR-310-RECONCILIATION.md` 3. `docs/ISSUES-332-333-PERFORMANCE-LEDGER.md` -4. `services/storage/storageEncryptionService.ts` and journal/adapter modules -5. `src-tauri/src/lora.rs` and `services/lora/loraTrainingService.ts` -6. `components/settings/LocalAiDownloadProgress.tsx`, `services/localAiFacade.ts`, - `services/ai/inferenceProgressEmitter.ts` -7. settings slice/view/listener persistence paths -8. `pnpm-workspace.yaml`, `pnpm-lock.yaml`, `.npmrc`, `README.md` +4. `index.tsx` (`bootApp`), `hooks/useSettingsView.ts` (`handleLockSession`), + `features/settings/settingsSlice.ts` (`appearancePreset`) — this session's + three real-bug fixes on #335 +5. `services/storage/protectedStoreMigration.ts` (verification-shortfall → + recovery-required), `services/storage/storageEncryptionService.ts` + (narrowed migration-only pre-write guard) +6. `services/ai/aiInferenceCacheService.ts`, `services/ollamaService.ts`, + `components/settings/AiProviderCard.tsx` — this session's #337 fixes +7. `pnpm-workspace.yaml`, `pnpm-lock.yaml` — read § 8/§ 12 before touching ## 21. Safe First Commands For Next Agent -Run only diagnostic commands first: `git status --short`; `git status --branch ---short`; `git branch --show-current`; `git rev-parse HEAD`; `git log -n 15 ---oneline --decorate`; `git diff --stat`; `free -h`; `gh pr checks 335`; `gh pr -checks 336`; `gh pr checks 337`; `gh run view 31484800148`. - -Then fetch review threads through the approved GitHub review workflow and inspect -cloud failure logs before reproducing locally. - -## 22. Commands To Avoid Initially - -- Any `pnpm install` unless lock graph changed. -- Full local coverage/E2E/mutation/Lighthouse/Storybook/Tauri builds. -- Concurrent/background shells, broad script approval, `pnpm rebuild`. -- Reset/clean/force-push/rebase/retarget. -- A new review wave before current CodeAnt findings are corrected and understood. +```bash +git status --short +git branch --show-current +git log -n 15 --oneline --decorate +free -h +gh pr checks 335 +gh pr checks 336 +gh pr checks 337 +``` + +Then run the review-thread count queries in § 15, and read the CI logs for +any red job before reproducing anything locally. + +## 22. Commands To Avoid + +- `pnpm install` without a properly-resourced environment, or without + immediately reconciling every file touched (§ 8, § 12). +- Hand-editing `pnpm-workspace.yaml`/`pnpm-lock.yaml` override strings without + a follow-up install. +- Any full local coverage/E2E/mutation/Lighthouse/Storybook/Tauri build — + cloud CI only on this hardware. +- Resolving a review thread because its anchor moved, without re-verifying + the underlying concern against current code. +- `--admin` merges to route around a `mergeable_state` cache lag — re-poll + instead (see the project `CLAUDE.md`'s documented quirk). ## 23. Open Questions / Uncertainty -1. Exact current CodeAnt bug bodies for #335/#337 require comment fetch. -2. Does the in-progress multi-platform Tauri workflow succeed, and must final - #336 be redispatched? -3. Does every #310 store recover across interruption/quota/stale-client/ - export/import/multi-tab cases? Evidence remains incomplete. -4. Is #332 shared renderer/native work, WebView/Wayland, layout, or persistence? -5. Why does sepia reset in the reported package? No end-to-end reproduction. -6. Does #333 overlap reproduce across supported scaling/locales? No matrix yet. +1. Did #335's E2E suite actually go green on `0353364d`? (§ 15 — unresolved + at capture time.) +2. Is #336's native Tauri build evidence still tied to a SHA that matches its + current head (`ad4364ac`), or does it need a fresh dispatch? Not + re-verified this session. +3. Does #310's remaining 28 unresolved threads (of 317) contain anything + that changes the Option C strategy, or are they all already superseded by + #335/#337 work? Not investigated this session. +4. Is there a *second* place in the codebase (beyond `settingsSlice.ts` and + `idbProjectStore.ts`'s `normalizePersistedSettings`) that assumes + `appearancePreset`'s first-run default is `'default'` rather than + `'sepia'`? A targeted grep found none, but a full grep across + `components/`/`hooks/` for `appearancePreset` was not exhaustively cross- + checked against every consumer. +5. Should `index.tsx`'s `bootApp` be exported and given a real test harness? + Deferred this session (§ 7 item 4) — worth revisiting. ## 24. Handoff Integrity Checklist -- [x] Local state captured before edits; no state discarded. -- [x] Live main, stack PRs, #310, #332 and #333 queried. -- [x] SHA-bound CI/Tauri/pnpm/resource evidence captured. -- [x] Performance non-closure and #310 non-final state explicit. -- [x] Exact next queue, safe commands, and no-go conditions provided. -- [ ] Final handoff commit/push recorded after this document is committed. +- [x] Local state captured before writing this document; no state discarded. +- [x] Live PR stack, #310, #332/#333 state queried and reflected. +- [x] SHA-bound evidence captured for every claim above. +- [x] Performance non-closure and #310 non-final state kept explicit. +- [x] The one genuinely-deferred item (§ 8) documented with its exact fix and + why it's blocked, not silently dropped. +- [x] The layering mistake (§ 16) documented rather than hidden. +- [ ] Final confirmation that #335's CI is green on `0353364d` — pending at + capture time; next agent's first job is to check this (§ 15). diff --git a/docs/session-handoff/archive/CLAUDE-HANDOFF-20260811T111445Z.md b/docs/session-handoff/archive/CLAUDE-HANDOFF-20260811T111445Z.md new file mode 100644 index 00000000..8af997e4 --- /dev/null +++ b/docs/session-handoff/archive/CLAUDE-HANDOFF-20260811T111445Z.md @@ -0,0 +1,308 @@ +# WorldScript Studio — Current Agent Handoff + +## 1. Capture Metadata + +- Captured UTC: `2026-08-11T11:14:45Z`. +- Mode: emergency state freeze; no new implementation, install, rebase, reset, + review trigger, or heavy validation began after this boundary. +- Evidence labels: **LIVE FACT** = command/API evidence at capture; **HISTORICAL + FACT** = retained provenance; **UNVERIFIED** = no closure claim. + +## 2. Executive Summary + +The clean, pushed checkout is `feat/encryption-recovery-journal` at +`fefd9efc87f40c323c9b998014c57ae3a68dcf87`. The active stack remains #335 +(foundation) → #336 (desktop/AI) → #337 (recovery); `main` is +`804793aa0815a726935785639e4fb139af7c4b59`. + +Recent code establishes a fail-closed lifecycle/recovery direction, durable +journal work, and bounded Python/LoRA handling. This is focused code/test +evidence, not release closure. Legacy PR #310 remains open and must neither be +merged nor closed as superseded yet. + +Live blockers: #335 quality fails because four README i18n counts say `2869` +instead of `2876`; CodeAnt reports 3 bugs on #335 and 16 on #337. #336's +external checks pass, but the Tauri bundle job remains in progress against +`88016dde`, an ancestor of its final merge SHA. #332/#333 remain open and no +packaged `.deb` performance/persistence evidence exists. + +Host state is severely constrained: 442 MiB free RAM, 1.4 GiB swap used, two +CPUs at load 3.50/3.85/4.04, and 6.6 GiB disk free. Use cloud CI for heavy work. + +## 3. Exact Live Git State + +| Field | Value | Evidence | +| --- | --- | --- | +| Branch | `feat/encryption-recovery-journal` | LIVE FACT | +| Head | `fefd9efc87f40c323c9b998014c57ae3a68dcf87` | LIVE FACT | +| Upstream | `origin/feat/encryption-recovery-journal` | LIVE FACT | +| Tree | Clean; no staged, unstaged, untracked, or stash entries | LIVE FACT | +| Unpushed commits | None; head equals upstream | LIVE FACT | +| Origin | `https://github.com/qnbs/WorldScript-Studio.git` | LIVE FACT | +| Default branch | `main @ 804793aa0815a726935785639e4fb139af7c4b59` | LIVE FACT | + +`git fetch --prune` removed local tracking aliases `origin/pr-310` and +`origin/pr-311`; GitHub confirms PRs #310/#311 are still open. That was only a +tracking-ref cleanup. + +## 4. Live PR Stack / Branch Topology + +| PR | Responsibility | Head → base | State / size | Review-thread total | Merge state | +| --- | --- | --- | --- | --- | --- | +| #335 | encryption/settings/pnpm foundation | `fa3cd983` → `main@804793aa` | Open; 88 files, +860/-648, 3 commits | 33 | `BLOCKED` | +| #336 | Local AI/provider/Python/LoRA desktop reliability | `fd7ed7c1` → `#335@fa3cd983` | Open; 36 files, +1297/-148, 4 commits | 43 | `CLEAN` | +| #337 | recovery journal, adapters, #310 replacement | `fefd9efc` → `#336@fd7ed7c1` | Open; 73 files, +4042/-228, 18 commits | 54 | `UNSTABLE` | +| #310 | legacy secondary-store encryption | `27177ce5` → `main@804793aa` | Open; 35 files, +2958/-390, 9 commits | 317 | `BLOCKED` | + +Keep fixes at the earliest affected layer. Other open Dependabot PRs (#312–334) +and #311 are outside this remediation stack. + +## 5. Commits Since Previous Checkpoint + +No prior `docs/session-handoff/` file existed. The recent checkpoint is: + +| SHA | Message | Intent / validation | +| --- | --- | --- | +| `fefd9efc` | `docs: add desktop performance evidence ledger` | docs only; see stale-ref note in section 12 | +| `dda48b33` | `chore: merge desktop reliability foundation` | merges #336 into #337 | +| `fd7ed7c1` | `chore: merge encryption lifecycle foundation` | merges #335 into #336 | +| `fa3cd983` | `chore(deps): align pnpm v11 security policy` | normal pre-commit passed; cloud docs gate red | +| `88016dde` | `fix(tauri): bound Python probes and LoRA process lifecycle` | rustfmt pass; cloud Tauri build in progress | +| `997b2f6d` | `fix(storage): harden migration recovery protocol` | focused migration tests pass before merge | +| `c4b64f83` | `fix(deps): reconcile release-age lockfile` | earlier Vercel pass on its own SHA | +| `58a3a82c` | `feat(storage): add resumable secondary store adapters` | later journal work hardens/supersedes its lifecycle | + +Latest implementation commit is `88016dde`; latest documentation commit is +`fefd9efc`; latest journal implementation ancestor is `997b2f6d`. + +## 6. Completed Work + +- #337: target-key verifier, owner lease/checkpoints, adapter conflict checks, + typed missing snapshots, safe scene retention, and best-effort cache writes. +- Focused local storage evidence: protected-store migration 11/11 PASS and + journal tests 9/9 PASS on an ancestor of the current head. +- #336: bounded Python candidates, blocking work moved from async paths, + explicit training states, duplicate job prevention, termination confirmation. +- pnpm v11 policy is explicit; a frozen script-free install synchronized local + metadata and normal `lint-staged` pre-commit later passed. +- `docs/ISSUES-332-333-PERFORMANCE-LEDGER.md` now prevents code-only closure. + +## 7. Work In Progress + +1. #335 README docs gate and current review correction loop. +2. #336 native Tauri evidence plus current review normalization. +3. #337 CodeAnt/review correction, failure-injection proof, and #310 mapping. +4. #332/#333 packaged desktop/performance/persistence validation. + +## 8. Current Blockers + +| Priority | Blocker | Evidence | Resolution | +| --- | --- | --- | --- | +| P0 | #335 cloud quality red | Run `31485190552` | Update four README counts 2869 → 2876, push, get green quality/build | +| P0 | CodeAnt gates red | #335: 3 bugs; #337: 16 bugs | Current-head thread fetch, fix/test/reply/resolve, fresh quiescent review wave | +| P0 | #310 not terminally reconciled | Open; 317 threads; ledger issue R009 | Finish compliant behavior/test/review mapping before merge/closure decision | +| P1 | Native Rust evidence incomplete | Run `31484800148` in progress on `88016dde` | Monitor; fix/re-dispatch on final #336 SHA if necessary | +| P1 | #332/#333 unmeasured in packaged app | Ledger matrix pending | Candidate `.deb` performance and relaunch matrix | + +## 9. Review Finding Reconciliation + +Counts in section 4 are live **total** thread counts, not a claim that every +thread was current-head normalized during this handoff. + +| PR | Live review/check state | Handoff classification | Required action | +| --- | --- | --- | --- | +| #335 | CodeAnt Quality/SCR FAIL; CodeRabbit pending | `VALIDITY_UNNORMALIZED` | Fetch and classify 33 threads; fix at #335; rerun bot to zero actionable/zero unresolved | +| #336 | CodeAnt Quality PASS; SCR rating B/12 bugs; CodeRabbit skipped because base disables review | `VALIDITY_UNNORMALIZED` | Inspect CodeAnt comments and 43 threads; skipped is not reviewed-pass | +| #337 | CodeAnt Quality/SCR FAIL; CodeRabbit/Sourcery skipped | `VALIDITY_UNNORMALIZED` | Normalize 54 threads and 16 bugs after `fefd9efc`; fix/reply/resolve then fresh wave | +| #310 | DeepSource JS FAIL; 317 threads | `VALIDITY_UNNORMALIZED` | Reconcile every material legacy concern before disposition | + +Check first: IDB read rejection, target verifier/adapter races, snapshot absence, +scene retention, cache failure, Local-AI busy state, stale provider requests, +Python probing, and LoRA termination. Never resolve only because an anchor moved. + +## 10. PR #310 Reconciliation + +- Ledger: `docs/PR-310-RECONCILIATION.md`. +- Strategy: Option C — replace through #335 + #337 while preserving all material + behavior and useful test intent. +- Live PR: #310 at `27177ce549d4579f1fc9dfbc4630ebf0c2592f9b`; still open. +- Current ledger decision: **NO-GO — REQUIRES FURTHER REMEDIATION**. +- Rows R001–R015 exist. Fourteen use allowed dispositions; R009 says `REWRITE`, + which is not an allowed final category and must be normalized with a concrete + replacement test mapping. + +Do not merge #310 over #337. Closure as superseded requires all material rows +to use permitted dispositions plus recovery, store inventory, interruption, +export/import, stale-client/multi-tab, review, and test evidence. + +## 11. #332 / #333 Status + +| Workstream | State | Evidence | Closure classification | +| --- | --- | --- | --- | +| #332 `.deb` sluggish Settings | Open | No package/profile | `NOT_REPRODUCED_ENVIRONMENT_LIMITED` | +| #332 sepia persistence | Open | Default change only; no relaunch matrix | `ROOT_CAUSE_CONFIRMED_FIX_PENDING` | +| #333 Local-AI acquisition | Open | Code direction only; no terminal runtime proof | `FIXED_CODE_ONLY_AWAITING_PACKAGED_VERIFICATION` | +| #333 UI freezes/overlap | Open | No trace/zoom/RTL/package matrix | `NOT_REPRODUCED_ENVIRONMENT_LIMITED` | +| #333 Gemini/LM Studio | Open | #336 code hardening; packaged diagnostic unverified | `FIXED_CODE_ONLY_AWAITING_PACKAGED_VERIFICATION` | +| #333 Python/LoRA | Open | `88016dde`; native build in progress | `ROOT_CAUSE_CONFIRMED_FIX_PENDING` | + +Neither issue is eligible for closure. + +## 12. Performance / Responsiveness Status + +- Ledger: `docs/ISSUES-332-333-PERFORMANCE-LEDGER.md`. +- Amendment integration: `PARTIAL`; contract/ledger present, no runtime capture. +- Settings P50/P95, long tasks, React/layout/paint/invoke counts, package startup + and memory metrics are all pending. +- No `.deb` build/install/terminal launch/menu launch/Wayland/X11 test occurred. +- The ledger's #337 line says `dda48b33` "local merge pending push"; live is + `fefd9efc`. Correct the ledger before relying on it for a new run. + +**PERFORMANCE CLOSURE NOT YET VERIFIED.** Browser/Vercel/unit success cannot +close the packaged Tauri reports. + +## 13. pnpm / Supply-Chain / Vercel Status + +| Item | Value | +| --- | --- | +| Declared / active pnpm | `11.5.2` / `11.5.2` | +| Node | `v24.11.1` | +| `minimumReleaseAge` | `10080` minutes | +| `verifyDepsBeforeRun` | `error` | +| `strictDepBuilds` / `blockExoticSubdeps` | `true` / `true` | +| Dependency scripts in reconciliation install | Not run (`--ignore-scripts`) | + +The release-age incident was resolved by exact `ip-address@10.3.1`, not policy +weakening or broad script approval. Do not reinstall unless the lock graph +changes. If metadata rejects a normal hook, use one bounded +`CI=true pnpm install --frozen-lockfile --ignore-scripts`, inspect diff, then +stop; do not use `approve-builds`, `rebuild`, or broad allowlists. + +Vercel is pass for #335/#336/#337 current deployment contexts. It proves deploy +build only, not package/performance/full-CI closure. + +## 14. Local Resource Constraints + +| Metric | Capture value | +| --- | --- | +| RAM | 3.7 GiB total; 442 MiB free; 1.3 GiB available | +| Swap | 3.9 GiB total; 1.4 GiB used | +| CPUs/load | 2; 3.50/3.85/4.04 | +| Disk | 6.6 GiB free; 93% used | +| Expensive processes | None besides current Codex sandbox | +| Class | `SEVERELY_CONSTRAINED` | + +Use single-command local diagnostics/focused tests only; use cloud for clean +install, coverage, E2E, packaged builds, performance, and large matrices. + +## 15. Test / CI / Deployment Evidence + +| Check | SHA/scope | Place | Result | Note | +| --- | --- | --- | --- | --- | +| protected-store migration test | `997b2f6d` ancestor | Local | PASS 11/11 | focused | +| journal test | `997b2f6d` ancestor | Local | PASS 9/9 | focused | +| crypto-heavy storage batch | pre-final merge | Local | INCONCLUSIVE | two empty JUnit/no completion runs | +| LoRA rerun | after `88016dde` | Local | INCONCLUSIVE | mock fixed; rerun resource-inconclusive | +| `rustfmt` on `lora.rs` | `88016dde` | Local | PASS | formatting only | +| `cargo fmt --check` | `88016dde` | Local | FAIL pre-existing | unrelated drift; no broad rewrite | +| #335 Actions `31485190552` | `fa3cd983` | Cloud | FAIL | four README doc metrics; downstream skipped | +| #335 CodeAnt | `fa3cd983` | Cloud | FAIL | 3 bugs/rating C | +| #336 CodeAnt Q/SAST/SCA | `fd7ed7c1` | Cloud | PASS | SCR B/12 must be inspected | +| #336 CodeRabbit | `fd7ed7c1` | Cloud | SKIPPED | base disables review | +| Tauri run `31484800148` | `88016dde` | Cloud | IN PROGRESS | Ubuntu/macOS/Windows in build stage | +| #337 CodeAnt Q/SCR | `fefd9efc` | Cloud | FAIL | 16 bugs/rating C | +| #337 SAST/SCA/GitGuardian/Semgrep/Vercel | `fefd9efc` | Cloud | PASS | security/deploy only | +| #310 historical CI | `27177ce` | Cloud | MIXED | DeepSource JS fails; no merge proof | + +## 16. Known Failed Approaches / Do Not Repeat + +| Approach | Outcome / required change | +| --- | --- | +| Repeated broad pnpm resolution | Unsafe on this host; retry only after graph change, frozen and script-free | +| Full local coverage/E2E/mutation/Lighthouse/Tauri build | CI-only on this hardware | +| Crypto-heavy storage or LoRA test batch | Resource-inconclusive; use cloud or one isolated test when host recovers | +| Vercel/browser success as desktop proof | Invalid; use installed package matrix | +| Resolving stale review anchors | Prohibited; current-head validation first | + +## 17. Uncommitted / Unpushed State + +At capture start the tree was clean and all implementation work pushed. This +handoff and its archive are the only subsequent local changes until committed. +No stash, reset, clean, rebase, or force push occurred. + +## 18. Exact Next Actions + +1. **P0-1/#335:** update README lines 15, 400, 509, 711 from 2869 to 2876; + run `pnpm run docs:check` only if healthy; commit/push; require green Node + quality and downstream build on the new SHA. +2. **P0-2/#335:** fetch/classify all 33 threads and CodeAnt bugs against current + head; fix at #335, test, reply/resolve, then one fresh CodeAnt wave to zero. +3. **P0-3/#336:** monitor Tauri run `31484800148`; inspect/fix if failed; if + passed decide whether final `fd7ed7c1` needs a new native dispatch; normalize + 43 threads and CodeAnt SCR comments. +4. **P0-4/#337/#310:** normalize 54 #337 threads/16 CodeAnt bugs; fix at #337; + convert R009 to allowed disposition; complete store/recovery mapping before + any #310 merge/closure decision. +5. **P1-1/#332:** after CI-safe candidate, run installed `.deb` terminal/menu + performance and appearance relaunch matrix; record before/after measurements. +6. **P1-2/#333:** validate Local-AI progress/cancel/retry/busy terminality, LM + Studio/Python menu-vs-terminal, LoRA cancellation, and layout matrix in app. + +## 19. Merge / Release NO-GO Conditions + +No merge/release if protected writes downgrade, lifecycle/recovery is not +resumable, #310 mapping/reviews remain incomplete, #335/#337 quality is red, +native Rust is unbuilt, or #332/#333 lack packaged persistence/performance +evidence. Also block if Local-AI remains busy after terminal operations, LoRA +can survive cancel, or no package before/after evidence exists for a reproduced +slowdown. + +## 20. Files / Symbols To Read First + +1. `docs/session-handoff/CURRENT-HANDOFF.md` +2. `docs/PR-310-RECONCILIATION.md` +3. `docs/ISSUES-332-333-PERFORMANCE-LEDGER.md` +4. `services/storage/storageEncryptionService.ts` and journal/adapter modules +5. `src-tauri/src/lora.rs` and `services/lora/loraTrainingService.ts` +6. `components/settings/LocalAiDownloadProgress.tsx`, `services/localAiFacade.ts`, + `services/ai/inferenceProgressEmitter.ts` +7. settings slice/view/listener persistence paths +8. `pnpm-workspace.yaml`, `pnpm-lock.yaml`, `.npmrc`, `README.md` + +## 21. Safe First Commands For Next Agent + +Run only diagnostic commands first: `git status --short`; `git status --branch +--short`; `git branch --show-current`; `git rev-parse HEAD`; `git log -n 15 +--oneline --decorate`; `git diff --stat`; `free -h`; `gh pr checks 335`; `gh pr +checks 336`; `gh pr checks 337`; `gh run view 31484800148`. + +Then fetch review threads through the approved GitHub review workflow and inspect +cloud failure logs before reproducing locally. + +## 22. Commands To Avoid Initially + +- Any `pnpm install` unless lock graph changed. +- Full local coverage/E2E/mutation/Lighthouse/Storybook/Tauri builds. +- Concurrent/background shells, broad script approval, `pnpm rebuild`. +- Reset/clean/force-push/rebase/retarget. +- A new review wave before current CodeAnt findings are corrected and understood. + +## 23. Open Questions / Uncertainty + +1. Exact current CodeAnt bug bodies for #335/#337 require comment fetch. +2. Does the in-progress multi-platform Tauri workflow succeed, and must final + #336 be redispatched? +3. Does every #310 store recover across interruption/quota/stale-client/ + export/import/multi-tab cases? Evidence remains incomplete. +4. Is #332 shared renderer/native work, WebView/Wayland, layout, or persistence? +5. Why does sepia reset in the reported package? No end-to-end reproduction. +6. Does #333 overlap reproduce across supported scaling/locales? No matrix yet. + +## 24. Handoff Integrity Checklist + +- [x] Local state captured before edits; no state discarded. +- [x] Live main, stack PRs, #310, #332 and #333 queried. +- [x] SHA-bound CI/Tauri/pnpm/resource evidence captured. +- [x] Performance non-closure and #310 non-final state explicit. +- [x] Exact next queue, safe commands, and no-go conditions provided. +- [ ] Final handoff commit/push recorded after this document is committed. From be11482ce7ec3b75c16d26345b3e30af7d59e7da Mon Sep 17 00:00:00 2001 From: qnbs <155236708+qnbs@users.noreply.github.com> Date: Wed, 12 Aug 2026 00:19:08 +0200 Subject: [PATCH 54/78] fix(lora,settings): stale-run training race, stuck onboarding on cancel, stale model list on context switch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three confirmed race conditions from a fresh CodeAnt review wave: - loraThunks.ts: startTrainingThunk's catch classified a killed process's rejection using the CURRENT Redux currentRun.cancellationRequested, not the run that actually produced it. abort_lora_training awaits child-process exit before resolving, so a new training run can start before the killed run's own train_lora promise finally rejects — the catch would then wrongly archive the NEWER run as failed/aborted using the OLDER run's outcome. Now guards on currentRun.id matching the runId this invocation generated; a mismatch is a stale rejection and a no-op. - LoraOnboarding.tsx: cancelling the native Python file picker resolves with null, but handleSelectPython unconditionally bumped the request-generation guard before checking the result — invalidating the still-pending initial environment check without ever applying a replacement value, since the code only calls setEnv on a truthy result. The onboarding could get stuck showing "checking environment..." forever. The guard now only advances once there's an actual new result (success or error) to apply. - AiProviderCard.tsx: useConnectionContextReset invalidated in-flight tests/model-loads on a provider/endpoint/preset change but never cleared the already-rendered ollamaModels list from the previous context, so a user could select a model id that doesn't exist on the newly selected server until a fresh load happened to complete. Co-Authored-By: Claude Sonnet 5 --- components/lora/LoraOnboarding.tsx | 13 ++++--- components/settings/AiProviderCard.tsx | 6 +++ features/lora/loraThunks.ts | 4 ++ tests/unit/lora/LoraOnboarding.test.tsx | 26 +++++++++++++ tests/unit/lora/loraThunks.test.ts | 43 ++++++++++++++++++++- tests/unit/settings/AiProviderCard.test.tsx | 26 +++++++++++++ 6 files changed, 111 insertions(+), 7 deletions(-) diff --git a/components/lora/LoraOnboarding.tsx b/components/lora/LoraOnboarding.tsx index e1aa4fdd..a6dd5a13 100644 --- a/components/lora/LoraOnboarding.tsx +++ b/components/lora/LoraOnboarding.tsx @@ -104,14 +104,17 @@ export default React.memo(function LoraOnboarding({ onDismiss }: { onDismiss: () const handleSelectPython = async () => { if (!api) return; - const requestId = ++requestIdRef.current; setIsSelectingPython(true); try { const result = await api.selectPythonExecutable(); - if (requestIdRef.current !== requestId) return; - if (result) setEnv({ loaded: true, ...result }); + // QNBS-v3: a cancelled picker resolves with null and must not invalidate the still-pending + // initial check — only bump the generation guard once there's an actual new result to apply, + // otherwise a cancel could leave `env` stuck at its unloaded default forever. + if (!result) return; + requestIdRef.current += 1; + setEnv({ loaded: true, ...result }); } catch (error) { - if (requestIdRef.current !== requestId) return; + requestIdRef.current += 1; // QNBS-v3: Keep the failed manual selection visible instead of falsely reporting Python as absent. setEnv((current) => ({ ...current, @@ -119,7 +122,7 @@ export default React.memo(function LoraOnboarding({ onDismiss }: { onDismiss: () lastError: error instanceof Error ? error.message : 'python_selection_failed', })); } finally { - if (requestIdRef.current === requestId) setIsSelectingPython(false); + setIsSelectingPython(false); } }; diff --git a/components/settings/AiProviderCard.tsx b/components/settings/AiProviderCard.tsx index cbfe0f4a..8cd0c50d 100644 --- a/components/settings/AiProviderCard.tsx +++ b/components/settings/AiProviderCard.tsx @@ -31,6 +31,7 @@ function useConnectionContextReset( setTestStatus: (status: 'idle') => void, setTestError: (error: string) => void, setIsLoadingModels: (loading: false) => void, + setOllamaModels: (models: string[]) => void, provider: AIProvider, ollamaBaseUrl: string, localBackendPreset: LocalBackendPreset, @@ -50,11 +51,15 @@ function useConnectionContextReset( // reset (its own stale-check skips setIsLoadingModels(false) too) and the button stays // permanently disabled until another load happens to be triggered. setIsLoadingModels(false); + // QNBS-v3: without this, the previous backend's model buttons stay rendered until a new load + // completes — a user can select a model id that doesn't exist on the newly selected server. + setOllamaModels([]); }, [ testRequestIdRef, setTestStatus, setTestError, setIsLoadingModels, + setOllamaModels, provider, ollamaBaseUrl, localBackendPreset, @@ -144,6 +149,7 @@ export const AiProviderCard: FC = ({ setTestStatus, setTestError, setIsLoadingModels, + setOllamaModels, provider, ollamaBaseUrl, advancedAi.localBackendPreset, diff --git a/features/lora/loraThunks.ts b/features/lora/loraThunks.ts index 842a514e..60b61d33 100644 --- a/features/lora/loraThunks.ts +++ b/features/lora/loraThunks.ts @@ -200,6 +200,10 @@ export const startTrainingThunk = createAsyncThunk< dispatch(adapterSaved(meta)); dispatch(trainingCompleted({ outputAdapterId: adapterId })); } catch (err) { + // QNBS-v3: a killed child's train_lora rejection can arrive after a newer run has already + // started (abort_lora_training waits for exit; currentRun can move on before this settles) — + // dispatching against a stale runId would wrongly terminate the newer run instead of a no-op. + if (getState().lora.currentRun?.id !== runId) return; // QNBS-v3: abort_lora_training waits for the killed child to exit before resolving, so the // train_lora invoke it just killed can reject here first — without this check, a successful // user cancellation would archive as a training failure instead of an abort (see diff --git a/tests/unit/lora/LoraOnboarding.test.tsx b/tests/unit/lora/LoraOnboarding.test.tsx index ad9ada00..9b0461e5 100644 --- a/tests/unit/lora/LoraOnboarding.test.tsx +++ b/tests/unit/lora/LoraOnboarding.test.tsx @@ -92,6 +92,32 @@ describe('LoraOnboarding', () => { expect(screen.queryByText(/3\.9\.0/)).not.toBeInTheDocument(); }); + it('does not get stuck on "checking" forever when the file picker is cancelled mid-flight', async () => { + // QNBS-v3: a cancelled picker resolves with null — it must not invalidate the still-pending + // initial check, or the component never leaves the loading state. + const initial = deferred(); + mockCheckTrainingEnvironment.mockReturnValue(initial.promise); + mockSelectPythonExecutable.mockResolvedValue(null); + + render(); + await waitFor(() => expect(mockCheckTrainingEnvironment).toHaveBeenCalled()); + expect(screen.getByText('lora.onboarding.checking')).toBeInTheDocument(); + + const user = userEvent.setup(); + await user.click(screen.getByRole('button', { name: 'lora.onboarding.selectPython' })); + await waitFor(() => + expect(screen.getByRole('button', { name: 'lora.onboarding.selectPython' })).toBeEnabled(), + ); + // Still checking — the cancelled picker applied no result. + expect(screen.getByText('lora.onboarding.checking')).toBeInTheDocument(); + + await act(async () => { + initial.resolve({ ...baseEnv, pythonAvailable: true, pythonVersion: '3.12.1' }); + await initial.promise; + }); + await waitFor(() => expect(screen.getByText(/3\.12\.1/)).toBeInTheDocument()); + }); + it('surfaces a translated native error even when pythonAvailable is true', async () => { mockCheckTrainingEnvironment.mockResolvedValue({ ...baseEnv, diff --git a/tests/unit/lora/loraThunks.test.ts b/tests/unit/lora/loraThunks.test.ts index bae72a60..9f56c9f5 100644 --- a/tests/unit/lora/loraThunks.test.ts +++ b/tests/unit/lora/loraThunks.test.ts @@ -95,6 +95,22 @@ function makeGetState(loraAdapters: unknown[] = [], currentRun: unknown = null) return () => ({ lora: { adapters: loraAdapters, currentRun } }); } +// QNBS-v3: startTrainingThunk generates its own runId via uuid() (not mocked in this file, so it's +// genuinely random) — a getState mock that echoes the id from the dispatched trainingStarted +// payload lets a test assert against the run this specific thunk invocation actually started. +function makeGetStateFollowingDispatch( + dispatch: ReturnType, + currentRunOverrides: Record = {}, +) { + return () => { + const started = dispatch.mock.calls.find( + (c: unknown[]) => (c[0] as { type?: string } | undefined)?.type === 'lora/trainingStarted', + ); + const id = (started?.[0] as { payload?: { id?: string } } | undefined)?.payload?.id; + return { lora: { adapters: [], currentRun: id ? { id, ...currentRunOverrides } : null } }; + }; +} + // QNBS-v3: RTK thunk test helper. ThunkFn uses `any` for dispatch/getState to satisfy // RTK's contravariant ThunkDispatch param — only call site, mocked in tests. // biome-ignore lint/suspicious/noExplicitAny: RTK thunk test helper — necessary for dispatch/getState contravariance @@ -329,7 +345,8 @@ describe('startTrainingThunk', () => { it('dispatches trainingFailed on service error', async () => { mockStartTraining.mockRejectedValue(new Error('GPU out of memory')); const dispatch = makeDispatch(); - await run(startTrainingThunk(trainConfig), dispatch); + const getState = makeGetStateFollowingDispatch(dispatch); + await run(startTrainingThunk(trainConfig), dispatch, getState); const failed = dispatch.mock.calls.find((c) => c[0]?.type === 'lora/trainingFailed'); expect(failed![0].payload).toBe('GPU out of memory'); }); @@ -346,13 +363,35 @@ describe('startTrainingThunk', () => { // rejection must be classified as an abort, not a failure, when cancellationRequested is set. mockStartTraining.mockRejectedValue(new Error('training_cancel_not_confirmed')); const dispatch = makeDispatch(); - const getState = makeGetState([], { cancellationRequested: true }); + const getState = makeGetStateFollowingDispatch(dispatch, { cancellationRequested: true }); await run(startTrainingThunk(trainConfig), dispatch, getState); const aborted = dispatch.mock.calls.find((c) => c[0]?.type === 'lora/trainingAborted'); const failed = dispatch.mock.calls.find((c) => c[0]?.type === 'lora/trainingFailed'); expect(aborted).toBeDefined(); expect(failed).toBeUndefined(); }); + + it('does not dispatch a terminal action when a newer run has already superseded this one', async () => { + // QNBS-v3: abort_lora_training awaits child-process exit before resolving, so a killed + // train_lora invoke can reject after a NEW run has already started — currentRun.id no longer + // matches this invocation's own runId, and dispatching against it would wrongly terminate the + // newer run instead of being a no-op for this stale one. + mockStartTraining.mockRejectedValue( + new Error('stale rejection from an already-superseded run'), + ); + const dispatch = makeDispatch(); + const getState = () => ({ + lora: { + adapters: [], + currentRun: { id: 'a-different-newer-run-id', cancellationRequested: false }, + }, + }); + await run(startTrainingThunk(trainConfig), dispatch, getState); + const aborted = dispatch.mock.calls.find((c) => c[0]?.type === 'lora/trainingAborted'); + const failed = dispatch.mock.calls.find((c) => c[0]?.type === 'lora/trainingFailed'); + expect(aborted).toBeUndefined(); + expect(failed).toBeUndefined(); + }); }); // --------------------------------------------------------------------------- diff --git a/tests/unit/settings/AiProviderCard.test.tsx b/tests/unit/settings/AiProviderCard.test.tsx index 31bbd880..c816dada 100644 --- a/tests/unit/settings/AiProviderCard.test.tsx +++ b/tests/unit/settings/AiProviderCard.test.tsx @@ -692,6 +692,32 @@ describe('AiProviderCard — connection-context invalidation (no remount)', () = expect(screen.queryByText('stale-model-from-old-server')).toBeNull(); }); + it('clears an already-loaded model list when the endpoint/preset context changes', async () => { + // QNBS-v3: distinct from the stale in-flight case above — this is a PREVIOUSLY SUCCESSFUL load + // whose rendered buttons must not survive a context switch, or a user could select a model id + // that doesn't exist on the newly selected server. + setDesktopRuntime(true); + vi.mocked(listLocalBackendModels).mockResolvedValueOnce(['old-server-model']); + const { rerender } = render( + , + ); + await userEvent.setup().click(screen.getByRole('button', { name: 'settings.ai.loadModels' })); + await waitFor(() => expect(screen.getByText('old-server-model')).toBeInTheDocument()); + + rerender( + , + ); + expect(screen.queryByText('old-server-model')).toBeNull(); + }); + it('preserves unsaved key input across a provider switch (no more key={provider} remount)', async () => { const user = userEvent.setup(); const { rerender } = render( From d536649d6b1ddca4765634b5a44a4d05bb5cad70 Mon Sep 17 00:00:00 2001 From: qnbs <155236708+qnbs@users.noreply.github.com> Date: Wed, 12 Aug 2026 00:32:29 +0200 Subject: [PATCH 55/78] fix(settings,ai): decouple URL edits from protocol preset; guard preload progress against superseded attempts AiProviderCard.tsx: editing the "Ollama Server URL" field unconditionally reassigned localBackendPreset to 'custom', which isOpenAiCompatibleLocalPreset always routes through the OpenAI-compatible /v1/chat/completions protocol. A native-Ollama user just changing host/port (e.g. pointing at a LAN server) had their protocol silently switched and every completion started failing. The URL input now only updates ollamaBaseUrl; protocol selection stays explicit via the preset dropdown. localAiFacade.ts: preloadLocalModel's own inferenceProgressEmitter calls, and the ones inside generateLocalText gated by reportToGlobalProgress, ran unconditionally. retryLastPreload() starting a new attempt while an older, cancelled/superseded attempt's generateLocalText call is still settling could let that stale attempt's reportWebLlmReady/reportWebLlmError/reset overwrite the newer attempt's modal state after the fact. Extended the existing activePreloadAbort identity-guard pattern (already used to protect the cancel hook) to every progress-report call site via a new isCurrentAttempt option. Co-Authored-By: Claude Sonnet 5 --- components/settings/AiProviderCard.tsx | 12 +++--- services/localAiFacade.ts | 44 +++++++++++++++------ tests/unit/localAiFacade.test.ts | 29 ++++++++++++++ tests/unit/settings/AiProviderCard.test.tsx | 18 +++++++++ 4 files changed, 84 insertions(+), 19 deletions(-) diff --git a/components/settings/AiProviderCard.tsx b/components/settings/AiProviderCard.tsx index 8cd0c50d..37e1f26a 100644 --- a/components/settings/AiProviderCard.tsx +++ b/components/settings/AiProviderCard.tsx @@ -573,12 +573,12 @@ export const AiProviderCard: FC = ({ id="ollama-server-url" placeholder="http://localhost:11434" value={ollamaBaseUrl} - onChange={(e) => - onAdvancedAiPatch({ - ollamaBaseUrl: e.target.value, - localBackendPreset: 'custom', - }) - } + // QNBS-v3: editing the URL must not silently reassign the protocol — a native-Ollama + // user pointing at a non-default host/port (e.g. a LAN server) previously had their + // preset force-switched to 'custom', which routes every request through the + // OpenAI-compatible protocol instead of native Ollama and breaks all completions. + // Protocol selection stays explicit via the preset dropdown above. + onChange={(e) => onAdvancedAiPatch({ ollamaBaseUrl: e.target.value })} className="flex-1 font-mono text-sm" />