Skip to content

Studio 2.0 - #99

Draft
senigami wants to merge 119 commits into
mainfrom
studio-2.0
Draft

Studio 2.0#99
senigami wants to merge 119 commits into
mainfrom
studio-2.0

Conversation

@senigami

@senigami senigami commented Apr 9, 2026

Copy link
Copy Markdown
Owner

No description provided.

senigami and others added 11 commits April 8, 2026 10:34
* stubbs

* more detailed connections

* robust scaffolding

* add missing functions and cleanup

* fixing issues before they happen

* fix some other issues

* added dependency contracts

* added missing scaffolding

* pr fix and better UX navigation

* add manifest version

* phase 1 complete

* final final commit v2
* phase 2

* Update test_domain_contracts.py

* Update phase_2_domain_contracts.md

* Update __init__.py

* Fix for build

* Update phase_2_domain_contracts.md
…93)

* engine contract, registry, registry, validator

* fixing bugs

* update plan

* next part

* VoxtralVoiceEngine

* xtts

* first-pass bridge-backed synthesis contract

* voice_build and voice_test now opt into the new bridge-backed synthesis path behind USE_V2_ENGINE_BRIDGE

* Path.replace() -> shutil.move()

* enhance importance of websocket communication
@senigami senigami linked an issue Apr 9, 2026 that may be closed by this pull request
* normalized websocket event contract

* Preserve job status in normalized websocket updates

* Implement progress service throttling and ETA safeguards

* Implement revision-safe artifact reconciliation

* Allow explicit progress resets in ProgressService

* Harden progress timing and stale websocket guards

* Preserve live job metadata across hydration and websocket updates

* Align progress ETA controls with V2 update semantics

* `authoritativeFloor` is only honored in some paths right now. The main `

* Simplify predictive progress handoff and staged test inputs

* Fix predictive bar ETA anchoring and floor handling

* Stabilize predictive progress floor handling

* Extract predictive progress bar helpers

* Refactor predictive progress bar into engine and debug helpers

* Reorganize agent rules into task-focused routers
#101)

* Expose raw smoothing budget in progress bar debug

* Expand phase plans for TTS server and settings UX

* Add TTS Server feature flag and bridge routing

* Update test_tts_client.py

* Add explicit Studio boot wiring and orchestrator flag

* Complete Phase 5 orchestrator foundation and TTS server routing

* Implement core Studio 2.0 orchestration task bodies

* Update registry.py
Comment thread app/tts_server/server.py Fixed
@senigami senigami added the Epic Larger Story containing smaller stories required for full completion. label Apr 18, 2026
@senigami senigami self-assigned this Apr 18, 2026
…avigation realignment (#103)

* Add named feature flag constants and helper tests

* Document Phase 6 handoff and frontend cutover assumptions

* Clarify Phase 6 queue and header cutover plan

* Introduce live-jobs store for frontend queue state
Implement hydration coordinator for queue snapshots
Wire queue and header badge to shared sync boundary
Fix duplicate hydration pull on initial websocket connect
Normalize overlay pruning timestamps to prevent stale state regressions

* Mark Phase 6 patch 1 checks complete

* Fix backend ETA projection for live progress events

* Harden startup ETA and speed metric training

* Checkpoint progress ETA history storage

* Move render performance history to database

* Add collaboration rules for Codex

Add .agents/rules/collaboration-with-codex.md: a new agent rule document defining expectations for working with Codex. It instructs implementers to follow prompts exactly, avoid unrelated edits, run `git status --short` and inspect diffs before handoff, clean up transient/scratch files, and never delete tracked files unless asked. It also documents state handling (Durable Studio 2.0 in SQLite; `state.json` as a migration shim), UI component responsibilities, job `status` as authoritative, ETA training restrictions (only successful persisted `done` jobs), test requirements when changing behavior (update product-rule tests and do not weaken tests), and a final handoff checklist (changed files, tests run and results, remaining risks, and artifacts).

* Fix progress hook test expectations

* Update .gitignore

* Stabilize shell hydration state in the app shell

* Wire project subnavigation into route shell

* Harden project route transitions

* Harden queue hydration source tracking

* Cut over queue route boundary

* Mark Phase 6 checklist complete

* Polish Phase 6 cleanup seams

* Refine project chapter breadcrumb navigation

* Remove duplicate chapter dropdown option

* Remove tracked frontend scratch artifacts

* Move performance retention to startup

* Improve worker fallback observability

* Remove debug logs and name layout z-index tiers
@senigami senigami added this to the Studio 2.0 milestone Apr 23, 2026
* Begin Phase 7 settings route foundation

* Phase 7 production blocks and export actions

* Add batch generation actions to production editor

* Add render batch recovery actions

* Add Script view and export polish

* Add Script range selection and split assignment

* Place it in the ScriptView playback polish slice, not the source-text or

* Highlight active playback batch in Script view

* Tighten Script playback highlight spacing

* Fix pre-push lint fallout

* Add TTS Engines settings shell

* Clarify TTS Engines install action status

* Restore API settings tab

* Mark API settings tab complete

* Add About tab diagnostics

* Fix settings deep links and Voxtral controls

* Fix settings deep links and Voxtral controls

* Fix Voxtral settings metadata and reload caching

* Expose plugin-owned engine schema metadata

* Remove legacy settings tray

* Finalize plugin enablement contract

* Fix voice engine filter labels

* Fix cloud voice control fallbacks

* Harden voice plugin hook contract

* Bump python-multipart to patched version

* Tighten voice gating and disabled voice tabs

* Mark phase 7 ready for handoff

* Fix settings test expectations
Comment thread app/api/routers/chapters.py Fixed
Comment thread app/api/routers/engines.py Fixed
* Move queue into companion drawer

* Add project snapshot and export manifest foundation

* Fix plugin template whitespace

* Add dated project backup bundle contract

* Add downloadable project backup archive

* Align backup archive format with issue 19

* Add stored project backup history

* Expose project assembly and backup tabs

* Finalize project tabs and backup management

* Unify inline editing across project tabs

* Unify chapter assignment feedback and cleanup flow

* Restore default selection and paragraph click targets

* Expand book paragraph hit targets

* Fix chapter audio export and voice readiness handling

* Tighten chapter header wrap and clarify stability mode

* Fix voice storage paths and rebuild status

* Clarify voice rebuild status and sample playback

* Fix stale voice and settings test expectations

* Add portable voice bundle export and import

* Make voice export/import more discoverable

* Update phase_8_cleanup_and_legacy_removal.md

* Harden chapter asset resolution

@github-advanced-security github-advanced-security AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

CodeQL found more than 20 potential problems in the proposed changes. Check the Files changed tab for more details.

* Fix voice profile resolution for narrator defaults and renames

* Harden filesystem path joins across chapter and project APIs

* fix push

* Update projects.py

* Harden backend path handling for CodeQL

* fix tests

* Harden path containment in project and chapter helpers

* The lesson is to keep the safety boundary explicit and local:
- use `for

* Update chapters.py

* Harden path containment and sanitize error responses

* fix tests

* That rule is not currently in `.agent/rules.md`, so it should be added:

* Refactor settings route into focused panels

* Refactor ChapterEditor into focused hooks and components

* Split chapter subcomponent tests into focused files

* Split voice router into focused modules

* Refactor chapter compatibility helpers into smaller modules

* Split chapter and job modules into focused files

* Split chapter and voice tests into focused files

* Split Voices tab into focused modules

* Split engine registry into smaller modules

* Refactor engines into modular utility packages

* Split speaker refinement tests into focused modules

* Refactor large test suites into modular files

* Fix manual memoization lint error in useVoicesData

* Fix fixture shadowing lint errors and pre-existing react-hooks/refs errors

* Fix lint issues in voices and XTTS utils

* Fix pytest test package imports

* Restore legacy state and xtts facade exports

* Fix state file patching across compatibility modules

* trial 1

* trial 2

* trial 3

* Update migration.py

* Update test_storage_normalization.py

* Update migration.py

* Update migration.py

* Harden project file paths for CodeQL

* fixed for push

* Update config.py

* Update config.py

* fix tests

* Delete codeql-annotations.json

* Update speakers.py

* trial 4

* Harden project migration path containment

* Update migration.py

* Refactor speaker path containment with realpath checks

* trial 5

* trial 6

* Update bundles.py

* Harden chapter path validation and update security tests

* trial 7

* Harden path containment and update chapter tests

* Update chapters_cleanup.py

* Harden voice storage version path checks

* Harden voice bundle root resolution and update path lessons

* trial 8

* trial 9

* Potential fix for pull request finding 'CodeQL / Uncontrolled data used in path expression'

Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>

* Potential fix for pull request finding 'CodeQL / Information exposure through an exception'

Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>

* Potential fix for pull request finding 'CodeQL / Uncontrolled data used in path expression'

Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>

* Potential fix for pull request finding 'CodeQL / Uncontrolled data used in path expression'

Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>

* Potential fix for pull request finding 'CodeQL / Information exposure through an exception'

Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>

* Potential fix for pull request finding 'CodeQL / Uncontrolled data used in path expression'

Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>

* Delete codeql-annotations.json

* Update speaker.py

* Update voices_bundles.py

* Create codeql.yml

---------

Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
Comment thread app/jobs/speaker.py Fixed
Comment thread app/jobs/speaker.py Fixed
Comment thread app/jobs/speaker.py Fixed
Comment thread app/jobs/speaker.py Fixed
Comment thread app/jobs/speaker.py Fixed
Comment thread app/api/routers/voices_bundles.py Fixed
* Add plugin security docs and guided install PoC

* Add public Studio TTS API and gateway controls

* Document Studio TTS gateway integration

* Add plugin isolation tests and mark phase 9 complete

* Promote TTS Server to default runtime

* Promote Studio 2.0 defaults and runtime status

* Harden Phase 10 retry fallback handling

* Add engine test endpoint and tighten voice profile scanning

* Handle engine API outages and voice migration

* It’s fixed enough to proceed with the plan of attack. The last patch is,

* Harden v2 voice profile discovery and startup readiness

* Persist engine test audio and expose replay metadata

* Clarify XTTS settings and make toast actions optional

* Fix XTTS docs and orchestration imports

* Update tts_api.py

* Use a short delayed startup overlay reveal and keep threading startup it

* Refine voice assignment dropdown rendering

* Add character reassignment to script view

* Relax startup gate and surface engine diagnostics

* Improve Voxtral settings and diagnostics handling

* Load Voxtral schema from plugin settings

* Consolidate Voxtral enablement state and remove stale notes

* Move autosave notice to General settings

* Stop sentence dropdown clicks from triggering paint assignment

* Document Phase 11 v2-only runtime cleanup

* Bump Studio to 2.0.0 and expand phase cleanup plans

* Mark phase 10 cleanup checklist complete

* Refine runtime status text and privacy notice metadata

* Update voice tests for motion and engine props

* Update phase 10 cleanup tracking notes

* Fix CI blockers in Phase 10 cleanup

* Stabilize backend tests for default TTS runtime

* Stabilize backend CI for v2-default runtime
* Document Phase 10 handoff and Phase 11 audit start

* Remove silent TTS Server fallback

* Trim wasteful legacy-path tests

* Remove legacy bridge quarantine tests

* Consolidate built-in engine metadata

* Finish Phase 11 audit cleanup

* Remove legacy voice routing flags

* Remove v2 bridge feature flag

* Remove unused legacy bridge shim

* Route render handlers through voice bridge

* Fix bridge handler error logging

* Trim bridge helper whitespace

* Add plugin behavior metadata foundation

* Migrate voice action policy to plugin behavior metadata

* Route orchestration through engine behavior

* Fix behavior cutover lint issues

* Move legacy engine implementations behind adapters

* Generalize legacy dashboard defaults

* Realign Phase 11 cleanup plan

* Record Phase 11 hard cutover direction

* Document declared plugin hook contract

* Generalize plugin hook contract and cleanup docs

* Genericize voice UI and job behavior metadata

* Finish state and metrics hard cutover

* Fix checkpoint blockers in metrics slice

* Fix analysis chunk limit response

* Remove legacy local bridge and cleanup tests

* Record deferred tts namespace rename

* Finalize frontend engine label cleanup

* Purge final dead legacy dashboard tests

* Record engine bundle test colocation

* Keep plugin engines self-contained

* Remove residual legacy config and dashboard

* Fix worker bridge output tests

* Fix worker bridge output tests

* Record final engine bundle verification

* Polish voice contracts and job engine typing

* Generalize engine boundary comments and contracts

* Move v1 engine cleanup into migration and generic defaults

* Remove legacy runtime logic and finalize v2 cleanup

* Enforce v2 speaker resolution and add profile tests

* Route assembly and voice jobs through orchestrator

* Remove legacy app.jobs worker runtime

* Fix voice verification and queue visibility regressions

* Add dynamic dev port selection for backend and frontend

* Track render start separately from preparing

* Add task cancellation and restore predictive progress

* Skip ETA projection for post-synthesis progress updates

* Harden chapter audio routing and legacy path compatibility

* Update test_studio_task_progress.py

* Remove remaining V1 runtime fallback paths

* Update test_voice_security.py

* fix tests

* Remove V1 fallbacks and move migration to boot

* phase 11 plan organize

* Disambiguate audiobook assembly cache paths

* Add manifest-driven engine test samples

* Remove per-project migration from project listing

* Fix blank engine enablement message in queue validation

* Fix duplicate React keys in chapter and settings views

* Honor plugin test_text for engine verification

* Improve TTS startup readiness and runtime cleanup

* Fix grouped render progress and chapter audio routing

* Serialize bridge-backed synthesis jobs

* Record render stats from orchestrator completions

* Refresh queue and segment state on live updates

* Restore pending render cues in ScriptView

* Add dev load timing probes

* Instrument project load timing

* Remove project migration from reads

* Optimize chapter segment loading

* Add Playwright settings navigation coverage

* Smooth chapter render text progress from predictive bar

* Propagate render block progress to script text

* Add model-aware render speed calibration

* Display plugin computer speed as CPS

* Add reset for computed engine speed

* Add hard reset for engine speed baseline

* Fix enqueue single API test

* Exclude Playwright specs from Vitest

* Update AboutSettingsPanel.tsx

* Refactor TTS plugins to canonical mini-repo layout

* refactor: migrate legacy frontend components to the new pages directory structure and implement comprehensive unit test coverage.

* docs: add version 2.0.0 and 1.8.5 release highlights to changelog

* refactor: reorganize project and settings components into dedicated feature directories for improved project structure

* Remove obsolete cleanup code

* Update bridge.py

* Keep engine test artifacts inside plugin folders

* Make Voices toolbar responsive

* Enhance agent rules with review and verification guidance

* Normalize engine test timestamps and Voxtral reference lookup

* Codify project structure rules

* Remove frontend XTTS fallback assumptions

* Remove last jobs shim compatibility stub

* Remove backend jobs queue shim cleanup

* Remove obsolete enqueue-single API route

* Harden backend behavior helpers with manifest progress parsing

* Switch chunking to engine-specific text limits

* plugin-declared test samples for voice assets

* Harden public project and voice asset routes

* Decommission stale V1 storage utility

* Audit uploads legacy cover compatibility

* Close behavior and text utility cleanup

* Relocate baseline CPS fallback to behavior helpers

* Move XTTS requirements into plugin

* Move XTTS launcher conflict checks into plugin

* Remove hardcoded XTTS-v2 copy from frontend

* Close phase 11 and plan phase 12 polish

* remove accidental files
* Expand Phase 12 backlog and verification gates

* Remove app persistence imports from Voxtral plugin core

* Localize plugin core dependencies for portability

* Refine chapter VCR playback controls

* Add press-and-hold skim controls to chapter playback

* Add Project Library sorting, list view, and created dates

* Retire legacy jobs API and complete master agnostic conversion

* Refine memory and handoff workflow

* Fix storage and registry lint regressions

* Move jobs hydration to WebSockets

* Document standalone CLI usage for XTTS and Voxtral

* Add developer harness links to plugin READMEs

* Add plugin developer mode and engine logos

* Document multilingual voice languages and disable dev mode

* Tighten plugin dev mode scenario safety

* Validate TTS plugin manifest contracts

* Surface developer plugin diagnostics safely

* Improve engine dev panel diagnostics

* Validate plugin dev scenarios

* Harden plugin dependency install refresh

* Finish plugin lifecycle and compatibility hardening

* Support per-voice plugin settings save flow

* Harden Chapter Editor playback controls

* Remove obsolete Chapter Editor tabs

* Remove remaining Chapter Editor production tab

* Prune production block infrastructure

* Fix API test syntax error

* Fix ChapterEditor queue test drift

* Refine Chapter Editor toolbar order

* Update Phase 12 cleanup boards

* Show queue output metadata

* Sync Phase 12 audit boards

* Mark TTS plugin zip flows complete

* Retire legacy jobs API surface

* Allow synthesis during pending verification

* Fix chapter audio finalization fallback

* Surface queue failure reasons

* Sync Memory for queue failure reasons

* attempted fix

* Reduce websocket chatter for job status updates

* Trace websocket sources and tighten queue scope

* Add websocket classification for queue routing

* Move websocket debug logging into listeners

* Restore xtts studio handler logger

* checkpoint before full "finalizing" removal

* not perfect but better queue

* startup working

* checkpoint but display still not as good as previous commit

* Trim redundant job_updated websocket broadcasts

* Skip redundant job_updated broadcasts during synthesis

* Trim redundant startup websocket broadcasts

* just before creating a central consumer

* Prevent inferred voice engines from persisting to disk

* Enforce explicit voice engine resolution across backend

* Remove last voice-engine fallback defaults

* Document the live event stream contract

* Add live event stream contract and schema module

* Flatten job update payloads in live event normalization

* Wire live output to the live event audit store

* Wire TTS diagnostics to live audit logs

* Route job events through live topic subscriptions

* Refactor queue handling and update tests

* Document live output subscriber ownership fix

* Refactor live output filtering to use consumer registry

* Document the studio event broadcaster contract

* Implement phase 1 studio event broadcaster helpers

* Add websocket facade for canonical studio events

* Refine phase handoff and memory workflow

* Route live events through new topic handlers

* Emit canonical queue events for websocket broadcasts

* Normalize voice test progress broadcast envelope

* Emit canonical studio events for TTS logs

* Migrate project updates to canonical lifecycle events

* Migrate segment progress to canonical studio events

* Remove frontend websocket compatibility shims

* Audit and correct studio event progress and log contracts

* Restrict queue sync to authoritative queue items

* Fix completed group rendering in Chapter Editor

* Fix live progress regression handling in queue and chapter jobs

* Stabilize queue ETA and retention debug visibility

* Refine queue retention and ETA diagnostics

* Fix ChapterEditor segment progress source and reset state

* Update test_websocket_broadcast.py

* Stabilize segment progress handoff at segment boundaries

* Refactor queue cleanup and update tests

* Remove synthetic 99 percent segment completion event

* Add confidence weighting to chapter editor progress bars

* Add confidence to live output and stabilize chapter progress

* Fix segment start seeding and preparing guard

* Unify live queue merge logic and block stale progress

* Promote segment start to processing for ChapterEditor bar

* Standardize runtime on Python 3.11

* Add job lifecycle live events

* feat: implement live event subscription routing for chapter and voice test states with improved queue snapshot persistence

* Fix progress rollback and segment source selection

* Disable startup verification synthesis and require verified synth

* Checkpoint ETA calibration rebuild

* Checkpoint ETA checklist corrections

* Checkpoint synthesis metrics path audit

* Fix ChapterEditor batch progress mapping

* Fix XTTS render timing handoff

* Fix render timing and ETA calibration

* Add ETA timestamps and stabilize queue countdown source selection

* Smooth predictive ETA during lane migrations

* `confidence` is missing from that debug projection, not necessarily from

* Normalize canonical event payloads and confidence fields

* Restore computed CPS display in engine settings

* Expose calibration summary on engine cards

* Add structured timing contracts for synthesis results

* Expose calibration confidence in engine registry

* Drop legacy make_mp3 from render performance samples

* Fix XTTS sample counts from render chunks

* Refine phase handoff and memory tracking

* Move voice-test queue schema into docs

* Align voice sample start timing with real synthesis markers

* Fix terminal parameter retention in done transition

* Fix pre-push whitespace warnings

* Update sample progress test contract
…t reserve (#203)

reserve_task_resources and release_task_resources each read
ENGINE_CLASS_ADMISSION independently. If the toggle moved while a task was
in flight, release took the other branch and the engine-class, engine-id and
global cap slots were never returned. release() is idempotent, so nothing
errored and nothing logged; the slots just went missing until a restart.

Reserve now records the mode it used in the claims dict and release branches
on that, so the two paths cannot disagree. Where the claims dict never went
through reserve (the recovery path rebuilds one), release covers both paths,
which is safe because releasing a slot a task does not hold is a no-op.

Closes #199
…A+B) (#206)

* test(xtts): add strict app-import gate covering function bodies (#200 A1)

The existing S4 cleanliness test walks only module-level statements, so all
31 app.* imports inside function bodies pass it today. Add a second test that
walks the full AST across the five extraction-target modules and asserts zero.

Marked xfail(strict=True): the gate lands before Stage B removes the imports,
so the fix cannot silently regress and an unexpected pass is itself a failure.

Refs #200

* feat(sdk): add get_lexicon context accessor and move apply_lexicon into the SDK (#200 A2)

Plugins need a book's pronunciation overrides and the substitution pass, and
today they reach into app.db.lexicon and app.utils.text.lexicon for both.

ctx.get_lexicon(project_id) mirrors the shipped ctx.get_chapter_segments: a
scoped read returning plain dicts, not a DB handle.

apply_lexicon imports only re, so it moves into studio_plugin_sdk/text.py as a
pure utility (the audio.py and proc.py shape) rather than becoming a wrapper
that calls back into app.*. app/utils/text/lexicon.py is now a re-export shim
and app/orchestration/tasks/synthesis.py imports from the SDK, so there is one
implementation reached by two paths, not two implementations.

Refs #200

* fix(sdk): ctx.split_long_sentences returns str, matching the function it wraps (#200 A3)

safe_split_long_sentences returns a re-joined string. The SDK wrapper declared
list[str] and wrapped the result as [result], so any plugin that swapped its
local alias for the ctx method would have fed a list into a synthesis script.
Owner decision: the SDK was the wrong side.

The only caller was the SDK's own unit test, which asserted isinstance(result,
list) and is updated. No production caller depended on the list shape.

New tests run the real function and assert literal expected strings traced by
hand, so the expectation does not follow the implementation.

Refs #200

* feat(sdk): bump SDK to 1.1 and check manifest sdk_version against the package (#200 A4)

The manifest's sdk_version was validated against a hardcoded {"1.0"} allow-list
in the loader and nothing compared it to studio_plugin_sdk.SDK_VERSION. Both
happened to read 1.0, so the check could not fail and the field was decorative:
bump the package and every manifest still declaring the old value loads clean.

sdk_version now comes out of _SUPPORTED_VERSION_FIELDS entirely and is checked
against the installed package (same major, at or below its minor). One rule,
derived from the package, so the two cannot drift. Mismatch raises
PluginLoadError naming both versions. scripts/validate_plugin_manifests.py
applies the same rule so CI and the runtime loader cannot disagree.

app/studio_plugin_sdk re-exports SDK_VERSION rather than declaring a second
literal. Shipped manifests stay at 1.0 and remain valid.

plugin-contract.md goes to spec_version 1.9.0 with a changelog row covering the
new capabilities, the corrected return type, the version check, and the
reversal of v1.3.0's tolerance of function-body app.* imports.

Refs #200

* refactor(xtts): route bake/segments/standard_handler host calls through the SDK (#200 B1)

The eighteen app.* imports in these three files were function-body imports
inside module-level alias functions. The alias names stay exactly as they
were (tests patch them as module attributes), only what they resolve to
changes: the SDK context instead of app.*.

Two SDK gaps this exposed, both fixed rather than worked around at the call
site:

ctx.wav_to_mp3 dropped on_output and cancel_check and returned None, while
the function it wraps takes both and returns the ffmpeg exit code. A caller
swapping a direct app import for it lost cancellation in a render path and
could no longer tell a failed transcode from a good one. It now forwards both
and returns the code.

ctx.get_chapter_dir took only chapter_id and re-derived project_id from the
DB. It now accepts an optional project_id, so a caller that already holds one
keeps its own authoritative and skips the read.

Retargeting the tests that patched app.db.get_chapter_segments and friends
turned up two mocks that were already inert before this change (they patched
a re-export the code never read), so those tests were exercising the real DB
without noticing. They now patch the module the function actually lives in.

* refactor(xtts): route adapter and app_adapter host calls through the SDK (#200 B2)

Removes the last twelve app.* imports Stage B is scoped for. adapter.py's
TYPE_CHECKING import of the Job model goes with them: the parameter is now
typed Any, matching how the rest of the plugin already treats it.

Three shape mismatches between the context and these call sites, adapted here
rather than pushed into the render path, since the import boundary and the
data shapes are separate questions:

ctx.get_speaker_wavs returns the split list, but the code downstream of the
bridge still parses the legacy comma-joined string, so adapter._get_speaker_wavs
and app_adapter.select_voice re-join before returning. select_voice's declared
contract is str | None either way.

ctx.get_chapter_dir, get_voices_dir and get_voice_profile_dir return str where
these call sites do path arithmetic, so each wraps in Path.

Two test fixtures needed correcting. app.db.state.update_job and
app.db.state_jobs.update_job are the same object reached by two names, and the
context reads the second, so the patches move. Separately, a fixture was
returning a list from get_profile_wavs, whose contract is Optional[str]. That
only went unnoticed while the adapter passed the value straight through
without parsing it.

* test(xtts): make the app-import gate live, and sync the plugin contract (#200 B3)

The xfail(strict) marker comes off test_studio_zero_app_imports_at_any_scope.
It went from 31 hits to zero and now fails on any new app.* import at any
scope in the five extraction targets.

One exemption remains: app_adapter.py's guarded import of the app-side
BaseVoiceEngine and engine models. That is Stage C, and needs a plugin-facing
registration contract in the SDK rather than a re-export. The exemption is
keyed on (file, module) so it cannot quietly cover a different app.* import in
the same file, and a companion test fails if it ever outlives the import it
names.

Spec goes to 1.10.0 with the migration itself and the two SDK signature
changes, plus the three shape traps that cost real time here, written down for
whoever migrates tts_voxtral the same way. Doc 02's signature block had three
entries stale against the code, two of them since Stage A.
senigami and others added 27 commits August 25, 2026 11:15
The workflow only triggered on main, so every PR into studio-2.0 has
merged without CI running on it. studio-2.0 is the development line, so
that is where the gate is needed.

CodeQL already got this treatment in 8ad04b5, before it moved to
GitHub's default setup. ci.yml was never given the same fix.

This will surface one known-red job immediately: the hardcoded styles
check fails on a pre-existing violation at ArchetypeQuickPick.tsx:242,
tracked as item A10 in #192.
… C) (#208) (#211)

* feat(sdk): move the engine bridge error tree into the SDK (#200 C1)

The six engine bridge exceptions were stdlib-only with no imports at all, so
they move verbatim into studio_plugin_sdk/engine_errors.py and app/engines/errors.py
becomes a re-export shim. Same class objects, so every existing importer and
every `except EngineBridgeError` clause keeps working.

Re-parenting any of these would fail silently: an `except` clause that stops
matching raises nothing of its own. Verified by mutation: with
EngineExecutionError re-parented under StudioException, the whole engine and
plugin suite (768 tests) still passed. The new
tests/engines/test_engine_error_hierarchy.py pins every direct parent by name
against hand-written literals and asserts every class is caught by both
`except EngineBridgeError` and `except RuntimeError`; it goes red on that
mutation in 3 places.

* feat(sdk): move the engine discovery and health models into the SDK (#200 C2)

ResourceProfile, EngineManifestModel and EngineHealthModel are frozen
dataclasses over dataclasses, datetime and typing, with no app reference at
runtime, so they move verbatim into studio_plugin_sdk/engine_models.py.
app/engines/models.py re-exports them (identical object identity) and keeps
EngineRegistrationModel, which pairs a manifest with a resolved engine object
and flattens both for /api/engines: host orchestration glue, not plugin
contract.

test_sdk_module_identity.py gains TestEngineModelIdentity, asserting the three
moved types are one object across app.engines.models,
studio_plugin_sdk.engine_models and the package namespace, and that
EngineRegistrationModel is not exported by the SDK.

* feat(sdk): publish the app-adapter contract as a Protocol (#200 C3)

VoiceEngineAdapter is a runtime_checkable Protocol over the nine methods
BaseVoiceEngine declares, so a plugin's app adapter satisfies the contract
structurally and inherits nothing host-side. That matches what the host
already does at the one place it consumes an engine object: registry.py wraps
every engine in _TtsServerEngineProxy, which has never subclassed anything.

BaseVoiceEngine is not moved into the SDK and stays app-side. Publishing it
would ship nine NotImplementedError stubs and four concrete method bodies into
the contract, and put a second engine base class next to StudioTTSEngine for
plugin authors to confuse it with. A Protocol says the same thing without
shipping behavior.

The four PL-3 request helpers are now module-level SDK functions. They already
took everything they needed as arguments and never used cls, so
BaseVoiceEngine binds these exact function objects with staticmethod rather
than keeping a second copy: the `self.normalize_output_format(...)` call shape
in both plugin adapters is unchanged, and the two names cannot drift apart.

* refactor(xtts): drop the last app import from the plugin adapter (#200 C4)

XttsVoiceEngine now satisfies studio_plugin_sdk.VoiceEngineAdapter
structurally and subclasses nothing, so app_adapter.py's guarded
`from app.studio_plugin_sdk import BaseVoiceEngine, ...` is gone. The four
data and error types come from the SDK, and the four request helpers are
called as SDK functions rather than inherited methods.

Three methods the class used to inherit (validate_environment,
current_settings, build_voice_asset) are now declared here, with the same
behavior the base gave them: empty settings, and NotImplementedError on the
two the app side has never driven. Nothing in the host or the suite called
them on this class.

The Stage A gate loses _STAGE_C_EXEMPT and its companion test, since the
exemption's reason is gone. tts_xtts shipped code is now at zero app.* imports
at any scope by AST walk. Four remain in the plugin's own tests/conftest.py,
which the gate does not cover; flagged, not touched.

* docs(spec): bump SDK to 1.2 and record Stage C in the plugin contract (#200 C5)

plugin-contract.md 1.10.0 to 1.11.0. The app-adapter section is rewritten:
it had been telling plugin authors to import BaseVoiceEngine from
studio_plugin_sdk since 1.5.0, which was never true (the real package has
never exported it, only app.studio_plugin_sdk did). It now documents the
VoiceEngineAdapter Protocol, the nine required methods, the four helper
functions, and why BaseVoiceEngine is deliberately not in the contract.

engines-and-plugins.md 1.2.0 to 1.3.0: the SDK module list was missing three
modules and still said SDK_VERSION = "1.0", and the import-boundary rule still
described tts_xtts's plugin/studio as module-level-only. Both corrected, with
the older rule kept for tts_voxtral, which has not been migrated.

SDK 1.1 to 1.2. Minor, not patch: the surface grew by a Protocol, four
functions, nine classes and three modules. Not major: nothing was removed or
reshaped, every relocation sits behind a re-export with identical object
identity, and the derived sdk_version rule from 1.9.0 means shipped manifests
at "1.0" stay valid.
None of these are regressions. Enabling CI on studio-2.0 made
long-standing failures visible for the first time.

1. Backend (Quick) listed ten test files by path, none of which have
   existed since the tests/ reorganisation into subdirectories. Replaced
   with directory targets, which cannot rot the same way. Verified: 1929
   passed, 1 skipped.

2. TakeManager.test.tsx:119 asserted synchronously with getByText right
   after a fireEvent that triggers a React state update, so it raced the
   flush. It won locally and lost on the runner. Now findByText.

3. ArchetypeQuickPick used 'var(--danger, #d33)'. There is no --danger
   token, so the #d33 fallback was always what rendered and the error
   text has never used the design system's colour. Now var(--error-text),
   which fixes a real visual bug rather than only the style check.
…fix the Blob shim behind the Node divergence (#215)

* test(xtts): skip the torch/numpy tests when the XTTS env is absent

CI installs only root requirements.txt, which deliberately excludes torch and
numpy (they live in the separate ~/xtts-env). pytest.ini collects tts_engines/,
so five tests in these two files failed in CI while passing on any developer
machine that has the XTTS env provisioned.

Module-scope pytest.importorskip with an explicit reason, so CI skips them and a
developer with the env still runs them. Verified by building a venv from root
requirements.txt alone: before, 5 failed / 2770 passed; after, 2769 passed /
8 skipped. That same run also confirms no other file under tts_engines/ has the
latent problem.

* fix(tests): read jsdom Blob bytes without undici, fixing the Node 20/22 CI failures

The `Blob.prototype.arrayBuffer` shim in the vitest setup routed through undici's
`Response`, which does not recognise a jsdom Blob as a Blob. That behaves
differently per Node version:

  Node 22: throws TypeError "object.stream is not a function"
  Node 24: silently coerces the blob to the string "[object Blob]" and returns
           those 13 bytes

So the shim was broken everywhere. It just failed loudly on the Node 20 CI runner
and quietly on the Node 24 dev machines. RecordControls' captured-state effect
awaits `blob.arrayBuffer()`, caught the throw, and set the "Could not analyze this
recording" verdict, which left Keep disabled and took out the 10 downstream
assertions in RecordControls.test.tsx and TakeManager.test.tsx.

Read jsdom's own byte storage instead. Not FileReader: it returns the right bytes
but never settles under vitest fake timers, so a test reading a take under fake
timers would hang rather than fail. FileReader stays as the fallback if a future
jsdom stops exposing its storage, and the new test pins both the bytes and the
fake-timer behaviour so losing the fast path fails loudly.

R1: with the old shim restored, the new test is 5 failed on Node 22 (TypeError)
and 5 failed on Node 24 ("expected [ Array(13) ] to deeply equal [ 97, 98, 99 ]").
Expected byte values are literals, never recomputed from the blob.

Full suite: 285 files / 2527 tests green on both Node 22 and Node 24.

* chore: declare the supported Node range instead of leaving it implied

Nothing in the repo stated which Node versions are supported, so the gap between
the Node 20 CI pin and the Node 24 dev machines was invisible until CI started
running on studio-2.0 (#214).

The range is the toolchain's own requirement, not a guess: vite 7.3.5 and
@vitejs/plugin-react-swc 4.2.2 both declare "^20.19.0 || >=22.12.0", which is the
tightest constraint in the tree (eslint, vitest and typescript are all looser).
setup-node's "20" resolves to the latest 20.x, so the existing CI pin satisfies it
and no workflow change is needed.

npm only enforces this with engine-strict, so treat it as documentation.

* test(frontend): serialise test files so local and CI agree

This suite has order-dependent tests: they pass alone and fail when run
alongside others, and the failing set MOVES between runs. Three runs of
the same code gave three different answers:

  Node 22, one worker    285 files pass
  Node 22, parallel      1 fail (RecordControls announce)
  Node 20, parallel (CI) 3 fail (App, BookLayout, ChapterWorkspaceHeader)

Chasing whichever test loses the race on a given day is endless, and a
green local run means nothing while CI runs a different way. fileParallelism
false makes both identical. Verified: three consecutive full runs, 285
files each, with no extra flags.

This does not fix the isolation defects, it stops them producing noise.
They are tracked separately.

* test(frontend): make WaveformTape's rAF loop and peaks decode deterministic

WaveformTape runs an unconditional 60Hz requestAnimationFrame polling loop
for as long as it's mounted. jsdom's real rAF is backed by a real ~16ms
setTimeout, so that loop kept a real timer armed through every
`await waitFor(...)` in WaveformTape.test.tsx — under CI/Docker load, stray
extra frames fired mid-test and raced assertions (R4: no real-timer-based
test timing). Confirmed via Docker (node:20-bookworm, matching CI's pinned
Node/OS): the pre-fix suite failed at the sub-grid rAF test.

installControlledRaf() (new tests/helpers/rafControl.ts) replaces rAF/cAF
with a manual, non-self-firing queue; position now only advances when a
test explicitly calls raf.flush(). The two tests that previously awaited
real animation frames now flush deterministically instead.

While chasing this down, the same fixed-code Docker run surfaced a second,
narrower race: usePeaks' fetch+decode is a real async Promise chain, and if
it settles between two snapshot captures in the fixed-grid shape-stability
tests (rather than before both), peakArray/barCount changes out from under
the comparison. Both tests now wait for the decode mock to have been
called before taking their first snapshot.

Verified clean: 10/10 isolated Docker runs of WaveformTape.test.tsx, plus
zero WaveformTape failures across 8 full-suite Docker runs (2 of them
--cpus=1 constrained). A separate, pre-existing flake in the
ChapterWorkspace/Book test family (confirmed present before this change,
confirmed unrelated — no WaveformTape/PlayerBar render, no timers) remains
and is called out separately rather than folded into this fix.

* test(frontend): stop racing ChapterWorkspace's async chapter load

Next/Previous chapter, Jump-to-next-unrendered, and Bookmark-this-chapter
all render before useBookData's fetchChapters resolves (chapters starts
as []). The affected tests only waited for the control to appear, not for
it to reflect settled data, so a click landed in the pre-load window: the
nav/jump buttons are silently disabled then (no-op click, stuck on the
same route), and the bookmark button has no disabled state to gate on so
it silently records the raw chapterId as the label instead of the title.

Reproduced both failures deterministically by forcing an artificial delay
into the fetchChapters mock, confirmed the fix holds under that same
forced delay, then removed the delay. Fixes wait on the real settled
condition (button enabled, or the resolved chapter title) per R4 rather
than a longer timeout.

Fixes #217.
…ath leaks (#220)

* test(xtts): skip the torch/numpy tests when the XTTS env is absent

CI installs only root requirements.txt, which deliberately excludes torch and
numpy (they live in the separate ~/xtts-env). pytest.ini collects tts_engines/,
so five tests in these two files failed in CI while passing on any developer
machine that has the XTTS env provisioned.

Module-scope pytest.importorskip with an explicit reason, so CI skips them and a
developer with the env still runs them. Verified by building a venv from root
requirements.txt alone: before, 5 failed / 2770 passed; after, 2769 passed /
8 skipped. That same run also confirms no other file under tts_engines/ has the
latent problem.

* fix(tests): read jsdom Blob bytes without undici, fixing the Node 20/22 CI failures

The `Blob.prototype.arrayBuffer` shim in the vitest setup routed through undici's
`Response`, which does not recognise a jsdom Blob as a Blob. That behaves
differently per Node version:

  Node 22: throws TypeError "object.stream is not a function"
  Node 24: silently coerces the blob to the string "[object Blob]" and returns
           those 13 bytes

So the shim was broken everywhere. It just failed loudly on the Node 20 CI runner
and quietly on the Node 24 dev machines. RecordControls' captured-state effect
awaits `blob.arrayBuffer()`, caught the throw, and set the "Could not analyze this
recording" verdict, which left Keep disabled and took out the 10 downstream
assertions in RecordControls.test.tsx and TakeManager.test.tsx.

Read jsdom's own byte storage instead. Not FileReader: it returns the right bytes
but never settles under vitest fake timers, so a test reading a take under fake
timers would hang rather than fail. FileReader stays as the fallback if a future
jsdom stops exposing its storage, and the new test pins both the bytes and the
fake-timer behaviour so losing the fast path fails loudly.

R1: with the old shim restored, the new test is 5 failed on Node 22 (TypeError)
and 5 failed on Node 24 ("expected [ Array(13) ] to deeply equal [ 97, 98, 99 ]").
Expected byte values are literals, never recomputed from the blob.

Full suite: 285 files / 2527 tests green on both Node 22 and Node 24.

* chore: declare the supported Node range instead of leaving it implied

Nothing in the repo stated which Node versions are supported, so the gap between
the Node 20 CI pin and the Node 24 dev machines was invisible until CI started
running on studio-2.0 (#214).

The range is the toolchain's own requirement, not a guess: vite 7.3.5 and
@vitejs/plugin-react-swc 4.2.2 both declare "^20.19.0 || >=22.12.0", which is the
tightest constraint in the tree (eslint, vitest and typescript are all looser).
setup-node's "20" resolves to the latest 20.x, so the existing CI pin satisfies it
and no workflow change is needed.

npm only enforces this with engine-strict, so treat it as documentation.

* test(frontend): serialise test files so local and CI agree

This suite has order-dependent tests: they pass alone and fail when run
alongside others, and the failing set MOVES between runs. Three runs of
the same code gave three different answers:

  Node 22, one worker    285 files pass
  Node 22, parallel      1 fail (RecordControls announce)
  Node 20, parallel (CI) 3 fail (App, BookLayout, ChapterWorkspaceHeader)

Chasing whichever test loses the race on a given day is endless, and a
green local run means nothing while CI runs a different way. fileParallelism
false makes both identical. Verified: three consecutive full runs, 285
files each, with no extra flags.

This does not fix the isolation defects, it stops them producing noise.
They are tracked separately.

* test(frontend): make WaveformTape's rAF loop and peaks decode deterministic

WaveformTape runs an unconditional 60Hz requestAnimationFrame polling loop
for as long as it's mounted. jsdom's real rAF is backed by a real ~16ms
setTimeout, so that loop kept a real timer armed through every
`await waitFor(...)` in WaveformTape.test.tsx — under CI/Docker load, stray
extra frames fired mid-test and raced assertions (R4: no real-timer-based
test timing). Confirmed via Docker (node:20-bookworm, matching CI's pinned
Node/OS): the pre-fix suite failed at the sub-grid rAF test.

installControlledRaf() (new tests/helpers/rafControl.ts) replaces rAF/cAF
with a manual, non-self-firing queue; position now only advances when a
test explicitly calls raf.flush(). The two tests that previously awaited
real animation frames now flush deterministically instead.

While chasing this down, the same fixed-code Docker run surfaced a second,
narrower race: usePeaks' fetch+decode is a real async Promise chain, and if
it settles between two snapshot captures in the fixed-grid shape-stability
tests (rather than before both), peakArray/barCount changes out from under
the comparison. Both tests now wait for the decode mock to have been
called before taking their first snapshot.

Verified clean: 10/10 isolated Docker runs of WaveformTape.test.tsx, plus
zero WaveformTape failures across 8 full-suite Docker runs (2 of them
--cpus=1 constrained). A separate, pre-existing flake in the
ChapterWorkspace/Book test family (confirmed present before this change,
confirmed unrelated — no WaveformTape/PlayerBar render, no timers) remains
and is called out separately rather than folded into this fix.

* test(frontend): stop racing ChapterWorkspace's async chapter load

Next/Previous chapter, Jump-to-next-unrendered, and Bookmark-this-chapter
all render before useBookData's fetchChapters resolves (chapters starts
as []). The affected tests only waited for the control to appear, not for
it to reflect settled data, so a click landed in the pre-load window: the
nav/jump buttons are silently disabled then (no-op click, stuck on the
same route), and the bookmark button has no disabled state to gate on so
it silently records the raw chapterId as the label instead of the title.

Reproduced both failures deterministically by forcing an artificial delay
into the fetchChapters mock, confirmed the fix holds under that same
forced delay, then removed the delay. Fixes wait on the real settled
condition (button enabled, or the resolved chapter title) per R4 rather
than a longer timeout.

Fixes #217.

* fix(security): close assembly path escape, plugin zip-bomb ceilings, and error-body path leaks

Three fixes, all TDD'd with red-then-green:

Issue #218: a project's display name flowed unsanitized into the assembly
output filename and from there into ffmpeg's read directory (input_folder =
output_path.parent), so a traversal-shaped or absolute project name could
steer both outside the project's own m4b_dir. Fixed with a new safe_path_stem
helper feeding secure_join_flat for the on-disk filename only (the display
title still flows verbatim into job/queue metadata), plus a containment check
in AssemblyTask.run() before ffmpeg runs, regardless of who built the path.

Issue #219a: /plugins/import and /plugins/preview read an upload fully into
memory and extracted it with no size ceiling anywhere. Added three named
ceilings (200 MB upload, 2 GB total declared uncompressed size, 10,000
members), enforced before extraction, rejecting with 413.

Issue #219b: an OSError's str() embeds the full filesystem path it failed on;
the icon-save handler forwarded that straight into the HTTP response. Now
logs the real detail server-side and returns a fixed generic message. Same
shape fixed in two related sites that are currently latent (no path in
today's message, but one change away from leaking one).

design-docs/specs/security.md bumped 1.3.0 -> 1.4.0 (S16/S17/S18) with the
new invariants this establishes.

* fix(security): cap the plugin upload the LAN actually reaches, and make the ceiling tests real reds

Review follow-up to ffffe4d.

The 200 MB upload ceiling landed on the TTS server's own /plugins/import and
/plugins/preview, which only ever see traffic the Studio process has already
forwarded. The routes a browser or a LAN client actually posts to are
/api/engines/import and /api/engines/preview, and those still did an unbounded
await file.read() into the Studio process before handing the bytes to the
bridge. So the ceiling existed everywhere except the surface an attacker
reaches. Capped there too, answering 413 without touching the bridge.

The constant is duplicated rather than imported, because app/api must not
import the TTS server's in-process runtime. A new test pins the two values
together so raising one and forgetting the other fails rather than
half-applying the ceiling.

The five existing ceiling tests were not valid reds. They monkeypatched the
new constants down to small values, so against pre-fix source they died with
AttributeError before the request was ever made, which means they never
demonstrated the defect they were written for. Adding raising=False makes the
patch a no-op on pre-fix code instead: the request goes through, the oversized
upload is accepted and extracted, and the test fails with 200 != 413. Verified
in a throwaway worktree at ffffe4d^.

security.md 1.4.0 to 1.4.1. Phase 0 also now records the two limits it does
not provide, since the previous wording claimed more than the code does:
Starlette's multipart parser spools the whole request body to a temp file
before the handler runs, so the ceiling bounds handler memory rather than
bytes on disk, and the 2 GB uncompressed ceiling is a sum, so one member
declaring just under it is still read whole into memory.

* fix(security): close the one genuinely unbounded zip read, and correct security.md's overstated claim about the others

The prior fix's own security.md claimed Python's zipfile "enforces file_size
as the stop condition when reading/extracting a member" as a blanket
property of the module. Measured directly rather than trusted: it isn't. That
truncation-at-declared-size behavior belongs to the streaming reader
(zf.open(member), which extractall() also uses internally) -- not to
ZipFile.read(name), the single-shot convenience call this module used for
manifest.json/settings_schema.json/requirements.txt. read(name) decompresses
a member's entire DEFLATE stream in one call regardless of declared size and
only compares against it, via CRC, at the very end.

Confirmed empirically: a member declaring 100 bytes while its real content
was 800 MB cost +839 MB RSS through zf.read() before raising; the identical
forgery read through zf.open() in a bounded loop cost none. So extractall()
was never actually exploitable this way -- only the three pre-extraction
convenience reads were, and manifest.json is read before any size check has
a chance to matter.

Fixed by routing all four reads through _safe_read_member/_safe_extractall,
which stream via zf.open() and count real bytes rather than relying on
zipfile's internal bookkeeping (extraction gets this too, for defense in
depth and consistent error handling, not because it was proven exploitable).

Because a forged declared size and an honest CRC can never both hold --
truncating an honest CRC's real content always mismatches -- the actual gap
this closes is not a size-ceiling bypass. It's that a corrupted or tampered
member propagated as an unhandled zipfile.BadZipFile, landing as a bare 500
(extraction) or a misleading 400 "Invalid manifest.json" (mis-caught by the
JSON-parse handler, which hid that the archive was tampered with rather than
badly formed). Now a clean, correctly-labeled 400.

security.md 1.4.1 to 1.4.2, with the correction recorded rather than the
overstated claim quietly edited away.
Addresses #222: 14 rows in design-docs/specs/README.md had gone stale —
each spec file's own spec_version had bumped (with a matching changelog
entry) but the central index table was never refreshed alongside it.
Also refreshed install-distribution.md's description, the one row where
the version bump (GitHub install/trust flow: URL validation, preview/
confirm staging, registry-membership trust model) added scope the
one-line summary didn't mention at all.

Issue needs manual closing after merge — GitHub's closing keywords don't
fire on a merge into a non-default branch.
The spec's own front-matter had already bumped to spec_version 1.3.0
(commit 1709b44, #200 C5) but the changelog table's last row still
stopped at 1.2.0, so the spec was drifting from its own version
header. Backfilled from that commit's diff and message: SDK module
list correction, SDK_VERSION 1.0 -> 1.2, and the tightened
zero-app-imports rule for tts_xtts (tts_voxtral kept the older rule).

Addresses #225. This PR targets studio-2.0 (non-default), so GitHub's
closing keywords won't auto-fire on merge -- close #225 by hand after.
ChapterSynthesisTask never carried a resource_claim, so the orchestrator
admitted every queued chapter unconditionally (an empty engine_class skips
every gate, including the global backstop) regardless of tts_parallel_cap.
Chapters showed status="running" with a live ETA the instant they were
submitted, while their segments quietly queued behind the real per-segment
cap underneath - producing wildly wrong ETAs (a reported 4-hour prediction
that finished in 45 minutes).

Gives chapters a dedicated admission pool (ResourceClaim.chapter_admission)
so tts_parallel_cap now genuinely bounds concurrent chapters, exempted from
the shared global-concurrency backstop since a chapter task consumes no
resources itself - only its children do, and they're gated normally. The
first version of this fix let a chapter's claim and its own children's
claims compete for the same global pool, which deadlocked whenever the cap
was raised to meet the global default; caught by adversarial review with an
actual reproduction, not a read-through, and fixed with a narrow exemption.

A parked chapter now stays "queued" instead of flashing "preparing" before
admission is even attempted.
#230)

The "Concurrent Renders" override on each engine card was hidden whenever
a manifest declared segment_orchestration, meant to exclude delegation-only
orchestrators (Mixed, which fans work to sub-engines with no real
concurrency of its own). XTTS also declares that flag, for an unrelated
reason (Studio's chunk-group chapter fan-out with resumable recovery), so
the control was invisible for the one engine an operator would most want
to tune. Only Voxtral showed it, and its manifest caps it at 1 anyway.

Added a correctly-scoped delegation_only flag to tts_mixed's manifest and
gated the UI on that instead. Also fixed the control's own description,
which claimed a restart was needed to take effect; that stopped being true
back in July when live cap resolution shipped.

The existing test's mock XTTS object never carried a features array at
all, so it never exercised the real bug. Updated it to match the real
manifest and added cases proving XTTS still shows the control and Mixed
still hides it.
SegmentRenderMonitor's "N of M segments" counter used the raw count of
chapter_segments database rows (one per sentence) as both the label and the
implied unit of rendering work. The real unit is a render batch: several
consecutive same-speaker sentences merged into one synthesis call. For a
real chapter this session verified, 402 sentence rows collapsed into 58
actual batches - the display was overstating the work by 7x, and produced
exactly the "50 of 400 segments, but the bar says 60%" mismatch reported.

The backend already computes and broadcasts the real batch numbers
(render_group_count, completed_render_groups) on every progress update.
This was a pure frontend wiring gap: the component just never used data
that already existed and already reached the browser. This is also a
regression against an existing spec rule (text-processing.md 1.1.0, June):
UI segment counts must derive from the real batch computation, not row
counts.

The percentage bar itself stays character-weighted, deliberately - that
math is a legitimate estimate from real character counts and a calibrated
per-character rate, not fabrication. Only the discrete count was wrong.
Also fixed: a failed segment was getting partial progress credit in that
same calculation; it now contributes zero.

Two issues an adversarial review caught in the first pass of this fix, both
corrected here: the batch-derived label still said "segments" for a number
that actually meant something else, reproducing the exact naming collision
a new glossary (added in this same change) was written to name; and a
missing completed_render_groups value could have silently shown "0 done"
over real progress, closed by requiring both real values before the batch
display activates at all.

New: design-docs/specs/glossary.md, canonical term definitions. Records the
owner's ruling: "segment" means the render batch; the per-sentence row is a
"span" (sentence-granularity today, word-level by design). Existing code
still uses the old names - that rename is real, cross-cutting work, tracked
but not yet scoped.
Consolidated write-up of the segment/span terminology ruling and the
adversarial review that found it isn't safe to build as scoped. Kept in
sync with issue #232's comment thread per OD-0033, this file is the
durable record.
…weep)

ad2_cap_resolution_hardening touches reserve/release_task_resources,
which #228/#229 modified today (new chapter_admission exemption).
parallel-segment-rendering's render-monitor doc predates the segment/span
terminology ruling. Neither doc is false, both get a dated note so a
future executor isn't surprised.
…renders

ChapterSynthesisTask._publish_progress (the W-PAR parallel chapter-fanout
path) already computed the real render-batch total/completed count but
never passed them to ProgressService.publish, so the frontend's real-batch
display (#231) silently fell back to the raw per-sentence segment count
for every chapter dispatched through this path — reproducing the exact
"39 of 306 segments" bug the owner reported live.

Regression test added and R1 revert-checked (red pre-fix, green after).
EngineClassSemaphore.try_acquire had no concept of arrival order: every
waiting task polled it independently about once a second, and whichever
poll landed right after a slot freed won admission — regardless of which
task had been queued (and polling) longest. With several chapters sharing
one CHAPTER_ADMISSION_ENGINE_CLASS pool, this let a later-queued chapter
("Part 19") jump ahead of earlier-queued ones ("Part 9") still waiting,
matching the owner's live report exactly.

Fixed with FIFO waiter fairness: a free slot now goes only to the
earliest still-waiting task_id (self-pruned after 10s of no polling, so a
crashed/cancelled waiter can't block admission forever).

Also discovered, recorded but not fixed here: choose_next_task/
_task_sort_key (queue-jobs.md §6) are dead code, never called from the
live admission path, so the documented priority-bucket ordering
(studio_first/api_first) is currently aspirational for real dispatch.
Spec updated (queue-jobs.md 1.13.1 -> 1.13.2) with both the fix and the
drift note; README's version pointer, already stale, corrected too.

Regression tests added and R1 revert-checked (red pre-fix, green after);
test_live_cap_admission.py's shrink test updated to release its abandoned
waiter, matching the new fairness contract.
No test exercised the actual click-to-expand interaction on the >60-segment
summary bar's "Segment detail (N)" disclosure, or whether it survives a
live-progress re-render (new segments prop) without losing its open state.
Both new tests pass against the current code — the click toggle and its
persistence across prop updates work correctly at the component level.
…ibleTable

Owner confirmed clicking the "Segment detail (N)" summary does nothing in
the real running app, even after a full cache/reload. Live-browser testing
of the isolated component shows the native <details> toggle working
correctly (open attribute flips, table appears), so the failure is coming
from something in the real page's context rather than the <summary>
element itself — not reproducible in isolation, and not chased further
without a live multi-tick render job. Either way, a bigger explicit button
with a controlled open state is the more reliable target regardless of
what's intercepting the native toggle.
…e bug)

_publish_progress's live WS broadcast got render_group_count/completed_render_groups
in 44649b6, but the durable update_job() refresh right below it never included
them, so a restarted app's job row stayed null until the next WS tick landed on
an already-open tab. REST-hydrated reads (page load, reconnect, recovery-after-
restart) kept showing the raw per-sentence count.

Confirmed live: state.json showed render_group_count/completed_render_groups
both null on a job with real active_segments_map progress since restart.
… counts

At 466 segments, SegmentBlockRow's 6px-per-block floor sums to ~2800px, and
the peek strip's flex chain (root div, expand button, block-row span) left
the default flex-item min-width:auto in place, so each ancestor inflated to
that intrinsic width instead of letting the existing overflow:hidden clip
it — the row bled into the neighboring panel. min-width: 0 on the three
containers fixes it (live-repro-verified in a real browser: rowRight went
from 2814px to 307.5px against a 348px card).
Owner ruling: render batch is the finest granularity this UI may ever
show. useSegmentInventory now aggregates the per-span merge into one
row per render batch, using the script-view API's existing
render_batches field (no backend change needed — same grouping as the
real synthesis-time build_chunk_groups). SegmentRenderMonitor,
SegmentBlockRow, and SegmentPeekStrip needed no logic changes, since
they were already generic over whatever segments array they're
handed — only their copy changed ("segment" -> "batch"). Retry now
resolves a clicked row back to every span in its batch and re-generates
them together through the existing multi-id generate endpoint, instead
of retrying only the batch's leader span.

Bumps progress-presentation.md to 1.12.0 (new invariant M6).
_dispatch_segment's stats recorder reads chars from payload["script_text"];
_SyntheticSegmentTask.describe() never included that key, so every W-PAR
batch child returned early with chars <= 0, starving engine speed
calibration on the whole parallel fan-out path.
…ds (#239)

SynthesisTask.from_task_context() read payload["engine_id"], but a
recovered payload is a raw processing_queue row whose column is
"engine" (there's no "engine_id" key in a raw DB row) - every recovered
plain synthesis job has always failed validate() with "engine_id is
required" on restart, not just the reported one. Falls back to
payload.get("engine") now.

The DB row's cancelled status at crash time isn't a separate filtering
bug: reconcile_queue_status() intentionally cancels snapshotted rows
before recover() resubmits them (queue-jobs.md 5.3), and the crash just
prevented that reactivation from ever happening.

Also hardened the recovery thread itself: an uncaught exception inside
a real thread's target never reaches the parent's try/except, so a
resubmission failure silently dropped the job with nothing but a
stderr traceback. It now logs and publishes a proper failed status.

Regression tests in test_recovery_db_integration.py, R1 revert-checked.
Spec bumped to 1.13.4 with a changelog row.

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
Boot silently swallowed migration failure and had no mechanism to run a
destructive schema change safely -- no ordering, no record of what ran,
no backup, no abort path. #232's chapter_segments redesign needs a real
migration and can't be scoped without this existing first.

app/db/migrations/ adds a runner: migrations apply in version order,
each in its own transaction with explicit rollback on failure, backed
up to a timestamped file before the first write, with a dry-run mode
that validates without persisting. boot_studio() now runs it before the
legacy one-shot data migrations and lets a failure abort boot instead
of catching it -- refusing to serve beats booting on a half-migrated
schema. registry.py is empty for now; #232 adds the first entry.
* Fix WAL-unsound migration backup (#246)

backup_database() used shutil.copy2, which never touches the <db>-wal
sidecar. Every connection runs PRAGMA journal_mode=WAL, so a recently
committed write can live only in the WAL until a checkpoint -- the raw
copy could silently produce a backup that opens fine but has quietly
rewound past real work, with no visible sign anything was wrong. This
backup is the entire rollback plan for #232's destructive migration, so
it has to actually be correct.

Switched to sqlite3.Connection.backup() (reads through the live WAL,
writes one consolidated file) and to a write-.partial-then-rename
pattern so a crash mid-backup can't leave a truncated file mistaken for
a complete one.

* Harden the migration backup's .partial scratch path

Review follow-up on the #246 fix. The Connection.backup() switch itself
holds up (the WAL-sidecar regression test reproduces the original data
loss), but the .partial mechanism it introduced had two problems of its
own.

The scratch path was derived only from the backup's final name, which has
one-second resolution, so two overlapping migration runs opened the same
scratch file and all but one died with "attempt to write a readonly
database". boot_studio()'s _booted guard is an unsynchronized flag, so
overlapping runs are possible. The scratch path is now unique per call.

It was also never cleaned up when a backup failed, which is more than
untidiness: sqlite3 opens a junk file happily and only fails on the copy,
so an orphaned .partial makes any later backup landing on that path fail
with "file is not a database". It is now removed on failure.

Both are covered by tests that were revert-checked red against the
shared-scratch, no-cleanup version. Spec amends the in-flight 1.13.1 row
rather than adding a version, since #256 hasn't merged.
* Fix migration/recovery startup race (#247)

startup_event() ran run_startup_recovery() synchronously while
boot_studio() (which runs the versioned schema-migration runner) was
dispatched to a background daemon thread afterward, so recovery could
resubmit interrupted jobs — and the web server could start serving
chapter/segment routes — before a schema migration finished. That's
unsafe for #232's upcoming segment-shape migration.

Extracted run_schema_migrations() out of boot_studio() and call it
synchronously from startup_event() right after init_db(), before the
recovery-context snapshot and run_startup_recovery(). boot_studio()
still calls it too (idempotent no-op once web.py already ran it) so
direct callers like tests and CLI tools are unaffected.

* Fix spec drift found in review of #247

Two documentation problems on this branch, both in the specs the change
itself touched:

The router index in specs/README.md still pointed at data-model.md 1.13.1
while the spec had been bumped to 1.13.2 during the rebase past #246. The
index is where CLAUDE.md tells you to start reading, so a stale pointer
there sends you to the wrong version of the contract.

The Migration section's closing line still claimed boot_studio() triggers
both migration mechanisms. That is no longer true (and was already only
half true): startup_event() now runs the schema runner plus
migrate_voice_profiles() and migrate_legacy_project_covers(), while
boot_studio() runs the schema runner and migrate_state_json_to_db().
Spelled out the split per entry point, and kept the guarantee that
actually matters (the schema runner precedes any legacy data migration on
every path).

No behavior change.
…rts (#242)

react-router-dom ^7.17.0 -> ^7.18.2 resolves react-router to 7.18.3, closing
all 12 react-router advisories (XSS, open redirect, CSRF, DoS). npm audit fix
then closed the remaining transitive high-severity set: js-yaml (quadratic
CPU via merge-key/omap), postcss (source-map path traversal), and
brace-expansion (ReDoS/OOM), with no dependency-tree changes beyond patch
bumps. vite and vitest were already on patched versions (7.3.5 / 3.2.6) per
existing package.json ranges.

The two high-severity transformers/RCE alerts (requirements-xtts.txt) are
NOT closed here: both patched versions (5.3.0, 5.5.0) are a transformers
major-version bump, and installing 5.16.1 into ~/xtts-env breaks coqui-tts's
actual XTTS import path (ImportError: cannot import name
'isin_mps_friendly' from transformers.pytorch_utils, removed upstream in
transformers 5.x) - confirmed by direct import test, not assumed. Left
open pending either an upstream coqui-tts release compatible with
transformers 5.x or a manual compatibility patch; documented back on #216.

Verified: backend `pytest -q` 2809 passed/3 skipped; frontend `vitest --run`
285 files/2541 tests passed; `npm run build` and `npm run lint` clean;
`npm audit` reports 0 vulnerabilities.
…240)

* Add engine-id allowlist to tts_engine_caps settings endpoint (#235)

POST /api/settings accepted any string key in tts_engine_caps with no
check against real engines, so a client could set an override for
synthetic internal keys (e.g. chapter_admission) or plain typos (an
existing test used "tts_voxtral", the plugin dir name, instead of the
real engine_id "voxtral" — silently accepted and silently inert).
Validate against the live plugin registry and reject unknown keys
with 400.

* Fail closed when the engine registry is unreachable (#235 review)

save_settings() swallowed registry errors from the new allowlist check
inside its existing broad except-Exception handler, so an unavailable
TTS server made the endpoint silently return 200 and drop every field
in the request, including tts_api_key/huggingface_token. Also reject
the empty-registry case explicitly instead of failing every engine_id.
* Carry project_id/chapter_id through crash recovery

load_recoverable_task_contexts() read chapter_id only for its per-chapter
dedup and never passed it (or project_id) to TaskContext, so every recovered
context arrived with both as None. The orchestrator gates on these as fields
rather than through payload, so a crashed chapter render could not resume: the
resume-progress lookup never fired, and reconstruction fell through to a bare
SynthesisTask whose validate() rejects the empty script_text, killing the job
instead of resuming it.

The existing recovery tests asserted only task_id, which is why this survived.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Fix two recovery tests exposed by the chapter_id/project_id fix

test_startup_recovery_resubmits_interrupted_task asserted on a background
recovery-<task_id> daemon thread's side effect with no synchronization. It
only ever passed because a recovered context with chapter_id=None took a
fast/no-op progress-publish path; with real identifiers now threaded through,
the real (slower) path exposed the race. Fixed by joining the thread via the
existing join_recovery_threads() helper before asserting, inside the patch
window.

test_recovered_job_from_real_db_row_reconstructs_with_engine_id's own
docstring documented that chapter_id wasn't threaded through as a known,
separate defect and asserted the exact resulting error message
("script_text must not be empty") as expected behavior -- pinning the bug
it described. Now that chapter_id survives recovery, validate() correctly
skips that check for a chapter-driven job and reaches the next real gap
(a recovered row has no output_path either); updated the assertion and
comments accordingly.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Epic Larger Story containing smaller stories required for full completion.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Studio 2.0 - Core Architectural Redesign & Modular Plugin System

2 participants