| 文件 | 最后提交记录 | 最后更新时间 |
|---|---|---|
feat: sample declared interactive state before classroom questions (#1508) * feat: sample declared interactive state before classroom questions * test: exercise generated publication example through the iframe reader * chore(generation): bump package version for observation prompt contract * fix: keep interactive state optional on insecure HTTP origins * fix(playback): keep component picking and separate state from reference A declared state interface replaced the component picker with a forced whole-area `#experiment` reference, removing the per-component selection and outline that `main` already ships. Sampling was also gated on the reference selector, so the only way to obtain state was to give up the selection. Reference identity and area state are now independent request-scoped evidence items: - `handleToggleElementPick` always arms the picker again, so a scene that declares the interface keeps main's per-component selection, outline, and send-time clearing. - `sampleInteractiveState` follows the current Scene instead of the draft reference, so an unreferenced follow-up still reports current facts and never re-creates or extends a reference. - The Host carries area state with or without a component reference. The evidence header names both identities and refuses to present area facts as properties of the referenced component. - `metadata` is absent when only area state travels, so no element identity and no Spotlight authorization can be derived from it, and the accepted-reference receipt stays driven by explicit references only. Review follow-ups in the same change: - Client sampling follows `NEXT_PUBLIC_COURSEWARE_REFERENCE_ENABLED`. An ungated packet turned an ordinary Pi question into a 400 while the reference feature was disabled. - A Scene that declares the interface always receives an availability boundary, including when the browser produced no packet at all. It is reported as `not-sampled` rather than the previous `no-interface`, which was a false statement about an activity that does declare one. Courseware without the interface keeps its unreferenced behaviour. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * test(generation): register the observation snippet as a packaged asset The interactive-observation snippet is referenced by all six widget content templates but was never added to the packaged-asset manifest, so the asset test and the golden scene prompt both failed. - `SNIPPET_IDS` now lists `interactive-observation`, restoring both the "exactly the generation-owned templates and referenced snippets" check and the "every referenced snippet is packaged" cross-check. - The interactive system-prompt snapshot is re-pinned. The change is purely additive: the snippet is appended to the simulation template. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(pi): stop injecting state constraints while the feature is disabled The route rejects a request that carries a reference or a state packet while `NEXT_PUBLIC_COURSEWARE_REFERENCE_ENABLED` is off, but an ordinary question carries neither. It still reached the Host, and a Scene that declares the state interface then received the full page-state block — several kilobytes of constraints about evidence the deployment can never sample. The Host now returns before building that note when the feature is off. A route-level regression asserts that neither the Director prompt nor the Child prompt gains `PAGE-REPORTED STATE` in that configuration; disabling the guard makes it fail with exactly that symptom. Also reopens the composer before the unreferenced follow-up in the classroom browser spec. An accepted answer may close it, which made the assertion flaky without changing the behaviour under test. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(pi): decouple reference Scene from state freshness and bound assembled evidence Cross-review found two defects in the request-scoped state evidence. The reference's Scene was folded into the sample's staleness test. The packet is already bound to the current Scene by the identity check above it, so a valid current-Scene sample was being discarded as `stale-sample` purely because the student's component reference came from an earlier Scene. Reference and area state are independent evidence items; freshness is a property of the sample alone. With the coupling gone the two can now disagree on Scene, so the note says so explicitly rather than letting the model attribute area facts to a component that may not be on the current Scene. The assembled evidence had no stated output budget. The static component packet is bounded to 24,000 code points upstream, but that bound covers the static packet alone; the note and the escaped observation JSON were appended without a recheck. Escaping `<` for the prompt expands one code point into six, and `<` is legal in a label or a fact value, so a packet the Host accepts could assemble to 149,385 code points. The budget is now declared as the static bound plus the room the note frame needs, which is what makes the degradation terminate. Over budget, the state body drops whole to an explicit `unavailable` statement: truncating the JSON would emit a broken packet, and thinning a `complete` relation set would turn an exhaustive set into a false one. The relationship summary degrades with it, so the prose never asserts COMPLETE over a body that is gone. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(pi): keep slide references independent of activity state * refactor(interactive): simplify declared state and unify iframe preparation Accept any JSON report within byte and depth budgets, without generated field requirements or relationship-completeness semantics. Keep publishState and an optional rendered result in the generation guidance. Prepare the observation responder through patchHtmlForIframe and let the pool own document identity, preserving state across placeholder remounts. Settle sampling failures locally and align browser/server nesting limits. Cover permissive JSON delivery, resource limits, lifecycle, legacy behavior, and real renderer remounts with focused regression tests. * fix(generation): publish automatic activity changes with clear positions * fix(interactive): report missing legacy scope as no interface * fix(interactive): guard sampling capabilities and bind scopes lazily * chore(generation): bump version after main integration --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com> | 1 个月前 | |
fix(tests): make the suite reliable on Windows and newer Node runtimes (#1890) - tests/ci/publish-openmaic-skill.test.ts: resolve bash the way PATH finds it on Windows (/bin/bash elsewhere) and join PATH with path.delimiter. All 16 cases failed on Windows before this. - tests/store/kv-persist.test.ts: dedupe standing-problem reports per key; how many recovery attempts elapse inside flushTasks is wall-clock, not code. - tests/workbench/workspace-rail-session-rename.test.ts: wait for the row/menu with vi.waitFor(..., { timeout: 30_000 }) and give the suite its own 30s budget for the cold dynamic import of the rail. - tests/lint-llm-entry-guard.test.ts: 120s suite timeout — every case runs the real eslint over every guarded path. The media-orchestrator part of the original change is dropped: upstream rewrote that suite and removed the fragile microtask-hop waits it targeted. No production code, no global config changes, no assertion weakened. Co-authored-by: wyuc <wang-yc24@mails.tsinghua.edu.cn> | 20 天前 | |
feat(storage): route settings/user-profile persist through KVStore (no legacy migration) (#1001) * feat(storage): route zustand persist through KVStore The settings and user-profile stores persisted through zustand's default `localStorage` storage, while the rest of the app's small keyed values had already moved to `@openmaic/storage`'s `KVStore` — two unrelated mechanisms over the same browser API, which is the split-brain the storage RFC set out to remove. `kvPersistStorage` shipped with the package but had no importer. Add `lib/store/kv-persist.ts`, the app seam that adapts a `KVStore` into a zustand `PersistStorage`, and wire both stores to it under an explicit `account` scope. Store logic is unchanged: `version` / `migrate` / `merge` still belong to the stores, only the bytes' destination moves. Existing installs are adopted rather than orphaned. On first read the seam moves the raw `localStorage` entry into the KV scope — write first, then drop the raw key, so an interrupted migration leaves the value readable from one side or the other but never from neither. Once KV holds the entry it is authoritative, so a stale raw entry reappearing cannot roll back newer state, and re-running can neither duplicate nor resurrect anything. An entry that is not a readable persist envelope is left exactly where it is. Hydration is now asynchronous, because a `KVStore` may be remote. The browser backend resolves within a few microtasks of the store module being evaluated, so nothing user-visible changes, but tests that assert on persisted state now await `persist.rehydrate()` instead of relying on a synchronous read. The four pre-persist keys (`llmModel` / `providersConfig` / `ttsModel` / `selectedAgentIds`) are deliberately untouched: they are read-only inputs to a migration that never owned them, and `providersConfig` is still read independently by `lib/ai/providers.ts`. Part of #857. * fix(storage): close the async-storage failure modes in the KV persist seam Cross-review found that routing `persist` through an asynchronous store opens failure modes a synchronous `localStorage` never had. Each one below is now covered by a test that fails without its fix. Data loss on a failed or empty hydration. A KV read that threw was indistinguishable from an empty scope, so the store stayed on defaults while an always-on initializer's `set()` persisted those defaults; the next load then read them as a completed migration and deleted the raw original, leaving nothing. Reads now separate "nothing there" from "the backend could not answer": a failed read serves the raw entry read-only, adopts nothing and deletes nothing, and leaves the key unwritable for the session so no un-hydrated state can be persisted over an original. Raw entries are additionally only dropped against a recorded completion marker, so a KV entry of unknown provenance can never authorize deleting the only copy of the user's data. Lost adoption across tabs. A reader whose KV read answered before another tab's adoption write, and whose localStorage read landed after that tab's delete, saw nothing in either place and hydrated defaults. Against a remote backend that interleaving is ordinary rather than rare, so an apparent "nothing anywhere" is now re-checked before it is believed. Out-of-order writes. Two rapid `setItem`s could complete in either order and let the older value win. Reads and writes are serialized per key. Pre-persist migration resurrecting on every load. `migrateFromOldStorage` was guarded by the existence of the raw `settings-storage` key — the very key the migration removes — so once the blob moved into the KVStore it ran on every load and republished whatever the pre-persist keys still held, including API keys the user had since deleted, into first-frame state. It is now the persist layer's last-resort source instead of a synchronous read in the store initializer: its result is adopted like any other migration, so the adopted entry is the marker that stops it. Initial state is plain defaults, and nothing stored reaches observable state before the storage layer has vouched for it. The source keys are still only read, never deleted, since `lib/ai/providers.ts` remains a reader. Also: KV reads and writes are guarded so a corrupt entry surfaces instead of throwing through zustand's silent catch; clearing the cache now clears the migrated stores through their own storage, which a server-backed scope would need and a blanket `localStorage.clear()` would only appear to do; `removeItem` drops the KV copy before the raw one; and the e2e suite gains post-migration steady-state coverage, which every existing spec missed by seeding the pre-cutover key. Part of #857. * fix(storage): scope migration bookkeeping to the device, gate writes on call Second review round. The findings shared one root cause: two of the three sentinels recorded a fact about *this machine's* files but were stored beside the data, in the store's own scope. Under a syncing `account` backend that is a category error with teeth — one device's history would travel to another and authorize deleting files it says nothing about. Sentinels are now always `device`-scoped and carry a reserved `__` prefix, whatever scope the store uses. The worst combination this closes: device A migrates, its marker syncs, device B — which has never run this build — loads and deletes the raw entry that is still the only copy of its own settings. A test drives exactly that sequence against two devices sharing one account scope and nothing else. The pre-persist fallback gains a device sentinel of its own. Its source keys are device-local and deliberately left in place, so "KV and raw are both empty" was not a safe trigger: clearing the account entry, from the cache button or a server-side wipe, let any device re-read those keys and republish credentials the user had rotated. It is now consulted at most once per device, and `removeItem` deliberately does not clear that sentinel — clearing the entry is precisely the case it exists for. The fallback also no longer writes the adoption marker: that marker is a licence to delete a raw persist entry, and the fallback has adopted no such thing. Were it written, a raw entry later restored by an older bundle would be deleted unread. The write gate is now read when `setItem` is called rather than when its queued turn arrives. Evaluated inside the queue, a write issued during hydration waited behind `getItem`, found the gate opened by the very read it raced, and persisted its pre-hydration snapshot on top of the stored value. Refused writes are no longer a dead end: the first one asks the store to rehydrate, since the backend may have recovered, and if that does not work the user is told through a durable notice rather than losing every change on reload. Each refusal is logged, not just the first — a once-per-session warning hides how long a session has been silently unsaved. `removeItem` now propagates backend failures, so clearing the cache cannot report success for a clear that did not happen, and that flow reloads immediately instead of leaving a window in which a background `set()` re-persists the credentials just deleted. Also: the recovery hook is bound after each store is constructed, because naming the store inside its own definition made its type circular and silently widened every selector to `any`; the `serial` comment no longer claims an unreachable rejection handler; and the pre-persist version stamp documents that it skips `migrate`'s unversioned normalization too, which `merge` redoes anyway. Part of #857. * refactor(storage): make the KV persist seam a per-key state machine Three review rounds kept turning up the same defect class in new places: a backend failure read as "there is nothing there", and the result of an operation that nobody looked at. Patching instances of it was not working, because both are possible whenever a call site is free to decide for itself what a failed operation meant. This makes that decision unavailable to call sites. Every backend call now returns an `Outcome` whose payload is a private field and whose only reader demands a `KeyState`. Feeding the machine is not a convention to remember; it is the only way to get the value out. What comes back is the value or `UNAVAILABLE` — deliberately not `null`, so "the backend could not answer" is not spellable as "nothing is there". The machine then decides, in one place, what failure means: any read failure is unavailability, any write failure closes the key, and either raises the health signal and asks for recovery. The scattered closure flags (a settled set, a warned set, a recovery latch) become one explicit phase per key — unhydrated, settled, unavailable, clearing — with the transition table written out in the docstring and a test per row. What that consumes: - A write failing after the key settled used to be ignored; its outcome now closes the key, signals, and the value is remembered for replay. - `removeItem` enters `clearing` synchronously at call time, so a `set()` racing a clear cannot be admitted, queue behind the delete, and write the just-deleted value back. Nothing is replayed across a clear. - The pre-persist sentinel is recorded by *every* settling path, not only the one that consults the old keys. A device that had only ever loaded an already-populated account entry recorded nothing, and so stayed eligible to republish its own stale credentials the moment that entry was cleared — which the docstring already claimed it would not. - Two places treated a failed read as an answer: the sentinel read that decides whether the fallback runs, and the confirming re-read before adopting. Both now stop. - An adoption whose marker did not land no longer settles, and never deletes the raw entry: no proof, no licence. - Refused and failed writes are remembered and replayed once storage recovers, but only when the store was holding real data at the time — replaying a snapshot taken over defaults would overwrite the stored value, which is the failure this seam exists to prevent. When a snapshot cannot be replayed the user is told, because rehydration is about to discard those edits. - Health notices publish on a later task, so a recovery that lands moments later cancels a warning nobody should have seen, and a subscriber mounting after the failure is caught up once its toast host exists. `StorageHealthNotice` now sits after the `Toaster` for the same reason. Also: adoption re-reads before committing so a concurrent write is not rolled back; a shadowed raw entry is only deleted if it parses, matching the promise never to delete bytes we could not read; the unavailable copy points at the control that resolves it rather than suggesting a reload that cannot; and the version pin, the pre-persist retention rationale, and the cache-clear comment say what is actually true. Part of #857. * fix(storage): separate the contexts settle() was conflating Convergence round on the state machine. The findings share a root: settle() treated every read that concluded as licence to open the key, without asking what else was true at the time. - A read finishing while a clear is outstanding no longer settles. Under a remote backend a recovery rehydrate in flight during a clear is ordinary, and opening the gate there admits a write that queues behind the pending delete and restores the credentials just removed. Only finishClear opens a cleared key. - The raw entry served read-only after a failed read no longer counts as the authoritative value. A shadowed raw entry is by construction the older copy — long-term coexistence with a newer KV value is a designed state — so an edit taken against it must never be replayed, or one transient read failure on one device rolls the whole account back. - A write that lands supersedes an earlier failed one. Both were admitted before the key closed, so the queue ordered them; replaying the older snapshot on recovery would undo the newer value. - Unreachable browser storage is a failure, not an empty store. Privacy modes make localStorage throw; treating that as "nothing stored" hydrated defaults, refused every write and said nothing until the user reloaded and found the work gone. SSR stays a silent no-op. - The recovery latch re-arms when storage answers, so the three recovery transitions are not empty promises from the second failure onward. - A refusal carries the phase it happened in. Cold-start timing — an always-on initializer writing before the first read resolves, on a backend that never failed — is a log line, not a sticky red toast; only a refusal under `unavailable` reports lost changes. The health channel stops delivering stale catch-ups: its timers are cancellable and re-read the current state when they fire, so a recovery cannot be overtaken by a warning with nothing left to dismiss it. And `changes-lost` is now a fait accompli rather than a condition — a later recovery has nothing to retract and leaves it standing. Also: a throwing `localStorage` is unavailability rather than absence wherever the raw entry is read; clearing records the pre-persist sentinel like every other settling path, making that docstring true; the raw entry is dropped before the marker so a failed marker clear leaves something inert; and the pre-persist fallback and its four source reads can no longer throw into zustand's silent catch. Part of #857. * fix(storage): hold a replay until it lands, and prove a raw copy before deleting it Three failure-path corners. A replay was retired the moment its write was attempted. Readable but not writable is a real state — a quota-exhausted backend answers reads perfectly — so a recovering read could succeed, the replay write fail, and the snapshot be gone with nothing owed: the next rehydrate served the older stored value over the user's edit and said nothing. The snapshot is now held until the write is durable; a failed replay goes back to being a refused write, so the next recovery tries again and a permanent failure is eventually reported. The adoption marker was treated as proof that a raw entry is the migration's leftover. It proves only that a migration happened on this device once. A rolled-back deployment, or a tab still running the old bundle, writes the raw key again with settings the migration never saw, and the next load on the new bundle deleted them unread. A raw entry is now dropped only when its content equals the value shadowing it — anything else is data, and keeping a copy beats deleting an original here as everywhere else in this file. `changes-lost` was being cancelled by a later recovery. The two statuses answer different questions: "storage is down" describes the world now and stops being true when storage works, while "your edits were lost" describes something that already happened and no amount of recovery undoes. They are now tracked on independent lines per key and rendered as two toasts with distinct ids, so retracting the fault cannot take the acknowledgement with it; the loss notice goes away only when the user closes it. Part of #857. * fix(storage): bound recovery, keep a live store from walking backwards Three interactions the state machine did not have an answer for. Recovery had no budget. A backend that reads but cannot write — a quota-exhausted one does exactly that — turned it into a treadmill: the recovering read succeeds, the key settles, the replay write fails, and that failure asks for recovery again, forever. Attempts now follow a backoff schedule whose length is the cap, and the budget is re-armed only on real progress: a settle that left nothing owed. A settle reached *by* a recovery with a replay still queued is not progress, and counting it as such is precisely what made every lap look like a fresh start. When the budget runs out the key is left read-only and the loss is reported, rather than retrying where nobody can see it failing. A failed read could walk a live store backwards. Once a session has loaded the authoritative value, a transient read failure was still falling back to the shadowed raw copy — which is legitimately older, and legitimately long-lived now that a differing raw entry is kept rather than deleted — and handing it to zustand overwrote a live store with stale data. Worse, the store had already latched "holding real data", so edits made on top of that stale value counted as replayable. A failed read after an authoritative one now returns nothing, which is exactly "keep what you have", and the raw fallback stays where it belongs: the case where nothing authoritative has been seen at all. Dismissing a lost-changes toast only closed the UI. The key stayed in the lost set, so the same store losing changes a second time was swallowed as a duplicate and any later subscriber resurrected a notice the user had already dealt with. The close button now clears the latch, so a second loss is reported and an acknowledged one stays gone. Part of #857. * fix(storage): close the last two provisional and clear-ordering gaps An abandoned migration was handing back an authoritative value. When the confirming re-read fails, or the adoption write does, `adopt` returns the raw envelope unadopted — the same footing as the raw copy served after any other failed read — but it was returned bare, so the store latched "holding real data" and an edit taken on top of it counted as replayable. Another device publishing to the account scope in that window would have been rolled back. `adopt` now reports provisionality like every other read path, and the shape is a named `LoadedValue` so the next path cannot forget. Clearing swallowed a failed raw-entry deletion. The account entry was already gone by then and the marker was cleared afterwards, so the operation reported success while leaving an empty scope beside a live raw entry — precisely the shape the next hydration re-adopts, handing back the settings the user had just deleted. The raw entry now goes first and its failure propagates, which also fixes the ordering: failing before anything is removed leaves the KV value authoritative and the clear simply unperformed, which the caller can retry. Adoption keeps the tolerant variant, where a leftover raw entry is a duplicate rather than a loss. Part of #857. * fix(storage): make the shadowed-raw delete atomic against a concurrent tab cosarah reproduced a data loss: both raw-entry deletes verified the value, then awaited (reading or writing the adoption marker), then removed the key by name. A tab running an old bundle — which takes no Web Lock, so a lock could not have helped — overwrites the raw key with newer settings during that await, and the unconditional `removeItem` then deletes the newer value. The raw side is synchronous `localStorage`, so the fix needs no lock. Both deletes now finish every await first, then run a single synchronous compare-and-delete: re-read the raw bytes, and remove only if they still equal the exact string that was verified. A competing tab is a separate context whose writes surface here only at event-loop boundaries, so its write lands wholly before the re-read (seen — bytes differ — kept) or after the remove, never in between. A changed value is newer data, not a migration leftover, and is kept. Both sites cosarah named are covered: the shadowed-cleanup path (`dropShadowedLegacy`) and the adoption cleanup in `adopt`. `readLegacyEntry` now carries the exact bytes alongside the parse so the re-check is byte-exact rather than comparing a re-serialized value. The clear path's delete is unchanged: it is a deliberate full delete, not a conditional leftover cleanup. Part of #857. * fix(storage): stop deleting the raw entry — adopt by copy (Plan B) Maintainer decision after cross-vendor + red-team review: `localStorage` has no cross-process atomic compare-and-delete (the storage mutex was dropped from the spec), so a tab on an old bundle can insert a write between our re-read and our removeItem in a genuinely parallel process. The synchronous compare-and-delete narrowed that window but could not close it — as its own comment admitted. Since the raw-cleanup cannot be done safely on localStorage while an old bundle may be live, this stops doing it. Adoption is now a copy: the value is written to KV and the raw entry is left in place. The delete race is therefore impossible — there is no delete. KV stays authoritative (once it holds the value, reads take the KV path and never consume the raw one), so the shadow duplicate is inert, left for a wholesale raw-cleanup at migration exit. Removed with the two deletes: - `dropShadowedLegacy` (its only job was the gated delete) and its three call sites. - `deleteLegacyIfUnchanged` and the `{raw,value}` shape of the legacy read (`readLegacyEntry` → back to `readLegacy` returning the value). - The adoption marker (`__persist-adopted:`), whose only reader was that delete gate. It did not prevent re-adoption — the presence of the KV value does — so removing it breaks no invariant. `dropRaw` / `expectedRaw` on `adopt` and the marker-clear step in the clear path go with it. The clear path (user deletes all data) still deletes the raw entry strictly — that is a deliberate full delete, not a migration cleanup. Tests: the delete/marker cases become "the raw shadow always survives, KV is authoritative"; a two-device and an old-bundle-concurrent-write case guard it, and re-adding a delete on either adoption path turns them red. Part of #857. * fix(storage): add a monotonic migrated marker to stop shadow resurrection Cross-vendor + red-team review independently caught a P1 the previous "adopt-as-copy" revision introduced. Because the raw entry is now kept and writes only ever reach KV, the raw copy becomes a frozen pre-cutover value. If the account KV is later emptied by something other than a local clear — a server-side wipe, an account switch, KV eviction — the next load saw `stored === null` beside a live raw entry and re-adopted that stale copy, refilling the wiped account with ancient settings/credentials and syncing them to every device. Deterministic, not a race; it would fire once a server-backed account backend exists. Root cause: "keep the raw entry" inherently requires a record that it has already been migrated, so an empty-KV load can tell "first migration" from "external wipe". Removing the old delete-authorization marker in the copy revision dropped that record — Plan B was only half done. Adds a monotonic, device-scoped `__migrated:<name>` marker: - Read on every load. `stored === null` + raw present + marker set ⇒ respect the wipe, do not adopt (fall through to the confirm re-read, so a concurrent repopulation is still served). Only a genuine first migration (no marker, raw present) adopts. - Written best-effort after a successful KV write. A failed write degrades to the KV value's own presence check, so resurrection needs both a failed marker write and a later external wipe. - Read failure fails closed (do not adopt), never "assume not migrated". - Monotonic: only ever set true, cleared only by a deliberate full clear — no compare-and-delete race, which is why it is safe where deleting the raw entry was not. Live state with a real reader, not the delete-authorization marker removed earlier. The clear path clears the marker too (back to a genuine first-load state); best-effort, since with the raw entry already gone a leftover marker is inert. Tests: resurrection guard (external wipe does not re-adopt; concurrent post-wipe write is served; first migration records the marker; marker read fails closed) + the clear-path marker clearing. Mutation-checked: removing the gate resurrects the shadow, dropping the record leaves it unguarded. P3: the inert `__persist-adopted:` test rot is retargeted to the real `__migrated:` path. Part of #857. * refactor(storage): remove automatic legacy migration — pure KV wiring Maintainer decision (terminal): after repeated review rounds kept finding new P1s in the legacy-adoption path — its state space is too large to keep correct — the PR is narrowed from "migrate + wire" to "wire only". Settings and user-profile persist through the KVStore; pre-cutover data is not migrated; an upgrading user reconfigures once (the data is reconstructable and the cost is one-time). Removed the entire legacy-adoption subsystem from the adapter: - `adopt()`, `readLegacy()`, the `legacyStorage` dep, and the raw-fallback read on a failed KV read. - The `__migrated:` marker and its resurrection guard, the confirm-re-read, and the `provisional`/`LoadedValue` machinery. - The `prePersistFallback` dep, `recordPrePersistHandled`, the `__pre-persist-consulted:` sentinel, and `readSentinel`/`writeSentinel`. - In settings.ts, `readPrePersistSettings` (the llmModel/providersConfig reader wired as that fallback) — reading legacy keys is migration, which this PR no longer does. `lib/ai/providers.ts` still reads `providersConfig` independently, so those source keys are left in place. The adapter now reads and writes the KV scope and touches no legacy key: an empty scope hydrates the store to defaults (ordinary zustand). `load()` is a single KV read; a failed read stays unsettled so defaults are never persisted over an unread value; the clear path removes only the KV entry. Kept, because they are async-storage *steady state* a remote KV backend needs, not migration scaffolding: the KeyState machine (hydration gate, write gate, recovery, replay), the Outcome discipline, per-key serialization, and the persist-health channel. Added `purgeLegacyPersistKey`: a best-effort, fire-and-forget removal of the old raw persist blob, called once by each store. Never read, so a leftover is pure garbage — and the old settings blob held plaintext provider API keys, so clearing it is a small security win. Net: the adapter drops ~460 lines. Tests are rewritten to steady-state KV coverage plus an explicit "old raw key is ignored, not migrated" proof at the store level; the e2e seed fixtures move from the raw key to the KV scope key. Part of #857. * docs(storage): align README and comments with the no-migration behavior Follow-up requested on the approved PR: the migration/adoption logic was removed, but a few comments and one README roadmap line still referred to those deleted concepts. Documentation only — no behaviour change. - README roadmap: the settings/user-profile line said "adopting their pre-cutover raw keys"; it now states there is no automatic migration (legacy keys ignored and best-effort purged, users reconfigure once). - kv-persist.ts: the per-key serial chain comment no longer claims to keep "adoption" from interleaving; it serializes a key's read/write/clear. - settings.ts: `SETTINGS_PERSIST_VERSION` no longer documents a "pre-persist fallback" that no longer exists. - Test/fixture headers that described seeding "the pre-cutover raw key" for "one-time adoption" now describe seeding the KV `account` scope, with no legacy-key migration. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * chore(storage): bump version to 0.1.1 for README doc alignment The README is a publishable input, and the no-migration doc alignment changed it, so the package version must increase. Patch bump: documentation only, no code or API change. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> Co-authored-by: 杨慎 <117187635+cosarah@users.noreply.github.com> Co-authored-by: wyuc <zdq1204@gmail.com> | 3 个月前 | |
feat(config): openmaic.yml, capability slots and server-side model configuration (RFC #1701) (#1765) * ci: run CI for the provider-config integration branch Development of the provider configuration RFC (#1701) lands on integration/provider-config; build it on push and run CI for pull requests into it, as with earlier integration branches. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR * feat(config): capability slot registry and stage-to-slot mapping (#1726) * feat(config): capability slot registry and stage-to-slot mapping First P0 step of the provider configuration RFC (#1701, tracked in #1725): define the capability slot forest and map every LLM stage key to exactly one slot. Pure data with no callers yet, so there is no behavior change. Requirements are checked on the slot that declares them and are not passed down to child slots, so agent.title can use a model without tool calling even though agent requires it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR * test(config): pin every stage destination and the capability roots Review found that the station and root assertions derived their expectations from the module under test: re-pointing a single-stage station or dropping a capability root still passed. The tests now compare against an independently written stage table and root list, and pin agent.title as config-only. Also document that browserless outlines still run on the generate-classroom model. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR * test(config): pin slotForStage and full slot lineages Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> * feat(config): provider presets and the openmaic.yml schema (#1727) * feat(config): provider presets and the openmaic.yml schema Second P0 step of the provider configuration RFC (#1701, tracked in #1725). - lib/config/provider-presets.ts: one preset per built-in registry entry, plus the token plans as multi-capability presets whose stage recommendations become slot recommendations. Registry ids that collide across capabilities get explicit preset ids. - lib/server/model-config/openmaic-yml.ts: parse and validate the operator's openmaic.yml (or the file named by OPENMAIC_CONFIG): ${VAR} interpolation, strict schema, and cross-checks that every assignment names a declared provider whose preset offers the slot's capability. Every problem is reported with its path. - instrumentation.ts: an invalid file refuses to start. Without a file nothing changes, and nothing resolves models through the file yet. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR * fix(config): harden openmaic.yml validation after review - Look providers up by own key only, so "constructor:m" is not taken for a declared provider. - Refuse non-mapping objects YAML produces (an unquoted timestamp becomes a Date that the schema would accept as an empty object), and refuse a YAML alias that refers back to itself instead of overflowing the stack. - Accept lowercase variable names; refuse a "${" with no closing brace, checked on the text as written, never on substituted secrets. - Cross-check the entries that are valid on their own even when the schema rejects others, so every problem is reported at once without duplicate errors for declared-but-invalid providers. - Name the three valid assignment shapes when a value has none of them. - Test the boot path: an invalid file exits with code 1 before any schedule starts; a valid one boots. - Ignore a root openmaic.yml in git. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR * fix(config): keep secrets out of openmaic.yml diagnostics Review round 2: - Read only variables the environment itself has, as non-empty strings: ${constructor} or an inherited value is not a variable. - Never print a value that came from ${VAR}, and report YAML syntax errors by reason and position without js-yaml's source excerpt, which can quote a literal key. - Keep the base URL requirement on presets: SearXNG from its registry entry, and Azure OpenAI and self-hosted MinerU, which have no usable default endpoint. - Check slot names and valid model references even inside an entry that fails the schema, and report a failed placeholder once rather than again as an empty value. - Boot test for the no-file case. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR * refactor(config): validate openmaic.yml in two phases Rounds 1-3 of review kept finding edge cases in one feature: running the cross-checks on the half-valid parts of a file the schema had rejected, so that every problem showed at once. It hid problems inside a rejected entry, lost a __proto__ slot key, and built misleading provider errors from failed placeholders. That feature is gone. Validation now runs in two phases. Phase 1 is the document itself: placeholders, key names (checked on the document as written, since the schema drops a __proto__ key), and the schema. Any problem there stops before phase 2, the references between entries, which therefore only ever sees a fully valid file. Each phase reports all of its problems, one per path. YAML syntax errors now report only their position: js-yaml's reason can quote source text too (an alias or tag name), not just its excerpt. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> * feat(api)!: generate-classroom takes requirement + uploaded materials; capabilities follow server config (#1728) Narrows POST /api/generate-classroom to { requirement, materialIds? }; capabilities follow server configuration; materials go through the owner material library (upload, reference by id, delete); new GET /api/generate-classroom/capabilities; skill docs updated. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat(config): resolve a slot through the configuration layers (#1729) * feat(config): resolve a slot through the configuration layers Third P0 step of the provider configuration RFC (#1701, tracked in #1725). resolveSlot walks from a slot to its capability root and returns the first assignment it meets, consulting the deployment layer (openmaic.yml, locked) before the workspace layer at every node. An explicit null disables the subtree; nothing assigned up to the root is unassigned, with no fallback to any vendor. The result carries the provider, preset, registry entry, effective base URL, key, model, call options, fallback, where it was resolved and whether it is locked, and the requested slot's own requirements checked against the model catalogue (met, unmet or unknown). Pure and uncalled for now, so there is no behavior change. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR * fix(config): lock only written slots and trust the catalogue only where it applies Review round 1: - locked now means the requested slot itself is written in the deployment layer. Inheriting a deployment value does not lock a slot, since the workspace may still assign it (source and resolvedAt still report where the value came from). - A custom OpenAI-compatible endpoint borrows the OpenAI registry for transport only, so its preset no longer trusts that model catalogue: requirements there resolve to unknown. - The fallback is checked against the slot's requirements too, and the result exposes fallbackRequirements so retries can refuse it. - A malformed reference fails with a path-qualified SlotResolutionError that does not echo the value, which is often a misplaced key. - Requirement tests use named catalogue models instead of picking one from the catalogue at run time. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR * fix(config): order layers deployment-first and keep references out of errors Review round 2: - resolveSlot no longer depends on the order its caller passes the layers in: deployment layers always come first, for assignments and provider lookup alike. - Resolution errors name the path and the preset, never a value taken from the reference, so a key pasted into the provider position does not end up in a log. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR * test(config): cover catalogue aliases and fallback capability errors Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> * feat(classroom)!: save server-generated classrooms through server persistence (#1730) Server-side classroom generation saves the finished course create-only into the request owner's library with media in the owner's asset pool; jobs move to PostgreSQL with owner-scoped polling; /api/classroom is removed; legacy data/classrooms files are imported once in the background. Adds @openmaic/storage 0.36.0 PgAssetStore.releasePending. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat(config): translate the legacy configuration into a deployment layer (#1731) * feat(config): translate the legacy configuration into a deployment layer Deployments without openmaic.yml keep working: provider variables, server-providers.yml, DEFAULT_MODEL, MODEL_ROUTES and MODEL_FALLBACK are translated into the openmaic.yml shape and serve as the deployment layer for resolveSlot. - Providers: each configured entry becomes a provider under its preset id; force-disabled entries are left out; AliDocMind's key pair goes into credentials. - DEFAULT_MODEL goes on the llm root. Every other slot that serves stages gets what those stages used before (their route, or else the default model), written only where inheritance would give something else, so an unrouted stage under a routed parent stays on the default model. - Stages that now share a slot but had different routes, models whose provider has no server configuration, and stages that used the browser's model but would now inherit a server one become startup notices, never failures. - MODEL_FALLBACK attaches to every chat assignment without its own. - When openmaic.yml exists it wins, with a notice if legacy variables are also set. Nothing reads the layer yet; routes switch over in P1 (#1725). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR * fix(config): follow the agent driver's rules and keep notices value-free Review round 1: - The agent never used DEFAULT_MODEL: it needs a valid maic-agent-driver route and is off otherwise, so the translation writes agent: null where it would inherit a server model (and a notice for a route that does not work today). Unrouted conversation titles reuse the driver's model with thinking off, as the title generator does. - Notices name registry ids only; a credential pasted into a model variable is never repeated. - Provider entries and route options are checked against the new schema and left out with a notice (field names only) instead of producing a file that would not parse; driver-only options are dropped elsewhere. - A route with its own fallback never falls back to MODEL_FALLBACK, even when that fallback cannot carry over; the retry model is part of the comparison with the inherited value. - Force-off switches without a configured entry are reported, a legacy configuration made only of them is detected, and translating one adds a deprecation notice. - loadDeploymentLayer reads the process environment and working directory like the legacy loaders, instead of taking parameters it could only partly honor. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR * fix(config): compare routes by behavior, validate references, name only known ids Review round 2: - Stages sharing a slot are compared by what the route changes for them (model, thinking, fallback), with bare ids read as openai models and a route to DEFAULT_MODEL counted as no route; api and contextWindow are inert outside the driver. - A model reference carries over only when it names a provider from the providers section and forms a valid reference. - Section keys and force-off ids are named in notices only when they are registry ids with a preset. - Retries now follow the slot; where a call site picked its retry model by another label (scene types under scene-content, browserless generation), the change is reported. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR * fix(config): chat references need a chat entry; one notice for retries Review round 3: - A model reference carries over only when its provider was translated from the providers section, not merely declared by another section under the same id. - Routes are compared by the retry model that takes effect, so leaving out a fallback equals repeating MODEL_FALLBACK. - The per-call-site retry notices kept missing cases (streaming and calls outside server-managed routing never retry today). They are replaced by one notice, given whenever a retry model is configured, that retries now follow the slot. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> * refactor(config)!: translate only providers and the default model (#1732) * refactor(config)!: translate only providers and the default model MODEL_ROUTES does not map one to one onto slots: several stages share a slot, the agent driver and conversation titles have rules of their own, and retries are picked by call-site labels. Emulating that took most of the translation and still ended in notices that are easy to miss. The translation now carries over only the unambiguous part: providers, DEFAULT_MODEL as the llm root and MODEL_FALLBACK as its fallback (the agent stays null, as it never used DEFAULT_MODEL). A deployment that sets MODEL_ROUTES without openmaic.yml gets LegacyRoutesError asking it to write the per-stage models as slots. loadDeploymentLayer is not wired into startup yet; that happens when routes switch to resolveSlot (P1). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR * fix(config): say what a dropped MODEL_FALLBACK loses Without DEFAULT_MODEL there is no assignment to hold MODEL_FALLBACK, and calls that retried on it (server providers picked in the browser) stop retrying; the notice says so. thinkingSchema is module-private again. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> * feat(persistence): workspace model configuration with keys encrypted at rest (#1733) * feat(persistence): workspace model configuration with keys encrypted at rest The store behind the web settings of RFC #1701 (#1725, P1): one row per workspace (owner) in workspace_model_config, the same shape as openmaic.yml without policy. - Provider secrets (apiKey, credentials) are sealed per provider with AES-256-GCM under OPENMAIC_SECRET_KEY, bound to the provider id, and never stored in the config column. Without the variable, a secret is created once in data/instance-secret.key (the Docker volume). - A secret sealed under another instance secret is reported as unreadable ("enter again"), and a save that brings no new secret for that provider keeps it, so a misconfigured secret cannot destroy keys. - Saves replace the document with a compare-and-swap on the revision, after the owner's identity lock; two concurrent first saves cannot both land (PostgreSQL contract test). - A claim moves the anonymous configuration to the account unless the account has its own (core participant, order 900). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR * fix(persistence): publish the instance secret atomically; force the races in tests Review round 1: - The generated secret is written and flushed under a private name, then published with an exclusive link, so no process can read it half written and exactly one of two starting processes creates it. A file that is not a complete generated secret (empty, truncated) is refused instead of deriving a key anyone could compute. - The PostgreSQL test holds both first saves at their insert until both have read the missing row, and holds the row lock for the update case until both saves queue behind it (checked by backend pid, not by any lock waiter in the database). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR * fix(persistence): verify the written secret; require full GCM tags Review round 2: - The generated secret is written in full, read back and checked before it is published; the temporary file is removed on any failure. - Sealed values open only with a 12-byte IV and a full 16-byte tag (authTagLength), so a shortened tag cannot weaken authentication. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> * feat(config): resolve slots at request time over deployment, workspace and defaults (#1734) * feat(config): resolve slots at request time over deployment, workspace and defaults The runtime half of RFC #1701 resolution (#1725, P1): - deploymentConfig(): the deployment layer, loaded once per process. The legacy translation now splits into the deployment's providers and a separate default layer (DEFAULT_MODEL, MODEL_FALLBACK) that locks nothing and ranks below the workspace, as DEFAULT_MODEL ranked below the model a user picked. - workspaceLayer(owner) reads the web settings; requestWorkspaceId(req) names the request's owner (none for an owner minted by the request). - lookupSlot walks deployment and workspace over the whole tree first; the defaults are a second walk, so a default on a child never outranks a workspace choice higher up. - resolveStageModel builds the language model: the configured slot, else what the request still names the old way (deprecated), else the defaults, else a loud error; a slot turned off fails whatever the request names. A workspace provider's endpoint is checked like a caller-supplied one and gets the transport that refuses redirects. - resolveSlot gains the default source, and reports which layer declared the provider, its proxy and credentials. Nothing calls it yet; call sites switch next. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR * fix(config): keep request identity and workspace endpoints inside their bounds Review round 1: - A refused owner credential throws InvalidOwnerCredentialError (401) instead of resolving with the deployment's models and keys. - A retired request owner gets no workspace: canonicalizing it would hand an old anonymous cookie the claiming account's settings. Only background work, which names a stored owner, is forwarded. - A workspace provider may not be Amazon Bedrock (which falls back to the server's AWS credential chain) or set a proxy (which routes around the checked, redirect-refusing transport); the deployment still may. - Test seams for the deployment config and the workspace loader, and tests for identity, forwarding and caching. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR * fix(config): read only the named owner's settings; refuse unmet requirements Review round 2: - workspaceLayer reads exactly the owner it is given. Forwarding through a claim let a request whose owner was claimed between its check and the read see the account's settings; background work passes the owner it works for now instead. - A model the catalogue says does not meet the slot's requirement is refused before anything is built (SlotRequirementError), without consulting the request. - Tests: a database failure fails the call without falling back, a workspace provider without a key never gets the deployment's, and the transport each provider source gets. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR * test(identity): exempt the model settings lookup from cookie forwarding requestWorkspaceId resolves the owner only to pick whose model settings a generation call uses, and returns no response of its own, like the vision prompt helper already listed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> * feat(config)!: resolve every LLM call through its capability slot (#1735) * feat(config)!: resolve every LLM call through its capability slot The LLM call sites of RFC #1701 switch to slot resolution (#1725, P1): - resolveModel resolves a stage through its slot for the request's or job's workspace: the deployment and workspace configuration first; the model and key a request names (x-model, x-model-routes, body fields) only for a slot left unassigned, deprecated; then the defaults an older deployment set with DEFAULT_MODEL. Routes that read headers get this through resolveModelFromRequest; the chat routes pass their workspace; background work (agent runs, titles, generation jobs) passes the owner it works for now. - Retries follow the slot: a slot-resolved model carries its slot's fallback (lib/ai/model-fallbacks.ts), which callLLM and the outline stream use; only a model from the request path still retries on MODEL_FALLBACK. A fallback that cannot meet the slot is not used. - The agent driver resolves the agent slot: tool calling required, api defaults to openai-completions, thinking.effort still refused. Conversation titles resolve agent.title with thinking off unless the title slot sets it. - /api/generate-classroom resolves each step through its slot for the job's owner, its outline through course.outline. - MODEL_ROUTES is gone: loadDeploymentLayer runs at startup and a server that still sets it without openmaic.yml refuses to start. The pbl-chat and maic-agent stage keys, which nothing resolved, are removed. BREAKING CHANGE: MODEL_ROUTES is no longer read; write per-stage models as slots in openmaic.yml. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR * fix(config): a slot fallback needs no serverManaged stamp; follow claims per stage Review round 1: - callLLM arms a model's attached slot fallback whether or not the caller passes serverManaged (the PBL agents pass none); the stamp still gates MODEL_FALLBACK on the request path. - /api/generate-classroom resolves the owner the job works for now at each stage, so stages after a claim follow the moved settings. Streaming calls (agent driver, classroom chat, PBL streams) still do not retry on a fallback model, as before this change; that is the #1725 item on retries at every call site. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> * feat(config): resolve media and tool capabilities through their slots (#1737) * feat(config): a provider-only reference names the provider's default model Search and document providers mostly have no model to pick, so a slot may name just the provider (`webSearch: tavily`); the target then has no modelId and the adapter uses its default. Chat slots still need providerId:modelId, checked in openmaic.yml and at resolution. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR * feat(config): resolve media and tool capabilities through their slots The capability routes and server paths of RFC #1701 (#1725, P1): text to speech, speech recognition, images, video, web search and document extraction resolve through their slots, like language models. - lib/server/model-config/media.ts: the configured slot (deployment, then workspace); else the provider a request names the old way (deprecated); else the legacy defaults; else a loud error. A slot turned off fails whatever the request names, and while the legacy <CAP>_<VENDOR>_ENABLED=false switches are in effect a switched-off provider stays off whoever assigns it. A workspace endpoint is checked like a caller-supplied one and may not use a proxy. - The legacy translation assigns the media roots the provider the server picked when a request named none (first configured; web search by its old priority; DEFAULT_IMAGE_PROVIDER for images), with the first pinned model. - Routes: /api/generate/{image,video,tts,voice}, /api/transcription, /api/web-search, /api/parse-pdf and /api/extract-document. A TTS voice applies only to the provider it was chosen for; voices register on the tts slot's provider. - Server paths: agent image and video generation, scene narration, the voice catalog and registration, web search and fetch_url, material extraction (the document slot's service, the asr slot for local transcription), browserless classroom media, and the generation capabilities reported by /api/health and the classroom capabilities endpoint. - A provider-only reference (`webSearch: tavily`) names the provider's default model; chat slots still need providerId:modelId. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR * fix(config): keep slots authoritative on every media path - Workspace providers reach media, search and document services only at their preset's endpoints; a custom endpoint or proxy is deployment-only (403 INVALID_URL). - A configured or turned-off document slot decides the extraction service: deprecated request fields may only pick a self-contained extractor, and legacy operator credentials are no longer reached. - Request paths resolve their own workspace without following a claim; background extraction still does. - A configured TTS slot keeps its own model; legacy pins apply only on the deprecated and default paths. - Local transcription and agent voice registration keep the connection's network policy flags. - The agent's web search forwards the whole resolved configuration. - An unusable DEFAULT_IMAGE_PROVIDER leaves the image slot unassigned with a notice instead of switching vendors. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR * fix(config): public presets only for workspaces, plan default models - A workspace preset whose default endpoint is on the server's own network (self-hosted TTS, ASR, image) is deployment-only, and a workspace provider's endpoint runs under the public-only policy. - A provider-only reference to a token plan means the plan's own default model for that capability. - On the legacy default provider, the request's image, video and ASR model still applies through its allowlist, and image/video without a model is still MISSING_MODEL. - An asr assignment a workspace may not use no longer blocks document extraction, and a refused document service answers 403. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR * fix(config): keep legacy search and voice choices on their old paths - Classroom search honours the provider and key a request names while the webSearch slot is unassigned; only /api/web-search prefers the operator's configured backend, as before. - A TTS request's voice applies unless the request chose it for another provider. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR * fix(config): legacy search model and capability discovery errors - On the legacy default search provider, the request's search model still applies through the server's pins. - Capability discovery answers a refused credential (401) or a workspace service it may not use (403) instead of 500. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR * fix(config): classroom submission refuses a workspace service with 403 A classroom submission whose materials would go to a document or speech service the workspace may not use answers 403 INVALID_URL instead of 500, and its material check resolves the request's own workspace without following a claim. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR * fix(config): workspace image and video providers run public-only A workspace-configured image or video provider now uses the strict public transport for its requests and for the redirects its clip download follows, whatever ALLOW_LOCAL_NETWORKS says; the operator's own providers and the deprecated per-request path keep their policies. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR * Revert "fix(config): workspace image and video providers run public-only" This reverts commit 68021ded. A workspace provider cannot choose an endpoint for media, search or document services at all: a custom base URL or proxy is refused, and so is a preset whose default endpoint is on the server's own network. What remains is a preset's fixed public endpoint, the same one a deployment default uses, so these calls keep the operator's transport policy like every other preset endpoint, and the connection no longer claims a user-typed endpoint (userEndpoint is false). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR * fix(config): /api/web-search without a provider searches as before A deprecated web search request that names no provider again means the server's configured provider, else the default one with the request's key, while the webSearch slot is unassigned. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR * fix(config): browser speech recognition does not count for extraction An asr slot assigned to speech recognition that runs in the browser gives server-side extraction no transcription service, so audio uploads are not advertised or accepted for extraction. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR * fix(config): /api/parse-pdf honours an explicit local parser A request that asks for a self-contained extractor (local parsing) parses the PDF locally and sends it to no document service, whatever the document slot names, as /api/extract-document already did. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR * test(classroom): give the persistence contract its media slots The generated-classroom PostgreSQL contract configured its image and TTS providers through the legacy provider mocks, which slot resolution no longer reads; it now sets the deployment's slots. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> * feat(llm): streaming calls fall back on their slot's fallback (#1739) * feat(llm): streaming calls fall back on their slot's fallback A stream resolved through a slot that fails before any content (the request is refused, or the first part is a retryable error) runs once on the slot's fallback model. A failure after content has started still reaches the caller. The outline stream keeps its own retry and fallback handling. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR * fix(llm): the stream fallback is the call's last attempt, before any content - The primary's own SDK retries run first; the fallback runs once, as the last attempt, and a failing fallback is not retried. - No fallback once content reached the caller in any step (a tool that ran must not run again); after a fallback took over, later steps stay on it. - The fallback gets the thinking options built for its own provider and model, not the primary's. - Usage of a stream with a slot fallback is recorded per step, against the model that served the step. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR * fix(llm): stream fallback recognises transient error payloads Providers send a stream's error part as a plain { type, message } payload rather than an error; a transient type (overloaded, rate limited, server error) now counts as retryable for the slot fallback, while authentication and request errors still do not. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> * feat(config): model settings API for workspaces (#1740) * feat(config): model settings API for workspaces GET/PUT /api/model-config read and edit a workspace's slots and providers: every slot with its own assignment, effective model, source and lock state; deployment providers read-only and without credential details; workspace keys write-only and masked. Edits are checked against the whole configuration and a revision. The view carries the preset catalogue a workspace may add providers from, and each provider's models per capability, so the settings UI needs no registry code of its own. Workspaces cannot add Bedrock, self-hosted media/search/document presets or custom endpoints for anything but chat. POST /api/model-config/import merges settings a browser kept, item by item under the same checks, never replacing what the workspace or deployment already has. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR * refactor(config): preset ids as client-safe data The preset id tables move to lib/config/preset-ids.ts, which imports no registry, so browser code (the settings import) can name presets. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR * fix(config): tighten the settings API's views and checks - Effective targets name an endpoint only for the workspace's own providers; a deployment's endpoints never reach a response. - A workspace provider of a local model server preset (whose default endpoint is the server's own network) must name its own endpoint. - Every change is checked against the stored shape, so an import skips a malformed item instead of failing the batch, and PUT validates its whole body (400, never 500). - Clearing a key deletes it even when the instance can no longer open it. - A workspace provider with its own endpoint offers chat models only, and an OpenAI-compatible provider offers only the models it lists. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR * fix(config): no credentials in endpoints; key-pair presets stay in the yml - A workspace base URL cannot carry a username or password (they would be stored and shown in the clear), and views never show credentials a stored endpoint carries. - Presets that authenticate with a key pair (AliDocMind) are not offered to workspaces: the settings take one key per provider. - An import checks each proposed provider on its own, so one malformed provider is skipped instead of failing the batch. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR * fix(config): settings neither offer nor accept switched-off providers A provider the operator switched off for a capability (the legacy <CAP>_<VENDOR>_ENABLED=false switches) is left out of the capability lists, refused as an assignment, and shown as invalid where a deployment assigns it, as the calls themselves refuse it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR * fix(config): recommendations name only capabilities a preset still offers Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR * fix(config): a provider id follows the reference grammar before it is used Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR * fix(config): the catalogue lists the models a search provider offers Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> * fix(config): refuse a fallback on a slot whose calls never use one (#1743) * fix(config): refuse a fallback on a slot whose calls never use one Only language-model calls retry on a slot's fallback; a fallback on a speech, image, video, search or document slot was accepted and silently did nothing. Resolution now refuses it by path, so openmaic.yml fails at startup and the model settings refuse it on save. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR * fix(config): openmaic.yml refuses a non-chat fallback at startup The boot check parses openmaic.yml without resolving slots, so the refusal also lives in the file's cross-checks. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> * feat(settings): Models section with the course model map (#1741) * feat(settings): client for the workspace model settings A small client for /api/model-config: it caches the view per page, applies changes against the revision it read, reloads on a stale revision (409 CONFLICT) or a slot the deployment has since locked, and treats a server without persistence (404) as settings managed by the server. Pure helpers behind the settings UI live beside it: slot changes from the card picker (follow, off, a model, a fallback, the media switches), the provider form's change (keys stay write-only: keep, replace or remove), the first-run setup that adds a provider and fills only the empty, unlocked slots with its preset's recommendations, and the layout of the course model map. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR * feat(settings): Models section with the course model map A new "Models" section, first in the settings dialog and the one it opens on. It reads and writes everything through /api/model-config: - The model map: a pannable, zoomable canvas with the course pipeline as a two-row serpentine flow, the default language model above the stations that inherit from it, and the page types under the content station. Each card shows the effective model, where it comes from and a lock when the server sets it. Solid edges follow the parent, dashed ones mark a setting of the slot's own. - Editing happens in place: a picker at the card to follow the parent, pick a model a provider offers (or a provider, for search and document slots), turn the slot off, or set a fallback for chat slots. Media switches turn a slot off and back on. - While no language model is configured, the default model's card offers a first-run setup: pick a service, give its key, and its recommended models fill every empty slot. - A Providers tab lists the server's providers (read-only) and the workspace's own, which can be added, edited and removed as the server's policy allows. The generation toolbar's "configure provider" prompt opens this section. Below the sm breakpoint the settings nav becomes a strip above the panel. Strings are translated for all 12 locales. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR * fix(settings): keep what a change does not mean to touch - A provider edit carries only the fields the form shows: a hidden model list or endpoint is left out, so the server keeps it; a shown field that is emptied is removed. A pinned model list keeps its field on edit. - A key the server cannot read starts as "replace" (it can also be removed), so a key typed for it is sent instead of the broken one being kept. - Switching a media slot off remembers what it held; switching it back on restores that assignment (or nothing of its own), and when that is not known the caller asks instead of clearing the slot to the default. - The first-run assignments are checked against the provider as the server answered it: a provider with its own endpoint serves chat only, a model list the user gave wins over the recommended chat models, and `llm` is one of the provider's own chat models. The filling can be retried against a reloaded view, and a refusal keeps its reason. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR * fix(settings): recoverable first-run, root clear action, keyboard on the map - A first-run setup that added its provider but could not assign it (a stale revision, say) reports to the section, which keeps a notice with the provider and a way on (assign its models again, or add them under Providers) through any reload, instead of losing it with the form. - The picker of a root slot with a setting of its own offers to clear it, leaving the server's value or default. - A card's switch restores what the slot held; when that is unknown it opens the picker. - Keyboard focus on a card outside the view pans the map to it, and the arrow keys pan the map while it has focus. - The provider form offers only replace or remove for an unreadable key, and says that a provider with its own endpoint serves chat only. Component tests drive the provider form, the switches, the root picker, arrow-key panning and a first-run setup that meets a conflict. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR * fix(settings): keep picker row labels whole; one unreadable-key notice A long note no longer truncates the row's label in the slot picker, and the provider form stops repeating the unreadable-key warning the row already shows. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR * fix(settings): a lost answer to a write is an outcome, not a throw When the server takes a change but its answer cannot be read (a truncated body, a dropped connection), apply() no longer rejects: it reloads the view to reconcile a change that may have been saved and returns an `unconfirmed` outcome with the reloaded view. The first-run setup goes on when the reloaded view has the provider it added, counts a slot write whose answer was lost as done when the reload shows the default model set, and says so otherwise. Busy states in the provider form, provider removal, first-run setup and its notice are reset in `finally`. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR * fix(settings): keyboard roving in the slot picker The picker's rows are plain buttons (pressed for the current choice) in a group, not listbox options without the keyboard model those promise. The list is one Tab stop, the current choice or else the first row; ArrowUp and ArrowDown step, Home and End jump, Enter and Space choose. The picker opens on that row. The picker's and the card switches' busy states are reset in `finally`, and the component tests cover the lost-answer first-run paths and the picker keyboard. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR * fix(settings): a write lost in transport is unconfirmed, and first run can resume A write whose request fails in transport may still have been saved, like one whose answer is lost: apply() now reloads the settings and returns an `unconfirmed` outcome in both cases, with the reloaded view when that read worked. A first-run setup whose provider add cannot be confirmed (the reload failed too) keeps a notice that says so, and "Check again" reads the settings and either assigns the provider's models or says it was not added. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR * fix(settings): retry an unconfirmed provider add under the same id The add-provider form keeps the id it tried. When the answer was lost it closes if the reloaded settings show that provider, and otherwise retries under the same id, so a save that did land is updated rather than joined by a second provider; a genuinely new add still gets a fresh id. Component tests cover this and the first-run paths where the provider add fails in transport after the server saved it, or cannot be confirmed at all. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR * fix(settings): 5xx answers to writes are unconfirmed; reads never go back A server error or a gateway timeout can come after a write was saved, so a 5xx answer to a write is treated like a lost answer: the settings are read again and the outcome is `unconfirmed` with the reloaded view. A 4xx is still a refusal, without a reload. Reads and adopted write answers are numbered: a read's answer is dropped when a later read started or a write's answer was adopted after it began, or when its revision is older than the view held. The reads that must see a write just made (after an ambiguous write, a conflict, "Check again") start a fresh request instead of joining one begun before the write. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR * fix(settings): first run is done only once the default model is set A slot write that succeeds without setting `llm` (another session turned it off meanwhile, so only media slots were filled) no longer counts as a done setup: the notice stays and says the default model is still missing, for the user to pick on its card. Test views now carry `llm` resolved the way the server answers it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR * fix(settings): the revision decides which settings view is newer A view with a lower revision than the one held never replaces it, whether it comes from a read or a write's answer, and one with a higher revision always does, whenever it arrives; the order in which reads and adoptions were numbered only breaks ties at an equal revision (and decides while no view is held, so a read begun before the view was forgotten does not bring it back). A write whose answer is older than a view read meanwhile returns the view that is current. `adopt` takes a view or null (forget it) and is part of the client. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR * fix(settings): send each change against the view it was worked out from apply() takes the view a change was computed from and sends that view's revision, rather than the revision of whatever view is held when the write goes out. A retry of the first-run assignments that waits for a reload in flight is therefore refused (409) when the reload shows the settings changed, instead of overwriting a slot set meanwhile; the reload then feeds the next attempt. The slot picker, the card switches, the provider form, provider removal and the first-run setup all pass the view they showed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR * fix(settings): a provider edit sends only what changed, and rebases on a conflict The provider form keeps the basis of an edit: the provider as the edit began and the view it came from. A save sends only the fields changed against that basis (a key-only edit sends the key), against the basis view's revision. When the provider changed elsewhere meanwhile (409), the edit moves onto the reloaded provider, keeping only what the user changed, and the form says the provider changed before it is saved again. A 409 now returns the reloaded view to work the change out again from. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR * fix(settings): a refused provider add keeps its draft and picks a free id Only an add whose outcome is unknown (a lost answer, a transport failure, a 5xx) is reconciled by looking for its id in the reloaded settings. A confirmed refusal (a 409, another tab having added the same preset under the same id meanwhile) saved nothing of ours: the form keeps the draft, shows the conflict, and the retry uses an id still free in the reloaded view. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> * refactor(settings)!: server-side model settings only; import browser settings once (#1744) * feat(settings): one-time import of browser model settings The settings store's migration to version 5 builds an import proposal from the provider state earlier builds kept in the browser (keys, custom endpoints, the chosen model, token plan enrollment, per-capability selections) and keeps it under its own localStorage key. Once the store has hydrated, the proposal is posted to /api/model-config/import: a 2xx answer removes it (and the keys) from the browser, 400 drops it, and anything else keeps it for a later load. Per-stage routes are not imported. Clear Local Cache keeps a proposal still waiting. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR * refactor(settings): stop sending provider data from the browser Every request now leaves model and provider choice to the server, which resolves it through the workspace's capability slots: no model, key or base URL headers (x-model, x-api-key, x-model-routes, x-image-*, x-video-*), no provider, key or thinking fields in chat, TTS, voice registration, transcription, web search or document extraction bodies. The server keeps accepting them until they are retired. What the client still needs to know is read from the /api/model-config view (lib/model-settings/capabilities.ts): whether a language model is set up, which provider the tts slot names (browser speech plays locally; voice lists follow that provider), whether speech input, image, video and web search are available, and the course model the toolbar shows or edits through the settings API. The scene concurrency comes from /api/health. The unused TTS config popover and getCurrent*Config helpers are removed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR * refactor(settings)!: remove the browser provider state and its settings UI The settings store keeps only the user's own preferences (playback, narration voice and speed, speech input language, outline review, agents, layout). Providers, keys, base URLs, the model choice, thinking settings, per-stage routes, token plan enrollment and seeds, the per-capability provider configs and selections and their on/off switches are gone; the version 5 migration sets them aside for the one-time import and drops them. The narration voice now records the provider it was picked for and applies while the tts slot names it. The Token Plan, Model Services and Course Model sections and their components are removed, with apply-token-plan, the server provider sync (ServerProvidersInit, fetchServerProviders) and helpers only they used. A Voice section keeps the per-user parts of the old speech settings: speed, a narration test, the VoxCPM and Qwen voices, and the speech input language, showing which services the workspace uses. The home toolbar picks the course model through the settings API, or shows it read-only when the deployment locks it. e2e fixtures answer /api/model-config instead of seeding browser providers; the managed-provider spec, which covered the removed panel, is removed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR * feat(settings): browser speech recognition while the asr slot is unassigned Speech input worked out of the box through the browser's own recognition, which needs no server provider; it stays available while the asr slot is unassigned, and turning the slot off turns speech input off. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR * fix(settings): bind the model settings import to its owner and never lose staged keys The import now asks for the browser's legacy import binding and sends X-OpenMAIC-Legacy-Import, so owner resolution refuses it for any owner that does not hold the browser (409 LEGACY_IMPORT_NOT_BOUND keeps the proposal for a later load); the import route joins FENCED_ENDPOINTS. Its completion is the proposal's own key, apart from the course ledger. Staging reports whether it succeeded. When the proposal cannot be written (a full storage, an unreadable proposal waiting), the migration keeps the old settings in legacyModelSettings and every load retries, writing the store back without them once staged. Nothing logged quotes the proposal or an error message: fixed text, item ids, error names. Older shapes are normalised before the proposal is built (version 0 default model, the single TTS model setting, global TTS/ASR model ids, a TTS provider's model field, the flat web search key). Capabilities the user turned off are proposed as off (null), and selected server-configured media providers are named by their preset id. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR * fix(settings): retry a failed read of the model settings A failed read of /api/model-config with nothing to show is read again with a backoff (2 s up to a minute) until one succeeds, instead of leaving the page without capabilities for the rest of its life. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR * fix(settings): send no provider or model with voice registration Voice registration, deletion and auto-registration no longer send providerId or ttsModelId: the server registers with the provider and model the tts slot resolves to. The model still derives the voice id and keys the session memo. The unused OpenRouter model list hook, which called the vendor with a browser key, is removed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR * fix(settings): carry only an explicit speech input off into the import Only `asrEnabled: false` becomes an off slot (`asr: null`): without an asr slot the browser's own speech recognition would take over, which the user had turned off. The TTS, image, video and web search switches were per-browser toggles that availability following the slots replaces on purpose; turning them into lasting workspace nulls would override the deployment's defaults from then on, so they are not carried over. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR * fix(settings): gate chat and generation on the slots they resolve Chat and discussion resolve the classroom slot, and course generation its outline, actions and content slots; each is now allowed whenever those resolve to a model, even with the llm root unassigned or off (a child assigned on its own). Settings not read yet still block nothing. The toolbar offers "Set up model" only when a course cannot be generated. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR * fix(settings): import models the user added to built-in providers A built-in chat provider whose model list held models the user added (ids not in its catalogue) is proposed with those models, listed after the catalogue's: a provider's model list names the chat models it serves, so listing only the added ones would hide the catalogue. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR * fix(settings): wait for the commit explicitly in the media overlap test The overlap test counted 50 microtasks for the mocked commit to be reached; with the capability read before each pass that is no longer enough, so the test failed alone and left a pass running into the next test. It now awaits a signal from inside the commit, flushes every pending microtask before asserting, and releases and awaits both passes in `finally`. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR * fix(settings): import models the user added to an enrolled token plan An enrolled plan's chat provider whose model list held models the user added (ids not in the plan's own list) is proposed with them, listed after the plan's, by the same rule as built-in providers. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR * fix(settings): scope voice registration memos to the tts slot's provider The session memo and in-flight map of auto-voice registration were keyed on the voice and model only, so after the tts slot moved to another backend serving the same model, registration was skipped and synthesis named a voice that backend never registered. They are now scoped to the provider the slot resolves to (provider id, registry entry, endpoint) and the settings revision, captured once per registration. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR * fix(settings): show the view the model settings import produced After a successful import the page read the settings again, but that read could join the page's first read, still in flight from before the import, and leave the unassigned view in place. The import now hands over the view the import route answered, and adoptNewerView lets any read in flight finish before keeping whichever view is newer by revision, so an older answer cannot land over it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR * fix(settings): offer and resolve voices against the tts slot's model With the tts slot on a model that cannot speak some voices (an OpenAI slot on tts-1 and Marin or Cedar, which need gpt-4o-mini-tts), the picker still offered them and synthesis on the slot's model failed. The slot's model (its own, else the provider's default) is now the model voices are offered under and checked against: the voice lists hold only voices it can speak, a persisted voice it cannot speak falls back to a compatible default, and narrator and agent bindings to such a voice are not used. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR * fix(settings): drop translations only the removed provider panels used 208 keys that only the removed provider settings (model services, token plan, course model config, their dialogs and the TTS popover) used are removed from all 12 locales: API key and base URL fields, provider and model editors, connection tests, per-capability switches and the like. Each was checked to have no remaining reference, literal or through a key prefix built at run time. The TTS enablement locale test keeps the key still in use. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR * fix(config): provider options in openmaic.yml, passed to the TTS adapters A provider in openmaic.yml may carry `options`: non-secret, provider-specific settings (a map of names to strings, numbers or booleans; `${VAR}` interpolation applies). They reach the resolved slot target, the media connection and the settings view, and the server's TTS paths (the TTS and voice routes, scene narration, classroom media generation, the voice-clone tools) hand them to the adapter as its providerOptions: over a request's options for a configured slot, under them while the slot is unassigned (the deprecated path). Voice registration follows them too (a VoxCPM backend without runtime registration is refused). Workspace providers cannot set options: they are the deployment's. The legacy configuration had no equivalent. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR * fix(settings): read the VoxCPM backend from the tts slot's options The voice settings, the voice lists and auto-voice registration assumed the default VoxCPM backend once the browser no longer chose one. They now read the `backend` option of the provider the tts slot resolves to (shown in the settings view), and the registration memo is scoped to the provider's options as well. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR * fix(settings): label the preset choice when adding a provider Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR * fix(settings): media planning from the slots; stop when settings are unread The outline route decided whether to plan images and video from the client's x-image-/x-video-generation-enabled headers, and the client sent them from settings it might not have read: a course could be generated silently without media. The route now reads the workspace's image and video slots itself (an explicit `false` header still lets an API client opt out; `true` turns on nothing), and the client no longer sends them. When the model settings cannot be read even after another try, generation stops with a message instead of leaving out narration (the scene fails and generation pauses; the preview's first scene errors), and a media pass or retry stands down without marking anything as disabled. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR * fix(settings): pick the course model when none is set Without a default model the toolbar showed no picker, so the course model could only be set in Settings. It now offers the picker whenever the workspace may set the llm slot and chat models exist, with nothing selected until one is picked (which sets the slot; the server assigns nothing on its own). The legacy translation's notice for a model whose provider the server does not configure now says each workspace chooses its model in Settings → Models, or to configure the provider and set DEFAULT_MODEL. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR * fix(settings): import keyless search choices and the Claude search model A keyless search service (Brave) that the user selected with research switched on is proposed as a provider of its preset with the webSearch slot on it; untouched defaults and self-hosted services (SearXNG, whose endpoint only the deployment may set) are not. The model picked for Claude web search is carried as `claude:<model>` instead of being dropped. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR * fix(config): refuse credential-like provider option names A provider's `options` are shown in the settings view, so a name that matches key, secret, token or password is refused with a pointer to apiKey and credentials. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR * fix(settings): pin which course slots the generation gate needs courseGenerationUsable requires the outline, actions and a content slot. The agents and research slots are not required, and a test pins it: with course.agents off the roster falls back to the preset agents, and with course.research off web search runs on the raw requirement, so neither request is refused by the server. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR * docs(settings): what the import leaves behind; extraction is a slot The importer README lists what is not carried over and where it is set now: per-stage routes, the per-browser media and research switches, Baidu sub-sources, thinking settings and the VoxCPM backend (options in openmaic.yml). The document extraction README no longer documents the removed store fields and request-level provider fields: extraction is the workspace's document slot. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR * fix(settings): release the recorder lock when a start is refused startRecording returned early without clearing its lock when speech input was not set up (the asr slot off, or still unknown while the settings loaded) or the browser lacked speech recognition, so every later click did nothing until the component remounted. Each early return now releases the lock. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR * fix(settings): decide research from a successful settings read The home page saved `webSearch: undefined` into the generation session when the model settings could not be read, and the preview researched only on that saved flag, so a transient failure skipped research for the whole generation. Saving the session now needs a successful read (read again after a failure, else it stops with the "settings could not be read" message), and starting or resuming generation decides research again from the current webSearch slot. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR * test(settings): type the research decision session Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> * fix(config): a policy without workspace providers also stops using them (#1750) * fix(config): a policy without workspace providers also stops using them `policy.allowWorkspaceProviders: false` only stopped workspaces from adding or editing providers; ones added before kept serving their assignments. Resolution now leaves them out, with the workspace assignments that name them (an assignment whose fallback alone names one keeps its model), so the policy takes effect for existing workspaces too and the settings view shows what the calls use. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR * fix(config): policy keeps shared provider ids and refuses new references - A workspace provider id the deployment also declares resolves to the deployment's provider, so its references are kept under the policy. - The model settings refuse a new assignment to a workspace provider the policy no longer allows, while other edits leave dormant ones as they are. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR * fix(config): settings validate a workspace as the policy lets the calls see it Assignments the policy leaves dormant are not checked on unrelated edits. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> * docs(config): openmaic.yml, capability slots and the model settings (#1742) * docs(config): document openmaic.yml, slots and the model settings The configuration docs are rewritten around openmaic.yml: providers, the slot tree and its inheritance, assignment forms, provider-only references, fallbacks, turning a capability off, locks and the workspace model settings, policy, OPENMAIC_SECRET_KEY, what workspaces may add, the deprecated request fields, and migration from provider variables, server-providers.yml, DEFAULT_MODEL/MODEL_FALLBACK and MODEL_ROUTES (with the stage-to-slot table). The environment-variable reference stays as the legacy configuration. Deployment, supported models (a preset catalogue), getting started and VoxCPM2 point to openmaic.yml; the READMEs' quick configuration and the agent runtime example use it; the openmaic skill tells agents to configure models through openmaic.yml or the model settings. openmaic.example.yml is a starting point, and a test parses it and every documented openmaic.yml example with the real schema. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR * docs(config): translate the openmaic.yml documentation Brings the zh-CN, zh-TW, Japanese, Russian and Arabic pages in line with the English configuration, deployment, supported models, getting started and VoxCPM2 pages: the same sections, examples and tables, with the legacy environment-variable reference kept as before under its own heading. Also fixes the zh-CN VoxCPM2 link to the TTS section, which pointed at the English anchor. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR * docs(config): match the settings UI names, set a model in the quick start - Configuration: the settings tabs are "Model map" and "Providers", the lock reads "Set by the server", and the first-run setup is "Connect a service". Browser import: keys are removed from the browser once imported (a failed import keeps them), and speech input that was switched off becomes asr: null. - Getting started: the browser no longer sends a model, so the quick start sets one, with a minimal openmaic.yml or DEFAULT_MODEL next to the key, and names Connect a service in Settings > Models as the path without a file. - openmaic.yml is in .dockerignore so a file with inline keys is never baked into an image; the deployment page says to mount it at run time. All six locales are updated where the text changed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR * docs(config): scope fallbacks, trim the example, translate volume repair - Fallbacks apply to language-model slots (llm and the slots below it); a fallback on a media, search or document slot is refused at startup or on save. - openmaic.example.yml is copied as-is by the READMEs, getting started and the skill, and startup refuses any unset ${VAR}: it now has one active provider (OPENAI_API_KEY) and the llm slot, with every other provider and slot as a commented optional block. The copy instructions say so. - The translated deployment pages gain the root-owned volume repair paragraph and its chown command. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR * docs(config): configure self-hosted and custom media endpoints in openmaic.yml Workspaces cannot add custom non-chat endpoints or self-hosted media presets, so the docs no longer send users to the browser settings for them: - Configuration (legacy reference): custom OpenAI-compatible TTS and ASR endpoints and ComfyUI are declared in openmaic.yml with their baseUrl and assigned to tts / asr / image; custom chat endpoints are an openai-compatible provider in openmaic.yml or the Providers tab. The claim that ASR configuration stays in client settings is gone. - VoxCPM2 (page and READMEs) is configured in openmaic.yml, with the legacy variable as the fallback; the per-browser Base URL option is removed. - MinerU in the READMEs is a document provider in openmaic.yml. - Deployment and ComfyUI: providers declared in openmaic.yml are server-managed and may use private endpoints without ALLOW_LOCAL_NETWORKS, which only matters for endpoints a user types (chat base URLs in the model settings, deprecated request fields). All six locales are updated. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR * docs(config): document provider options and the VoxCPM2 backend option - Configuration: the provider fields table documents `options`: non-secret, provider-specific settings (string, number or boolean values, ${VAR} allowed), shown in the model settings so never a key, and deployment-only. - VoxCPM2 (page and READMEs): the backend is chosen with options.backend on the provider in openmaic.yml (vllm-omni by default, python-api, nano-vllm), not in the browser settings; voice registration works only on vllm-omni, and the troubleshooting row points at options.backend. - The example test skips openmaic.yml blocks that set provider options until the schema on this branch accepts them (marked TODO). All six locales are updated. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR * test(docs): check the VoxCPM2 examples now that provider options exist Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR * docs(config): describe the browser import, Voice settings and web search as released - Browser import: the proposal and its keys are removed after any 2xx (even with skipped items, which are kept nowhere), and after a 400 or an unreadable proposal; it stays for another attempt only on 401, 404, 409, 5xx or a network error. Lists what has to be set up again by hand (per-stage models, thinking settings, custom speech and transcription providers, AliDocMind's key pair, the VoxCPM backend, Baidu sub-sources) and that the per-browser capability switches do not carry over. - VoxCPM2 voices (page and READMEs): assign VoxCPM to tts, then Settings > Voice > VoxCPM voices, which appears only when tts resolves to voxcpm-tts. - Web search has no per-generation switch: it runs when the workspace's webSearch slot resolves, and turning it off affects later generations. - The READMEs' persistence section no longer says keys go in .env.local only. All six locales are updated. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR * docs(config): startup errors name the field, or the line for broken YAML Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> * refactor(settings)!: main's settings UI on the server-side model configuration (#1767) * feat(config): test a saved provider from the settings by its id The settings test buttons (and the model list fetch) name a provider the workspace has configured instead of sending its key and endpoint: the server resolves it from the deployment or the workspace's own configuration, under the same rules as a slot (the policy, self-hosted media presets, custom endpoints). - verify-model, verify-image-provider, verify-video-provider and verify-pdf-provider take `provider` (and `model`); probe-models takes `provider` for one of the workspace's own chat providers. - generate/tts and transcription take `previewProvider` (and `previewModel`) for a settings preview of a provider other than the slot's. - The settings view's catalogue models carry what the registry knows of them (capabilities, thinking controls, context window) and the registry entry that serves each capability, for the settings to show. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR * refactor(settings)!: bring back the Token Plan, Model Services and Course Model sections on the server's configuration The settings dialog gets its earlier information architecture and layout back: Token Plan, Model Services (one tab per capability: language models, image, video, text to speech, speech recognition, document parsing, web search, each with its list of services and their panels), Course Model Config (the pipeline with a model per stage), Skills and General. The model map with its provider list and the separate Voice section are removed. The panels read and write the workspace's model configuration on the server instead of the browser store: - A service's key, endpoint (chat services only) and model list are its workspace provider (id = the service's preset id); keys are write-only (a mask is shown; replace or remove). A newly added service fills the root slots of what it serves that have nothing set yet. - Services the deployment configures are shown read-only; key-pair, self-hosted and (under a policy without workspace providers) all other services say that only the server's configuration can set them up. - The Course Model main model is the `llm` slot (with its thinking settings); each stage is its slot (follow the main model = no assignment of its own); the media switches set their root slot to null and restore what it held; locked slots are shown disabled. - Token Plan: connecting adds the plan's provider and fills the empty, unlocked slots it recommends; disconnecting removes it. - Test buttons name the saved service; no key leaves the browser. - The narration speed and test are back in the text-to-speech panel and the recognition language in the speech recognition panel. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR * feat(toolbar): the course model picker and extractor as before, on the server's slots The home toolbar's model picker gets its thinking control back (the `llm` slot's thinking settings) and its groups in the plan-first order the settings use, and the course material popover gets its extractor select back: it sets the `document` slot (a built-in parser that needs no key is added on first use). The set-up prompt opens Model Services. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR * docs(settings): name the Token Plan, Model Services and Course Model sections The docs, READMEs and messages pointed at the Models section (model map, providers tab, "Connect a service") and the Voice section, which are gone. They now name the sections the settings have again: a plan's key in Token Plan, a service's key in Model Services, per-stage models and capability switches in Course Model Config, VoxCPM voices in Model Services → Text-to-Speech. The server-side explanations (locks, write-only keys, deployment-only services, the one-time import) stay. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR * fix(settings): web research dims only when search is off, as before Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR * fix(config): list a saved provider's models without naming a model Fetching the models of a saved chat provider passed its bare id to the chat resolver, which requires `provider:model`, so every request was refused. The provider's connection is now resolved without a model (still only the workspace's own providers, and the endpoint is still checked). The settings view test also covers an OpenAI-compatible deployment provider's listed chat models. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR * feat(settings)!: Course Model Config is the course model map Course Model Config shows the course model map again in place of the earlier pipeline panel: the default model card on top, the pipeline as a two-row serpentine with the content station expanding to its page types, editing on the card (follow the parent, a model or off, and a fallback for chat slots), "Set by the server" locks, solid and dashed lines, zoom, pan and keyboard. Its provider tab and first-run card stay out: services are set up in Model Services and Token Plan, and the map links to Model Services where it needs one. - One switch implementation (flipSwitch): off sets the slot to null, on restores what it held, else takes the first service that serves it (speech input returns to the browser's recognition), else opens the picker. What it turned off is remembered, and what it restored forgotten, only once the server confirmed the change. - Speech input shows its switch while it runs in the browser (unset), and a service can be picked for it then. - The server's providers count wherever chat models are offered (the map, the toolbar, Model Services); the map's "no model" note shows only when nothing at all offers one. - Model Services names a provider by its preset and id ("OpenAI-compatible · gateway", with a generic logo), and shows one named after a built-in service as that service's entry. - A typed key stays in the field when the server refuses it; it is cleared once saved (Doubao's paired fields too). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR * fix(settings): provider logos in the model pickers; thinking, keyless services and model discovery on the map Logos: the home toolbar's model picker shows the provider's logo again, on the pill and on every group and model, as it did before the settings restructure; so do the course model map's card picker and its read-only pill. A provider looks the same everywhere: its plan's or built-in service's logo (a provider named after one included), its preset's, or the generic service icon for a custom endpoint, as in Model Services. The naming and logo helpers move to a data-only module the toolbar can load. Review fixes: - The map's card picker sets the thinking settings of a chat slot's own model (the main model and each stage), against the view it shows; locked slots have no picker. - A service that needs no key (the browser's own speech) is offered in a media slot's picker, and the text-to-speech panel can make it the narration: the provider is added, then the slot assigned against the view the add answered. - Fetching a provider's models reports them as added only once the server saved the list; a refused or lost write leaves a failure to retry. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR * fix(config): removing a provider's key frees the slots that needed it Removing the stored key of a workspace provider left the slots assigned to it pointing at a provider that can no longer be called, so the default model (or a stage) failed at generation time while the settings still showed it in use. Removing the key now drops those assignments, as removing the provider does, and the slots follow their parents again. Only the capabilities whose provider needs a key are affected: a local model server or a keyless search keeps what it serves. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR * fix(config): connecting a token plan fills an empty web search slot A token plan's web search has no models to pick, so the plan's preset recommended nothing for the webSearch slot and connecting a plan (TokenDance, MiniMax) left search unassigned, where the browser-side Token Plan used to select the plan's search. The preset now recommends the plan's search for it, and the first-run fill assigns the provider by itself to the slot when it is still empty. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> * fix(settings): show each TTS service's full request URL The server-backed TTS panel showed only the preset base URL as the request URL. Append the path each built-in service calls, as main did, so Gemini shows /interactions and Doubao /unidirectional. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR * ci: stop triggering on integration/provider-config before it merges to main Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR * fix(settings): cache provider logos so switching service tabs does not blank them (#1777) Next serves public/ files with `Cache-Control: public, max-age=0`, so each logo a newly mounted list draws is revalidated before it paints. Switching tabs in Model Services mounts a fresh list, and its logos stayed blank until every revalidation came back. Cache /logos/* for a day with a week of stale-while-revalidate; the files are not content-hashed, so not immutable. Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> * fix(settings): hide the Skills section when the agent runtime is unavailable (#1778) Without the agent runtime, GET /api/agent/skills returns 404 by design, so the Settings Skills section could only ever show a load error with a retry that cannot succeed. Probe GET /api/agent/runtime once per tab and list the Skills item only when it reports `enabled` (the flag plus DATABASE_URL, the same check the skills route gates on). The item stays hidden while the answer is unknown, and a request to open the dialog on `skills` without the runtime lands on the first section. Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> * fix(generation): show the selected extractor's formats in the material upload hint (#1779) The attach popover's dropzone hint claimed documents, slides, spreadsheets and images for every extractor, so with unpdf (PDF only, plus the built-in plain-text extractor) users could pick a .docx via "All Files" and only then hit a generic "unsupported" error. - The hint now lists exactly the formats the active extractors accept (getFormatLabelsForProviders), with the per-file size limit. - The unsupported-file error names the extractor and its supported formats; it covers the file picker, drag-and-drop onto the dropzone, and the cleanup that drops attached files after switching extractors. - Format labels are file-type names shared by all locales; the list separator is localized. Strings updated in all 12 locales. Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> * fix(settings): connecting a token plan applies its recommended configuration (#1780) Since model configuration moved to the server, connecting a token plan only filled the slots that were still empty, so a workspace that had already picked models never switched to the plan's setup. The browser-side Token Plan used to make the plan's default model, its recommended course stages and its image, video, speech and search services the active selection. Connecting a plan (or saving a new key for a connected one) now applies the plan's recommendation as one slots change: the default model, each course stage the plan names, and its media and search services. When that would replace assignments the workspace made itself, the panel asks first: use the plan's recommended setup, or keep the current one and fill only the empty slots. Slots the deployment locks are never offered or changed, and a plan yields the slots a connected plan of higher priority recommends, whatever the connect order. A replaced language-model assignment keeps its fallback; thinking settings go with the model they were set for. Disconnecting still removes the provider, which frees the slots that named it. Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> * feat(home): the toolbar picker sets the default model and shows stages set separately (#1781) * feat(home): label the toolbar model as the default and show stages set separately The home toolbar picker only sets the `llm` root, but read as if it chose the model for everything. It now says what it changes: - the pill reads "Default · <model>" (the prefix is hidden on phones); - a hint after it counts the stages under `llm` whose own setting resolves to something other than the default (inheriting or matching stages do not count; deployment-set stages do), lists them in a tooltip with the map's stage names, and opens Course Model Config on a click; - the dropdown says picking here leaves stages set separately alone; - when every stage a course is always generated with (outline, each page type's content, actions) has its own setting, the pill summarises the per-stage setup (naming the Token Plan when one plan provider serves them all) and opens Course Model Config instead of offering a switch. Picking a model still changes only `llm`; the locked and first-run states are unchanged. New strings are added to all 12 locales. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR * fix(logos): size the DeepSeek logo to its own view box The SVG declared width 182 and height 29 around a 34x29 view box, so any square icon slot scaled it as a wide strip and the whale drew as a dot. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> * fix(agent): don't inherit a thinking effort the agent can't use; recover sessions after a failed first run (#1782) * fix(agent-runtime): drop the thinking effort the agent slot inherits The agent slot follows llm, so a thinking level picked for the default model (the home toolbar writes it on llm) reached the agent driver, which refuses any thinking effort because its tool calls cannot carry one. Every agent run failed for a user who had picked a thinking level. The agent slot now declares that it carries no thinking effort: - Resolution drops an effort the agent inherits from an ancestor and keeps the rest of the thinking settings; an inherited effort of none stays "thinking off". - An effort set on the agent slot itself is refused when it is saved: openmaic.yml at load and a workspace change through the settings API. The driver's own check stays as a backstop for settings stored earlier. - The model map's thinking control for the Agent card offers no effort levels: an effort model that can be switched off shows on/off, and one that cannot shows nothing. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR * fix(agent-runtime): keep a session usable after a run that failed while starting A run that fails before it completes any message (for example, while resolving its model) writes nothing to the entry tree, but its lifecycle frames are in the event log. The runner treated any lifecycle frame as proof that the tree should hold history, so every later run of that conversation failed with "tree is empty after a prior run". The empty-tree check now asks the event log whether a prior run completed a message. The runner appends every completed message to the tree right after its message_end event, so an empty tree is refused only when a message_end exists: the tree lost history it held. An empty tree after runs that never completed anything is legal, and the next run starts the conversation over: - it resumes the conversation instead of opening it again, so the opening prompt is not painted twice; - a durable message is taken as the session's opening message only when it was posted before the first run; a message posted after a failed start is delivered as a follow-up, not consumed in place of the session's prompt. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> * fix(config): workspace-provider policy covers request headers; startup secret warnings; upgrade notes (#1783) * fix(model-config): ignore request-named providers when workspace providers are off With `policy.allowWorkspaceProviders: false`, users may only use the providers openmaic.yml declares. The deprecated request paths still let a request run on its own model, key and endpoint while a slot was unassigned. Under that policy the language model and media resolvers now skip the provider a request names, document extraction keeps only a self-contained extractor from the request fields, and the header form of the provider test routes (verify-model, verify-image-provider, verify-video-provider) is refused. Behaviour is unchanged when the policy is true or unset. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR * test(model-config): pin the media defaults an upgrade keeps Before the server-side settings, the browser switched image, video and narration on at its first sync with the server whenever the server had a provider for them, and the classroom chat and the agent searched the web whenever a search provider was configured. The translated legacy defaults keep those capabilities on, unlocked, so a workspace can switch each off; an explicit openmaic.yml assignment stays as written and locked. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR * feat(secrets): warn at startup when stored keys will not open Keys saved in the model settings are sealed under OPENMAIC_SECRET_KEY, or under a secret generated in data/instance-secret.key when it is unset. On a host whose data directory does not survive a restart, or with several replicas, a new secret is generated and the stored keys stop opening; on a read-only data directory no secret can be created and saving a key fails. At boot, with one query in the background, the server now warns when: - OPENMAIC_SECRET_KEY is unset and the data directory cannot hold a new secret file; - the secret file is missing (so a new one would be generated) while the database holds keys sealed under an earlier secret; - stored keys were sealed under a different secret than the current one. Each warning says the keys have to be entered again and recommends setting OPENMAIC_SECRET_KEY. The server still starts in every case. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR * docs: upgrade notes for server-side model configuration Add breaking changes and upgrade notes to CHANGELOG.md for the move of model configuration to the server: openmaic.yml and capability slots, MODEL_ROUTES refusing to start without openmaic.yml (with the route-to-slot mapping, including maic-agent-driver -> agent), the narrowed /api/generate-classroom body, deprecated request fields and the workspace-provider policy, the media capability defaults after an upgrade, the one-time browser settings import and what it cannot carry, and OPENMAIC_SECRET_KEY persistence across restarts and replicas. The configuration and deployment docs (all languages) and .env.example now cover replicas, ephemeral and read-only data directories, the new startup warnings, and request fields being ignored under the policy. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> * fix(import): keep settings the server could not take; Azure regional endpoints; keyless compatible providers (#1784) * fix(model-config): keep what the browser import could not move, and accept Azure Speech regional endpoints The one-time import of browser model settings deleted the staged proposal, keys included, on any 2xx answer, even when the server skipped items. The version 5 store migration had already dropped the originals, so a skipped provider lost its key everywhere. An Azure TTS/STT user with a regional endpoint lost theirs this way: the server refused the endpoint as a custom media endpoint. Import: - Only what the answer shows the workspace holding leaves the browser. Every other item (skipped as invalid, an id the deployment declares, or unconfirmed because the answer could not be read) is kept in the browser as staged, with its keys, under its own key. Skips that leave nothing behind (EXISTS, a locked slot) are not kept. If the items cannot be kept, the proposal stays for a later load. - What the builder cannot propose is kept the same way at migration time: custom speech/transcription providers, AliDocMind's key pair, custom chat providers without an endpoint or of an unsupported type. - Kept items are never sent again and do not re-trigger the import. A toast tells the user once; Settings -> Model Services lists them with the reason and a copy-key button until they are discarded. Setting one up again (a new workspace provider of its preset, or the slot) clears it. Clearing the local cache keeps them. - The import answer now carries a `code` per skipped item, and a provider id the deployment declares is reported as PROVIDER_RESERVED. Azure Speech: - Workspace azure-tts / azure-asr providers take their official regional endpoint (https://<region>.tts.speech.microsoft.com, https://<region>.api.cognitive.microsoft.com or .stt.speech.microsoft.com), region [a-z0-9]+, https only, no userinfo, port, path, query or fragment. It is checked at save time and at resolution (resolve-slot marks anything else as a custom endpoint, which media resolution refuses), and stored normalised. Their registry default only names the region as a placeholder, so they now require it. The TTS/ASR panels get a regional endpoint field. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR * fix(model-config): let a keyless OpenAI-compatible provider run A self-hosted OpenAI-compatible server (Ollama, vLLM) declared in openmaic.yml as `preset: openai-compatible` without `apiKey` resolved, but building its model threw "API key required for provider: openai": the preset rides on the OpenAI registry entry, which requires a key. - The openai-compatible preset marks its key optional, and the slot model passes that to getModel (a new `requiresApiKey` override on ModelConfig). Every other preset keeps the registry's rule, so OpenAI itself still needs a key and fails with the same clear error. - A request without a key no longer carries an empty `Authorization: Bearer ` header; with a key it is sent as before. On main, a custom OpenAI-compatible provider sent from the browser was built the same way (providerType openai, registry default requiresApiKey true) and also needed a key on the server; keyless worked only for registry entries marked keyless (Ollama, Lemonade). This lets the documented keyless openmaic.yml provider work. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR * fix(model-config): carry narration, image and video switched off over to the workspace The browser import carried a capability's "on" choice but not an explicit "off" for narration, images and video. A user who had switched one off got it back on after upgrading, because the deployment's defaults now turn those capabilities on. When the old store has `ttsEnabled`, `imageGenerationEnabled` or `videoGenerationEnabled` stored as false while a usable provider for the capability was there, the import proposes `tts: null` / `image: null` / `video: null`, as `asrEnabled: false` already becomes `asr: null`. Earlier builds defaulted these switches to off and turned them on by themselves once a provider was usable (a server provider on the first load, a key the user entered), and off when none was. So `false` with a usable provider (server-configured and not switched off by the operator, or with the user's own key; browser speech synthesis does not count) is the user's choice, and `false` without one is only the default, which is not carried over. A server that gained a provider after the browser's first load cannot be told apart and is read as off: that is visible in the settings and costs nothing, while reading it as on could start paid generation the user refused. Web search off is not carried over: it only stopped course research, while chat and the agent kept searching through the same provider. A slot the deployment locks is skipped like any other import (SLOT_LOCKED). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> * fix(import): drop a browser-kept key only when the server holds the same one (#1785) * fix(import): drop a browser-kept setting only when the server holds the same one The one-time import of browser model settings could still lose a key: - A provider id the workspace already had was answered `EXISTS`, and the browser deleted its copy, even when the workspace held another key. The server now compares the proposed provider with the stored one (preset, endpoint in its normalised form, models, and the key, decrypted and compared in constant time) and answers `EXISTS_SAME` or `EXISTS_DIFFERENT`. Only `EXISTS_SAME` lets the browser drop its copy; a key this instance cannot open is never confirmed. Nothing else about the stored provider is returned. - Provider ids and slot ids shared one namespace in the answer, so a slot `tts` that was imported confirmed a refused provider `tts` and its key was deleted. The answer now names each item's kind (`{ kind: 'provider' | 'slot', id }`), and kept items are keyed by kind and id. - The notice in Settings cleared a kept provider, key included, as soon as any new workspace provider of its preset appeared, even one without a key. A kept key now leaves only when such a provider holds it as far as the view shows (key set, readable, same mask); otherwise it stays, with its copy button, until the user discards it. Key pairs and keys too short to show in a mask are never cleared this way. The key mask moves to a module the server and the browser share. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR * docs(changelog): the narration, image and video off switches are carried over The changelog still said the image, video and narration off switches were not carried over. They become `tts`/`image`/`video: null` when a usable provider was set up, as the configuration docs and the import README already say; only the research switch, and a switch off only by default, are not carried over. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR * fix(import): never clear a kept key by itself A kept provider that holds a key (or a key pair) used to leave the browser once a new workspace provider of its preset held a key whose mask matched. A last-four-characters match does not confirm the workspace holds that key, so such an item now stays, with its copy button, until the user discards it. Kept items without a key still leave by themselves once the view shows them set up again. The key mask goes back to the server module, its only user. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> * fix(import): keep browser settings on a refused or self-contradicting answer (#1787) Two more ways the one-time import of browser model settings could lose a key: - A 400 removed the proposal outright. The server refuses the whole proposal then (an unexpected top-level field is enough), so sending it again cannot succeed, but its keys existed nowhere else. Every item is now kept in the browser first, keys included, as refused with the server's message, and only then is the proposal removed; if they cannot be kept, the proposal stays. Kept items are never sent again. 401, 404, 409, 5xx and network failures still keep the proposal for a later load. - An answer that listed an item as both imported and skipped, or skipped it with different codes, let `imported` (or the last code) win, which could delete a key the server did not hold. Such an item is now kept as unconfirmed. Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> | 30 天前 | |
fix(generation): reject unusable interactive scripts and surface runtime errors (#1649) Reject classic inline interactive scripts that fail to parse at generation time (extracted with parse5, checked with node:vm Script without executing), and surface iframe runtime errors on the active interactive scene. Addresses #1622 (partial: no recovery / regeneration UI). Co-authored-by: Frank-zhu0404 <Frank-zhu0404@users.noreply.github.com> | 1 个月前 | |
fix(audio): follow the interface locale for default speech languages (#1935) New users no longer get Chinese speech recognition and an English browser voice regardless of their language. An unset ASR language uses auto-detection when the provider supports it and otherwise resolves from the interface locale; browser recognition always receives an explicit language. Chinese text keeps the interface's zh-* region for preview, classroom narration and discussion speech. Saved ASR language choices are preserved. Fixes #1548 | 9 天前 | |
feat(roster): single-source the generated agent roster on the stage document (#994) * feat(roster): single-source the generated agent roster on the stage document The generated agent roster used to live in two places at once: the stage document carried generatedAgentConfigs, while a per-stage IndexedDB mirror (db.generatedAgents) was the actual read authority — and the only home of each agent's voice binding. The split caused real losses: voices vanished on device switches, exports, and shares (the mirror never travels), the load path trusted a device-local table over the portable document, and roster edits persisted through a bespoke debounce with no beforeunload flush. Make the stage document the single source of truth: - Contract: add optional voiceConfig/voiceDesign to GeneratedAgentConfig in @openmaic/dsl (VoiceDesign moves into the contract; the app re-exports it). Additive optional fields — not a breaking serialized-shape change, so DSL_VERSION stays put per the version policy. - Writes: remove every mirror writer (registry saveGeneratedAgents, the stage store's debouncedSaveAgents bypass, classroom-load hydration, import direct writes). setStageAgents now only marks the stage dirty for the shared persistence scheduler plus synchronous in-memory registry/selection mirrors; generation and import embed the roster on the stage. - Reads: classroom load hydrates the registry from the loaded stage's generatedAgentConfigs; export reads the stage roster directly. - Lazy migration: the mirror is retained read-only. On load, a roster (or voice fields) missing from the document is backfilled from the mirror, committed onto the in-memory stage, and persisted by the next flush — idempotent, and gated on the current stage id after every await so a classroom switch cannot leak a stale roster into the registry. - Deletion: deleteStageData discards the deleted stage's pending persistence work so a queued flush cannot resurrect the document. - Round trip: classroom ZIP manifests now carry the voice fields, so export/import preserves agent voices. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(roster): harden deletion, migration, selection, and ingress paths Review hardening on top of the roster single-source change: - Deletion vs in-flight persistence: deleting a stage now registers a session-scoped tombstone (lib/utils/deleted-stages.ts) before the cascade starts. Every persistence landing point checks it — the scheduled flush and the departing-stage retry drop their snapshots, and the aggregate/incremental saves re-check under the document lock, including the incremental path's full-save fallback that would otherwise rebuild a deleted document from the in-memory snapshot. A deletion that fails before the document is removed lifts the tombstone so the still-existing stage keeps persisting. - Lazy migration termination: a fruitless legacy-mirror probe (nothing to merge — e.g. server-generated rosters, which are voiceless by design) is remembered per stage for the session instead of re-querying the mirror on every load forever. Successful merges are not memoized so a failed flush is retried on the next load. Docstrings now state the real lifecycle. - Selection provenance: setStageAgents no longer overwrites a user-set agent selection with the full roster. Stage-derived selections still track the roster; a user-set auto selection is only narrowed to surviving agents (newcomers are not auto-selected, the user-set flag is untouched), and a user-set preset selection is left alone — matching restoreAgentSelection. - Mirror hygiene: deleteStageData clears the deleted stage's rows from the legacy roster mirror (best-effort), and the mirror table's docs now describe its actual access pattern (migration reads + deletion hygiene). - Registry persistence: the agent registry's persist snapshot partializes generated agents out of localStorage, making the stage document the roster's only durable home; the rehydration merge filter remains as defense in depth. - Ingress validation: imported manifest voice fields are structurally validated (malformed bindings dropped per field, the agent survives), and applyGeneratedAgentsToRegistry validates providerId against the known TTS provider registry instead of casting, treating unknown providers as "no bound voice". - Contract: drop the speculative AgentVoiceConfig.modelId — an audit of every roster producer, current and historical, found none that ever emitted it. Contract fields are added once a producer exists, not before. New tests cover the deletion tombstone at both the store and storage layers (including the missing-destination full-save pin and the lock-race re-check), the fruitless-probe memo, the selection-provenance matrix, persistence exclusion of generated agents, and voice-field sanitization on import. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(roster): close tombstone lifecycle, probe-memo, and selection-intent gaps Second hardening pass over the deletion/migration/selection fixes: - Tombstone lifecycle: a deleted classroom id can legitimately come back (deletion is client-side only). Explicit (re)creation points now lift the tombstone — server-copy restore in applyClassroomStageAndScenes and backup restore in importDatabase — so edits after a same-session restore persist again, while in-flight flushes stay fenced. - Failed deletion now restores the dirt it discarded: deleteStageData snapshots the pending map plus the in-flight round before tombstoning, and re-marks it when the delete fails with the document still present. - Legacy-mirror probe memo now distinguishes a FAILED read (null, retried on the next load) from a confirmed-empty mirror (memoized), so a transient IndexedDB error cannot suppress migration for the whole session. - Incremental save tail (currentScene KV row + chat sessions) is tombstone- fenced like the aggregate path, and both paths re-check the tombstone immediately before every write, with comments scoped honestly for the lock-free (no Web Locks) best-effort LWW fallback. - Roster-edit selection mirror: an empty intersection now falls back to the full roster and clears the user-set flag (matching restoreAgentSelection's length > 0 gate), and a user-set selection that equals the pre-edit full roster keeps tracking the roster wholesale so newcomers are not excluded forever by the AgentBar auto-toggle snapshot. - TTS/ASR provider lookups use Object.hasOwn instead of `in`/bare indexing, so prototype-chain keys ('toString', 'constructor', ...) no longer pass the provider whitelist or resolve to Object.prototype members. - Documented the per-tab limit of the tombstone set. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(roster): replace the boolean deletion tombstone with a deletion-generation model A boolean tombstone cannot express the deletion lifecycle once a deleted id legitimately comes back (server-copy restore on revisit, backup import): after the restore lifts the flag, a pre-delete flush still in flight is indistinguishable from a post-restore edit and can overwrite the restored document with pre-delete content. Two rounds of point fixes kept leaking variants of this race, so this restructures the fence instead. deleted-stages.ts now keeps a per-stage monotonic deletion epoch plus a deleted flag. markStageDeleted bumps the epoch; unmarkStageDeleted clears only the flag and never rewinds the epoch. Every persistence path captures the epoch at the moment it captures the data it will write (flush-round snapshot, departing-stage snapshot, aggregate save entry) and re-checks "captured epoch is current AND not deleted" immediately before each individual write. Invariants: - A write captured before a deletion can never land after it, even when a same-id restore has lifted the deleted flag (the captured epoch is permanently stale). - A write capturing data after a restore observes the current epoch and persists normally. - A failed deletion lifts only the flag; the restored pending changes are re-queued as descriptors, so their flush re-captures current store state under the current epoch and is neither dropped nor a stale replay. Also fixed in the same restructuring: - Incremental tail writes (currentScene, chats) each re-check independently; a delete landing while the first tail write is awaiting now fences the chat write instead of riding the earlier check. - Deleting a classroom evicts it from the warm in-memory store, and loadFromStorage treats a warm-but-deleted stage as not loaded (discarding the ghost), so navigating Back to a deleted classroom reaches the server-restore path instead of rendering an editable ghost whose every edit is silently dropped. - importDatabase records each document's pre-import deletion state and reinstates it when a failed import rolls the document back, so an outstanding flush cannot recreate the rolled-back document. - Docstrings on the failed-deletion restore path now state that only dirt captured before the deletion is restored; edits refused during the deletion window are not part of the snapshot. New tests cover the epoch invariants at the storage layer (pre-delete rounds dropped across restores on the incremental, aggregate, and tail paths; post-restore captures landing; strictly increasing epochs), the scheduler layer (departing-retry delete+restore straddle; restored dirt flushing under the current epoch), the warm-ghost restore driven through the real loadFromStorage, and the import lift/rollback pair. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(deletion): report fenced drops, gate ghost eviction on cascade settlement Edge hardening around the deletion-epoch fence; the epoch model itself is unchanged. - An epoch-stale drop is now reported, not disguised: saveStageData / saveStageDataIncremental return a distinct 'stale-dropped' status (entry check, every in-mutation re-check, and both tail-write re-checks) instead of a shape identical to success. saveToStorage reacts by skipping all success bookkeeping - no chatSnapshot rebind to a snapshot that never landed, no pending-dirt clearing - and returns false, keeping its "true means verified write" contract honest. persistDirtySnapshot keeps its existing (correct) semantics explicitly: a drop has nothing to retry. - Warm-ghost eviction is settlement-gated: the deletion cascade records an in-flight bit (begun by deleteStageData, settled in its finally), and the deleted-warm branch of loadFromStorage keeps the warm state while the cascade is unsettled - a delete can still fail before removing the document, in which case the warm state plus the restored pending dirt is the only copy of the pre-delete edits. Once settled, the ghost discard and server-restore path behave as before. - Read-side landing re-check: loadFromStorage re-checks isStageDeleted after hydration, immediately before set(), mirroring the write-side "re-check immediately before landing" discipline - a lock-free mid-cascade read can no longer re-materialize a ghost classroom. - clearStoreForDeletedStage re-checks isStageDeleted at eviction time, so a same-id restore completing inside the cascade-tail window is never wiped. - capturedEpoch is now a required parameter on both storage entry points: the default (call-time capture) silently reopened the capture-point/ validation-point split for future callers; the type system now enforces the pairing. All production callers already passed it explicitly. New tests cover the dropped-status contract, the cascade in-flight lifecycle (including both failure shapes), keep-warm during an unsettled delete with end-to-end pending restore after a failed delete, settlement- then-eviction on success, the eviction guard against a tail-window restore, and the read-side hydration re-check. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(deletion): settle the deleted-warm load by awaiting the cascade outcome Hardening around the deletion settlement gate introduced previously; the epoch model is unchanged. - The deleted-warm branch of loadFromStorage no longer completes the load while the deletion cascade is undecided (which exposed a classroom whose edits were refused without being restorable, and left a successful deletion's eviction with no follow-up reload). It now parks on a per-stage settlement promise (stageDeletionSettled) and branches on the outcome: a failed delete keeps the warm, restored classroom; a successful delete falls through to a full cold load so the server-restore path recovers the route; a navigation that moved on during the park is stopped by the load token guard. The eviction (clearStoreForDeletedStage) deliberately stops claiming a new load token so the parked load stays current - ghost re-materialization is fenced by the read-side deleted re-checks instead. No deadlock: a parked load holds no document lock, and the cascade never waits on a load. - 'stale-dropped' now propagates through the debounced flush path: persistDirtySnapshot returns the sentinel instead of an empty failure set, and startFlushRound skips the chatSnapshot rebind on it (mirroring saveToStorage) - a falsely rebound baseline would let later chat saves no-op-skip chats that never landed and corrupt cross-tab conflict detection. Pending-map clearing keeps its empty-failure semantics (restored descriptors mint fresh revisions). - The cascade in-flight state is a counter (begin++/settle--) instead of a boolean, and deleteStageData is single-flight per stage (a concurrent second call joins the first cascade), so overlapping deletions can never expose an undecided cascade as settled. - The StaleDroppedSave docstring no longer overpromises: a tail saveCurrentScene can land after the cascade's clearCurrentScene; the orphaned cursor row is ignored by the load path rather than removed by the cascade. Net +8 tests: overlapping-cascade counting, settlement-promise resolution (success and failure), concurrent double-delete single-flight join, the parked mid-cascade Back in both outcomes plus the navigation-away guard, and the fenced flush round keeping an honest chat baseline end-to-end. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(deletion): re-verify store identity on keep-warm resume, re-run joined deletes after a mid-cascade restore Three hardening refinements on the deletion/persistence fencing: - loadFromStorage's parked keep-warm branch now mirrors the success path's store-identity guard: if a tokenless writer (e.g. a database import) replaced the stage during the park, a failed deletion falls through to the cold load instead of reporting another classroom as the kept warm state. The ghost-discard block is correspondingly gated on the deleted flag so the fall-through does not mislabel a surviving document as a ghost. - deleteStageData: a joined call carries its own deletion intent. When the first cascade fulfills but a same-id restore lifted the deleted flag in the meantime, the joined caller now runs one fresh cascade against the restored document instead of reporting the pre-restore outcome. Exactly one re-check per call (no recursion); a rejected first cascade still propagates unchanged to every joined caller. - Narrowed the failed-delete restore-ownership comments: the restore covers only the pending map plus an in-flight flush round's dirt; a departing-stage snapshot is outside that capture and is fenced and dropped by design. Comment-only, no behavior change. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(roster): gate deleted-warm handling on stage identity, keep an explicit empty roster authoritative Final-audit fixes, three items: - loadFromStorage keyed its deleted-warm settlement handling (park / keep-warm / ghost discard) behind the scenes.length > 0 shortcut, so a zero-scene warm ghost of a deleted classroom skipped it entirely: the cold load found no document, the ghost stayed in the store, and the classroom loader's server-fallback gate (!getCurrentStage()) never ran — the silently-uneditable trap this branch exists to close, in its scene-less form. Deletion handling now keys on stage identity alone, before any scene-count shortcut; the non-deleted warm skip still requires scenes. - rosterNeedsLegacyFallback collapsed "roster field absent" (document predates roster persistence) with "explicitly persisted empty roster". A future writer persisting [] on a device whose read-only mirror still held stale rows would have its emptied roster resurrected on every load. The full-lift branch now triggers only when the field is absent (undefined); an explicit [] is authoritative and never probes the mirror. No current writer produces [], but the read side no longer depends on that invariant. - The GeneratedAgentConfig docstring claimed the voice fields are not a breaking change; that only holds for this codebase's tolerant structural validators. The generated stage.schema.json sets additionalProperties: false, so consumers pinning an older published schema artifact reject documents carrying the new fields. The docstring now states both sides honestly. Tests: a zero-scene deleted warm ghost is restored through the real loadFromStorage (fails pre-fix), an explicit empty roster does not resurrect stale mirror rows (fails pre-fix), and the rosterNeedsLegacyFallback unit matrix covers undefined vs []. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(deletion): re-queue aggregate persistence when a failed deletion is recovered Direct aggregate saves (saveToStorage: generation completion, server-restore hydration) run outside the pending map and the flush round, so the deletion path's pre-delete dirt snapshot cannot describe them. When a deletion bumped the epoch while such a save was in flight, the save was correctly fenced ('stale-dropped') — but if the deletion then failed before removing the document, the recovery only re-queued the snapshotted scheduler descriptors. The fenced aggregate-only content stayed memory-only with no retry anywhere, and a later reload lost it. The failed-deletion restore now merges a full re-mark of the aggregate (structure / stage / outline / currentScene / chats plus a descriptor for every current scene) into the recovery set, still guarded on the store holding the stage. Because the flush recaptures the CURRENT store state under the CURRENT epoch, this necessarily carries whatever the fenced aggregate save held — and, for the same reason, edits refused by the scheduler during the deletion window, which previously stayed memory-only as well; the scope docstrings now say so instead of declaring them excluded. New integration coverage drives the real store scheduler and the real storage layer end to end: a genuinely fenced in-flight saveToStorage (generation completion) plus a pre-removal deletion failure ends with the completion flag durable on the next flush; a deletion-window scene edit survives the same way; and a successful deletion re-marks nothing (control). Both recovery tests fail against the previous restore logic. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> Co-authored-by: 杨慎 <117187635+cosarah@users.noreply.github.com> | 3 个月前 | |
fix(whiteboard): correct the inverted viewportRatio so boards render 16:9 landscape (#1257) | 2 个月前 | |
feat(persistence)!: server-backed persistence only, with a one-way legacy browser import (#1710) * ci: run CI for the persistence-default integration branch Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat(docker): start PostgreSQL by default and add a single-user owner * feat(docker): start PostgreSQL by default and add a single-user owner `docker compose up` now runs a server-backed app against a bundled PostgreSQL, started only once PostgreSQL is healthy and published on 127.0.0.1 only, with a new built-in singleUser owner auth method: every request resolves to one fixed owner that may publish, and no anonymous cookie is minted. Single-user mode (OWNER_SINGLE_USER=true, OWNER_SINGLE_USER_ID default "local") runs with or without ACCESS_CODE. Without one, the server logs one prominent startup warning that anyone who can reach it shares, edits and can delete the single library; it never inspects request peers or forwarding headers. It excludes PERSISTENCE_SHARED_OWNER_ID and follows the sharedTeam registration rules. Its principal gets a claim candidate, so a browser's earlier anonymous work is claimed into the single owner (OWNER_CLAIM_TRIGGER=auto in the Compose defaults). Compose defaults live in docker-compose.defaults.env, read before .env.local so they can be overridden. The app warns when its declared published address (OPENMAIC_PUBLISH_ADDRESS, passed by Compose) is not loopback while PostgreSQL uses the default password. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(docker): keep claims explicit and let .env.local set DATABASE_URL - Compose no longer defaults OWNER_CLAIM_TRIGGER to auto: an irreversible merge of every browser's anonymous library into the single owner must not happen on upgrade. The single-user principal still gets a claim candidate, so POST /api/identity/claim works; the docs explain it and warn about auto on a deployment several people used. - The bundled DATABASE_URL moves to docker-compose.defaults.env, read before .env.local, so an external database or a rotated password set there keeps working. The password is inserted unencoded, so it must be letters and digits (documented). - Upgrade docs no longer claim browser-stored courses are copied as they are opened: they stay in the browser, are not deleted, and are moved by the automatic browser-to-server migration shipped with this work. - Single-user mode without ACCESS_CODE now logs one warning instead of two; the docs note that the single owner id is permanent. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> * feat(persistence)!: server-backed persistence is the only persistence (#1707) * feat(persistence)!: make server-backed persistence the only persistence Courses, folders and folder membership, chat history and learner runtime, and generated media are now always stored on the server through the embedded /api/persistence endpoint. The browser keeps no durable data of its own. - Remove the build-time NEXT_PUBLIC_PERSISTENCE switch: the client bootstrap always configures the HTTP document, runtime and asset seams, and the Dockerfile, docker-compose.yml, the Pi route and the whiteboard runtime no longer read it. Unconfigured seams refuse to resolve instead of falling back to IndexedDB, and the learner key is always the server-derived one. - Remove the browser write paths: the browser document, runtime and asset stores as backends, the browser-mode branches of stage, folder, chat, media, narration and import storage, the Dexie backup export/import and the whole-database clear. The library lists through /api/stages, folders through /api/folders, and membership now goes through /api/folders/members. Regeneration always forks to a fresh asset. - Device-local state moves to its own IndexedDB database (lib/device-storage, maic-device-cache): the local media and narration cache and refused-bytes retention, staged PDF images, undo history, browser voice profiles (carried over once from the old database) and the auto-voice cache. "Clear Local Cache" clears exactly that and no longer claims to delete classrooms or chat history. - Keep the pre-server data readable for the one-way importer: the Dexie schema and read-only accessors for its tables and for the browser document, runtime and asset databases live in lib/legacy-browser-storage, which nothing on the regular paths reads and which has no write path. - Require a database: the server exits at boot without DATABASE_URL, with a message naming `pnpm db:up` and `docker compose up`. `pnpm db:up` / `pnpm db:down` start and stop the Compose postgres service alone, published on 127.0.0.1 through docker-compose.db.yml; .env.example carries the matching local DATABASE_URL. - E2E specs seed their courses through the persistence endpoint, each under a fresh id, instead of writing IndexedDB. - Guard tests pin the boot requirement, the absence of any NEXT_PUBLIC_PERSISTENCE read, and the legacy-storage boundary. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * ci: run the E2E suite against PostgreSQL The app refuses to start without DATABASE_URL, so the E2E job gets a postgres:16 service and a job-level DATABASE_URL. The unit, storage and render-service jobs are unchanged and need no database. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs: document always-on server persistence and the database requirement README (English and Chinese), the deployment guide in every locale, the extension cookbook and the changelog now describe PostgreSQL as required: `pnpm db:up` for local development, `docker compose up` for a deployment, and an external database for Vercel and other serverless hosts (the Deploy button prompts for DATABASE_URL). They list what stays in the browser, drop the NEXT_PUBLIC_PERSISTENCE instructions, and say that courses an earlier browser-only build stored stay in the browser until the one-way importer moves them. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(pbl): use the server-derived learner key for PBL v2 runtime PBL v2 synchronization, hydration and drain resolved the learner key from their default watermark KV store, which reads or mints a device key in localStorage instead of asking runtime configuration. The runtime store is the server one, which refuses any key but the owner's, so saving a course with a PBL v2 scene failed before the document write, and the minted key landed in the slot the one-way importer reads as the pre-server runtime partition. The learner key now comes from getLearnerKey(args.kv): the configured server-derived key, or an explicitly injected store. The default KV store still holds the drain watermarks and nothing else. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test(persistence): pin that clearing the cache keeps legacy databases - Seed the four pre-server databases (MAIC-Database, maic-documents, maic-runtime, maic-asset-pool) with real rows, run Clear Local Cache, and assert every database and row survives; assert the device database name differs from every legacy name. - The legacy-module import rule now also catches relative static, side-effect, dynamic and require imports at any depth. - New rule: the runtime learner key is never read from a KV store the caller made up, only from configuration or an injected store. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * build(dev-db): run the development database as its own Compose project `pnpm db:up` / `db:down` shared the checkout's default Compose project with `docker compose up`, so they recreated or stopped a running stack's database. docker-compose.db.yml now declares its own project (`openmaic-dev-db`), which gives the development database its own container and data volume; it no longer touches the stack, and the two do not share data. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs: start the development database before `pnpm dev` CONTRIBUTING, the getting-started guide in every locale and the startup modes reference now run `pnpm db:up` and set DATABASE_URL before `pnpm dev`. README, the deployment guides, the cookbook and the changelog describe `pnpm db:up` as a separate development database. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test(persistence): sharpen the legacy-storage and learner-key guards - The Clear Local Cache test closes its seeding connections to maic-documents and maic-runtime, so a regression deleting either fails on the assertion that names the database instead of by timeout. - The legacy-module and Dexie import rules accept comments inside import() / require(), so a webpackIgnore hint no longer hides one. - The learner-key rule also flags an injected-looking name that is bound to a default local store in the same file; its remaining limit is documented, with the behavioural PBL test as the guard. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * build(dev-db): pin the development database's Compose project `pnpm db:up` / `db:down` now pass `-p openmaic-dev-db`, which takes precedence over COMPOSE_PROJECT_NAME from the shell or a .env file; the file's `name:` alone could be overridden and point the scripts at a running stack's database. The compose test pins the flag. The docs (all locales) and the file header say the development database is shared by every checkout on the machine, and how to run a separate one. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> * feat(persistence): import legacy browser data into the server, once (#1708) * refactor(persistence): prepare the seams the legacy browser importer uses - Legacy module: read-only accessors for the auto-voice cache, the course ids the old media and narration tables name, the pre-server learner key, and asset bytes from the browser asset pool. - Narration adoption exports its row ownership rule so the importer applies the same one to rows of the old database. - The quiz legacy migration is split into a non-deleting core (importLegacyQuizSnapshot) and the regular path, which still deletes the keys it migrated. - The lazy document migration exports its snapshot canonicalizer. - Clear Local Cache keeps the importer's per-owner ledgers, as it keeps the pre-server learner key. - A library-changed window event lets an open library list courses that arrive in the background. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MRME5T4oEaQ4EBUzrDXMKz * feat(persistence): import legacy browser data into the server, once On the first load after the upgrade, the client moves what earlier builds kept in this browser to the server, for the owner the server resolves: courses from the browser document store and the original tables (with chat, learner runtime, playback position, roster, folders and membership, pre-runtime quiz state), their media bytes through commitToPool and the existing write-back funnels, the bytes of server courses from earlier opt-in server builds that only the old tables hold, and device-only rows (failure records, refused bytes, auto-voice clips) into the device cache. It is automatic and silent, runs after the page is idle, never writes to a legacy store, and records every step in a per-owner ledger so it resumes after a crash and never imports twice. Runs are serialized across tabs with Web Locks. A course the owner already has stays authoritative; one whose id another owner holds is imported under a derived fresh id; one the owner deleted on the server stays deleted. Transient failures retry on a later load with backoff; permanent ones are recorded per item. The module is temporary and self-contained; its README lists the removal steps. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MRME5T4oEaQ4EBUzrDXMKz * test(persistence): pin that each owner keeps its own import ledger Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MRME5T4oEaQ4EBUzrDXMKz * test(persistence): run the legacy import against the app routes and a browser A PostgreSQL suite routes every request the importer and the app seams send to the real persistence, library and folder handlers, owner resolved from the anonymous cookie: a full import, a course another owner holds, a course the owner deleted, and an existing course whose media is filled in. An end-to-end spec seeds an old pre-server database in Chromium, loads the app, and checks that the course reaches the library with its narration uploaded, stays in the browser untouched, and is visible from a fresh browser context with the same owner cookie. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MRME5T4oEaQ4EBUzrDXMKz * docs: describe the one-way import of legacy browser data The README (and its Chinese version), the deployment guide in every locale and the changelog now say what the importer does instead of announcing it: courses an earlier browser-only build kept in the browser move to the server automatically on the first load after the upgrade, and the browser copy stays untouched. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MRME5T4oEaQ4EBUzrDXMKz * fix(persistence): probe server assets through the shared asset-URL owner The importer asked the pool directly whether an id exists, which the asset-URL ownership boundary forbids outside use-asset-url. It now uses assetRefExists, the existing metadata-only probe. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MRME5T4oEaQ4EBUzrDXMKz * fix(persistence): guard the importer's pool probe like every other entry point Only a reference the pool could have issued is probed, as the lease guard requires; the importer is added to the list of guarded entry points. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MRME5T4oEaQ4EBUzrDXMKz * fix(persistence): import legacy browser data once per browser Review round 1 of the legacy browser importer. - Once per browser, not once per owner: the data belongs to whoever used the browser before the upgrade, so the first owner the import runs for claims it and any other owner gets nothing. The ledger is one key, maic:legacy-import:v2, recording the owner only as a SHA-256 digest (an anonymous owner id is a bearer credential) and a random salt. - Handoff: when the claiming owner is retired (403 OWNER_RETIRED), or the current owner holds courses or folders the importer created (a claim moved them), the current owner continues the unfinished items; courses already done are never imported again, so a claim neither duplicates nor resurrects them, and a half-copied course keeps its runtime. - Fresh ids come from the salt and the legacy id through SHA-256, not from the owner: unlinkable, unpredictable, and the same in every tab. - 401 pauses with backoff instead of closing the import; 403 FORBIDDEN_LEARNER stops the run and leaves the item pending. - Without Web Locks: ledger writes merge with the stored copy, and the library is listed again right before a course is placed, so a second tab does not import a course the first one just created. - Media the server refuses for good gets the app's failed-media record instead of a dangling reference; the legacy bytes stay. - A legacy whiteboard or PBL session is not created when the server already has an active one of that kind. - A folder that is gone leaves the course unfiled instead of failed, and a membership naming a folder the old database lacks counts as unfiled. - Narration rows from before the course column are adopted when exactly one legacy course names their key; unconvertible chat rows are noted; an unreadable document-store copy falls back to the original tables; PBL v2 documents go through the save-path strip. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MRME5T4oEaQ4EBUzrDXMKz * test(persistence): cover the importer's handoff, tabs, refusals and review gaps Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MRME5T4oEaQ4EBUzrDXMKz * docs: say the legacy import runs once per browser The README (and its Chinese version), the deployment guide in every locale, the changelog and the module README now say that the first owner to load the upgraded app in a browser claims its legacy data, what the handoff to an account does, that the ledger holds no owner id, and how refused media and the new pause and skip cases behave. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MRME5T4oEaQ4EBUzrDXMKz * fix(persistence): note runtime the importer cannot find without the old learner key Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MRME5T4oEaQ4EBUzrDXMKz * feat(identity): let an owner confirm it absorbed a given owner through a claim GET /api/identity/merged-from?salt=<hex>&digest=<hex> answers whether a claim merged into the requesting owner an owner whose id hashes to SHA-256(salt, id). It reads only the requester's own owner_merges rows, never lists or names an owner, resolves the owner like every other owner-scoped route, and is uncacheable. The one-way import of pre-server browser data uses it to decide whether a different owner may continue an import the first owner started. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MRME5T4oEaQ4EBUzrDXMKz * fix(persistence): hand the legacy import to another owner only on a confirmed claim Review round 2 of the legacy browser importer. - The only way the browser's legacy data moves from the owner that claimed it to a different owner is the server confirming, through GET /api/identity/merged-from, that the current owner absorbed that owner through a claim. The inferred signals (an owner that never wrote, a library listing that holds imported courses) and the retired flag are gone; a 403 OWNER_RETIRED only stops the run and leaves items pending. - The first owner is bound only once the run's first authenticated request of its own (the library listing) succeeded, so a run that dies before that leaves the ledger unclaimed. - The owner is recorded as SHA-256 of the per-browser salt and the owner id; the server computes the same value from the salt the browser sends. - A "not absorbed" answer is reused for an hour instead of asking on every load, and the claiming owner stored first survives another tab's save unless that tab handed the import over; completion survives too. - Run-level failures (401, OWNER_RETIRED, FORBIDDEN_LEARNER, OWNER_BUSY) from any call stop the run and leave the course pending, including the "is the id taken" read and the library re-list, which could previously mark the course failed and close the import. - Refused media without a generation request (the user's own) gets a new non-retryable ASSET_REFUSED code, so it shows as failed without a Retry that could only fail; a skipped singleton runtime session leaves a note. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MRME5T4oEaQ4EBUzrDXMKz * test(persistence): pin the importer ledger's owner merge and the singleton-skip note Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MRME5T4oEaQ4EBUzrDXMKz * docs: the legacy import moves to another owner only on a confirmed claim The README (and its Chinese version), the deployment guide in every locale, the changelog and the module README now say that the import runs once per browser and moves to another owner only when that owner claimed the original one, confirmed by GET /api/identity/merged-from; that the owner is recorded as a salted digest; and how refused user media shows. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MRME5T4oEaQ4EBUzrDXMKz * fix(persistence): never let a transient failure settle a legacy import item Review round 3 of the legacy browser importer. - A read of the old browser stores that fails in storage (an aborted IndexedDB transaction, a closed database) now pauses the run and leaves the course pending. Only a record that cannot be migrated, parsed or validated is skipped. This covers the course read (which no longer falls back to the older table copy on a storage failure), and the quiz-scene and speech indexes, which used to read a failure as "no scenes" or "no holder" and drop quiz state or narration for good. - After a session-id collision, a failed read of the session propagates and is retried; only a successful read that shows the id taken skips it. - Without Web Locks, two tabs of different owners can both find the ledger unbound. Every ledger write keeps the owner stored first, and the run now re-reads the stored ledger after binding, before folders, before each course and before each course document is created, and stops if another owner holds it. - The "not yours" answer is reused for ten minutes instead of an hour, a stored wait further ahead than the longest the importer sets (a clock that ran ahead) counts as passed, and expired answers are pruned. - Generated media refused without an error code keeps its Retry; only media nothing can regenerate gets ASSET_REFUSED. A poster that fails transiently leaves the video pending instead of being dropped. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MRME5T4oEaQ4EBUzrDXMKz * test(persistence): cover transient reads, racing owners, the not-yours cache and the claim client Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MRME5T4oEaQ4EBUzrDXMKz * docs(persistence): say what the ledger's salt protects, and the new pauses The salt defeats precomputed and cross-browser tables, not a targeted guess against one browser's ledger; the module README and the digest's comment now say so. The README also covers storage read failures, which pause instead of skipping, the two-owner race without Web Locks, and the ten-minute recheck. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MRME5T4oEaQ4EBUzrDXMKz * feat(identity): bind a browser's legacy data to one owner on the server The one-way import of pre-server browser data now asks the server whose data it is, instead of deciding in the browser. - legacy_import_bindings (browser_id primary key, owner_id, timestamps), provisioned with owner_merges on fresh and upgraded databases. - POST /api/identity/legacy-import-binding {browserId}: one atomic insert (ON CONFLICT DO NOTHING) and a read-back; answers only whether the requesting owner holds the browser. Same-origin JSON, resolved and write-fenced like every owner write, 400 for a malformed id. - A claim participant re-keys the claimed owner's bindings to the account inside the claim transaction, so the account simply holds the browser. - Owner resolution refuses any request carrying X-OpenMAIC-Legacy-Import with 409 LEGACY_IMPORT_NOT_BOUND unless the owner it resolves to holds that browser: one central fence every owner-scoped route goes through. Requests without the header are unaffected. - GET /api/identity/merged-from is removed; the binding replaces it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MRME5T4oEaQ4EBUzrDXMKz * fix(persistence): import through fenced clients bound by the server Review round 4 of the legacy browser importer. - The ledger holds a random browser id and no owner information; the owner digest, the "not yours" cache and the client-side handoff are gone. A run first asks the server to bind the browser and imports only when the requesting owner holds it. - Every importer request goes through its own clients that carry the browser id, so the server refuses any write under an owner that does not hold the browser: after a cookie switch in another tab, from a page whose memoized owner is stale, or from a tab that lost the binding race. The runtime learner key is asked for during each run, never taken from the page. 409 LEGACY_IMPORT_NOT_BOUND stops the run with items pending. - The write-back funnels, commitToPool and the PBL save-path strip take an optional store, put or runtime, so the importer passes its fenced ones. - A failed read of one legacy course holds up only what it could affect: the quiz-scene and speech indexes leave the course out as unindexed, and only quiz state or narration it could own waits. Read failures are counted per course; after five failing runs over at least a day, a course whose document-store copy cannot be read falls back to the table copy, or is skipped with the reason when there is none. - Runtime ids are rewritten by replacing only their course segment, so a short course id cannot corrupt the prefix or the learner segment. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MRME5T4oEaQ4EBUzrDXMKz * test(persistence): cover the server binding, its fence, bounded reads and short ids Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MRME5T4oEaQ4EBUzrDXMKz * docs: the server binds a browser's legacy data to its first owner The README (and its Chinese version), the deployment guide in every locale, the changelog and the module README now say: once per browser; the server binds the browser to the first owner; a claim carries the binding to the account; the importer's requests are refused for any other owner. They also describe the bounded pause for a course the old storage cannot read, and the removal steps for the server side. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MRME5T4oEaQ4EBUzrDXMKz * fix(persistence): keep a dropped asset request pending in the legacy import The asset client reports a fetch that got no answer (a dropped request, an aborted upload, the existence probe's deadline) as status 0 with HTTP_REQUEST_FAILED, which the importer read as a final refusal: the media was marked failed and the course could complete without it. Read that code as transient, keep the client's local validation failures final, and treat a 2xx/3xx answer a client could not use as transient too. Unit tests drive every client the importer uses with a rejected fetch and a non-JSON answer. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MRME5T4oEaQ4EBUzrDXMKz * fix(persistence): ask a refused binding again only every ten minutes A browser whose legacy data another owner holds sent the binding request (a write) on every page load. Record a ten-minute recheck in the ledger (clamped like every other deferral), so a claim that moves the binding is still picked up, and open the old databases only once the browser is bound. The read budget now counts consecutive failing runs (a run that reads the course resets it) and counts a first failure dated in the future from the next one. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MRME5T4oEaQ4EBUzrDXMKz * fix(identity): answer 503 with the cookie when the import fence cannot read The legacy import fence now reads the header name from the shared constant, and a database error while reading the binding answers the usual JSON error (503 PERSISTENCE_UNAVAILABLE) with the resolution's Set-Cookie values instead of escaping as a bare 500. Route tests cover that, a bind by an owner a claim retired (403 OWNER_RETIRED, no row), and one header name on both sides. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MRME5T4oEaQ4EBUzrDXMKz * test(persistence): cover dropped asset requests, the recheck and the read budget With the real asset client's error for an upload and an existence probe, the media stays pending and a later run imports it. A non-holder's five loads send one binding request and never open the old databases, and a claim still hands the import to the account after the delay. The read budget's run count and time span each hold on their own, a clock that ran ahead does not hold a course open, and a good read resets the count. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MRME5T4oEaQ4EBUzrDXMKz * docs(persistence): complete the legacy import removal steps List every file the removal touches (the claim scenarios test, the claim participant table and list, every doc), keep the bindings table for one release after the code goes because of rolling deploys and give the DROP statement for that release. Add the claim's eighth participant to the README lists, fix the fresh-id wording in the changelog, and describe the recheck delay, the transient status-0 asset failures and the consecutive read budget. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MRME5T4oEaQ4EBUzrDXMKz --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> * fix(persistence): keep legacy quiz state through Clear Local Cache until the import completes Pre-runtime quiz drafts, answers, results and attempt ids exist only in localStorage, and the one-way importer copies them to the server with their course. Clear Local Cache deleted them even while the import was still pending (it waits for an idle page and can stay pending after a failure), so that quiz progress was lost for good. Clear Local Cache now keeps the four quiz key families unless the importer's ledger records the import as complete, read through a new `legacyImportIsComplete` helper in the ledger module. The ledger key moves there too, so the two modules do not import each other. Once the import is complete, the keys are cleared as before. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MRME5T4oEaQ4EBUzrDXMKz * docs: show DATABASE_URL set in the local development setup The getting-started guide showed the required DATABASE_URL line commented out, so copying the block left the server unable to start. Each locale now shows `pnpm db:up` and, separately, the `.env.local` line to set. The `.env.example` header said every variable is optional; it now says that provider variables are, and that DATABASE_URL is required except under docker-compose.yml. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MRME5T4oEaQ4EBUzrDXMKz * docs(changelog): drop two upgrade notes the final code contradicts The Compose entry said its default DATABASE_URL overrides .env.local; the defaults file is read first, so a value in .env.local wins, as the same entry says further on. The runtime-session entry offered turning server persistence off as an upgrade path; that switch no longer exists. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MRME5T4oEaQ4EBUzrDXMKz * docs(env): restore the ACCESS_CODE warning notes in .env.example A later change to the example file dropped the lines saying that an unset ACCESS_CODE fails open, that the server then logs a one-time startup warning and GET /api/health reports accessCodeConfigured: false. They are back, and say that single-user mode logs its own warning in place of the generic one, as the startup code does. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MRME5T4oEaQ4EBUzrDXMKz * ci: run every app PostgreSQL suite in the contract job The app-domain step named its suites one by one, and seven had been left off, among them the legacy importer's route suite. No other job gives the app suites a database, so they skipped everywhere. The step now lists every app `*.pg.test.ts`; all of them work in a schema or database of their own, or truncate only their own tables, and pass before and after the storage package suite in the same database. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MRME5T4oEaQ4EBUzrDXMKz * fix(identity): establish the anonymous owner before the first API request (#1721) * fix(identity): establish the anonymous owner on the page response On a browser's first load the page sent several API requests without an owner cookie; each minted its own anonymous owner and set its own cookie, and the last answer to arrive won. Work done in between (the legacy import binding, a first course write) then belonged to an owner the browser no longer presented. The middleware now mints the anonymous cookie on document navigations that carry no valid one, with the route handlers' own value format and attributes (the cookie module is Edge-safe now: Web Crypto instead of node:crypto), and forwards it on the request so the page render resolves the same owner. API, RSC, prefetch and Server Action requests never mint there, and a valid cookie is never replaced. It mints only when neither OWNER_SINGLE_USER nor PERSISTENCE_SHARED_OWNER_ID is set; host registrations are invisible to an Edge middleware, so the README tells hosts where to skip it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MRME5T4oEaQ4EBUzrDXMKz * fix(legacy-import): bind only an owner the browser already presents A binding request whose owner was minted by that very request answers 409 OWNER_NOT_ESTABLISHED with the minted cookie and binds nothing: other cookieless requests may still be minting owners of their own, and a binding to this one could be left with an owner nobody presents. The importer treats it as transient and retries on a later load. It also says plainly when a request after its own successful bind is refused as another owner (the owner cookie changed mid-run); items stay pending. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MRME5T4oEaQ4EBUzrDXMKz * test(legacy-import): cover a first visit whose first answers arrive out of order Against the real routes and PostgreSQL: a browser without a cookie loads a page, sends two requests at once, and the first answer reaches it only after the importer bound the browser; the import completes under the one owner the page established. A second case binds nothing for an owner the bind itself minted and imports on the next run. The E2E drives the same ordering in Chromium by holding the first owner-scoped answer until the binding answered. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MRME5T4oEaQ4EBUzrDXMKz * test(legacy-import): drop an unused initial assignment Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MRME5T4oEaQ4EBUzrDXMKz * docs(changelog): note the page response now sets the anonymous owner cookie Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MRME5T4oEaQ4EBUzrDXMKz * fix(identity): let hosts turn page-response minting off The middleware cannot see owner auth methods a host registers, so a host with anonymousFallback: false still got an anonymous cookie on a cookieless page load. Beside the host credential it is a claim candidate; with OWNER_CLAIM_TRIGGER=auto it is claimed and cleared on the next request, again after every such page load. OWNER_ANONYMOUS_PREMINT (default on; true/1/false/0, anything else fails startup) is read by the middleware before minting. At startup the server warns once when a registration turns the anonymous fallback off while pre-minting is still on. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MRME5T4oEaQ4EBUzrDXMKz * docs(identity): document OWNER_ANONYMOUS_PREMINT Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MRME5T4oEaQ4EBUzrDXMKz * fix(identity): keep the anonymous identity for 400 days and renew it in use The anonymous cookie had a fixed 30-day lifetime from its mint and was never renewed, so every anonymous visitor lost their whole library 30 days after the first visit however active they were; with persistence on the server only, that affects every anonymous deployment. It now lasts 400 days (the browser cap, one shared constant), and every route handler and Server Action response that resolves to a valid anonymous cookie re-sends the same value with a fresh Max-Age (best-effort where next/headers refuses writes). Page responses never renew, so pages stay cacheable, and a host principal never renews the anonymous cookie beside it. A response that clears the cookie (a claim, a retired owner) must not renew it, whatever order a route merged its headers in: withRequestOwner, and the owner-events retired answer, keep only the clearing value for a cookie a value clears. A renewal that lands after a claim cleared the cookie is pinned by a test: the next write with it, alone or beside the account, writes nothing, and its answer clears it again without renewing it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MRME5T4oEaQ4EBUzrDXMKz * docs(identity): 400-day renewed anonymous cookie; when to turn pre-minting off Hosts set OWNER_ANONYMOUS_PREMINT=false only when their registration sets anonymousFallback: false; hosts that keep anonymous visitors need pre-minting and skip it per request in middleware for requests their methods authenticate. The CHANGELOG notes the new lifetime and renewal, and that a lost cookie means a new owner. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MRME5T4oEaQ4EBUzrDXMKz * fix(identity): forward owner cookies from routes that resolve the owner directly The Pi chat and whiteboard-visibility routes and asset-id document extraction resolved the owner themselves and never sent its Set-Cookie values back, so an anonymous identity used only through them was never renewed (and a minted one never stored). attachOwnerCookies attaches a resolution's cookies to a response a route built itself, with the clear-wins rule, before a stream's body starts. Both Pi routes answer through it, success and error alike; resolveServerAsset hands the cookies back with every answer and the extraction route attaches them. A guard test scans for every module outside the identity seam that calls the owner resolution (or the asset helper) directly and requires it to forward the cookies; the two callers that cannot are listed with the reason. Behaviour tests cover the three paths. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MRME5T4oEaQ4EBUzrDXMKz --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: wyuc <dhq1204@yahoo.com> | 1 个月前 | |
feat(roster): single-source the generated agent roster on the stage document (#994) * feat(roster): single-source the generated agent roster on the stage document The generated agent roster used to live in two places at once: the stage document carried generatedAgentConfigs, while a per-stage IndexedDB mirror (db.generatedAgents) was the actual read authority — and the only home of each agent's voice binding. The split caused real losses: voices vanished on device switches, exports, and shares (the mirror never travels), the load path trusted a device-local table over the portable document, and roster edits persisted through a bespoke debounce with no beforeunload flush. Make the stage document the single source of truth: - Contract: add optional voiceConfig/voiceDesign to GeneratedAgentConfig in @openmaic/dsl (VoiceDesign moves into the contract; the app re-exports it). Additive optional fields — not a breaking serialized-shape change, so DSL_VERSION stays put per the version policy. - Writes: remove every mirror writer (registry saveGeneratedAgents, the stage store's debouncedSaveAgents bypass, classroom-load hydration, import direct writes). setStageAgents now only marks the stage dirty for the shared persistence scheduler plus synchronous in-memory registry/selection mirrors; generation and import embed the roster on the stage. - Reads: classroom load hydrates the registry from the loaded stage's generatedAgentConfigs; export reads the stage roster directly. - Lazy migration: the mirror is retained read-only. On load, a roster (or voice fields) missing from the document is backfilled from the mirror, committed onto the in-memory stage, and persisted by the next flush — idempotent, and gated on the current stage id after every await so a classroom switch cannot leak a stale roster into the registry. - Deletion: deleteStageData discards the deleted stage's pending persistence work so a queued flush cannot resurrect the document. - Round trip: classroom ZIP manifests now carry the voice fields, so export/import preserves agent voices. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(roster): harden deletion, migration, selection, and ingress paths Review hardening on top of the roster single-source change: - Deletion vs in-flight persistence: deleting a stage now registers a session-scoped tombstone (lib/utils/deleted-stages.ts) before the cascade starts. Every persistence landing point checks it — the scheduled flush and the departing-stage retry drop their snapshots, and the aggregate/incremental saves re-check under the document lock, including the incremental path's full-save fallback that would otherwise rebuild a deleted document from the in-memory snapshot. A deletion that fails before the document is removed lifts the tombstone so the still-existing stage keeps persisting. - Lazy migration termination: a fruitless legacy-mirror probe (nothing to merge — e.g. server-generated rosters, which are voiceless by design) is remembered per stage for the session instead of re-querying the mirror on every load forever. Successful merges are not memoized so a failed flush is retried on the next load. Docstrings now state the real lifecycle. - Selection provenance: setStageAgents no longer overwrites a user-set agent selection with the full roster. Stage-derived selections still track the roster; a user-set auto selection is only narrowed to surviving agents (newcomers are not auto-selected, the user-set flag is untouched), and a user-set preset selection is left alone — matching restoreAgentSelection. - Mirror hygiene: deleteStageData clears the deleted stage's rows from the legacy roster mirror (best-effort), and the mirror table's docs now describe its actual access pattern (migration reads + deletion hygiene). - Registry persistence: the agent registry's persist snapshot partializes generated agents out of localStorage, making the stage document the roster's only durable home; the rehydration merge filter remains as defense in depth. - Ingress validation: imported manifest voice fields are structurally validated (malformed bindings dropped per field, the agent survives), and applyGeneratedAgentsToRegistry validates providerId against the known TTS provider registry instead of casting, treating unknown providers as "no bound voice". - Contract: drop the speculative AgentVoiceConfig.modelId — an audit of every roster producer, current and historical, found none that ever emitted it. Contract fields are added once a producer exists, not before. New tests cover the deletion tombstone at both the store and storage layers (including the missing-destination full-save pin and the lock-race re-check), the fruitless-probe memo, the selection-provenance matrix, persistence exclusion of generated agents, and voice-field sanitization on import. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(roster): close tombstone lifecycle, probe-memo, and selection-intent gaps Second hardening pass over the deletion/migration/selection fixes: - Tombstone lifecycle: a deleted classroom id can legitimately come back (deletion is client-side only). Explicit (re)creation points now lift the tombstone — server-copy restore in applyClassroomStageAndScenes and backup restore in importDatabase — so edits after a same-session restore persist again, while in-flight flushes stay fenced. - Failed deletion now restores the dirt it discarded: deleteStageData snapshots the pending map plus the in-flight round before tombstoning, and re-marks it when the delete fails with the document still present. - Legacy-mirror probe memo now distinguishes a FAILED read (null, retried on the next load) from a confirmed-empty mirror (memoized), so a transient IndexedDB error cannot suppress migration for the whole session. - Incremental save tail (currentScene KV row + chat sessions) is tombstone- fenced like the aggregate path, and both paths re-check the tombstone immediately before every write, with comments scoped honestly for the lock-free (no Web Locks) best-effort LWW fallback. - Roster-edit selection mirror: an empty intersection now falls back to the full roster and clears the user-set flag (matching restoreAgentSelection's length > 0 gate), and a user-set selection that equals the pre-edit full roster keeps tracking the roster wholesale so newcomers are not excluded forever by the AgentBar auto-toggle snapshot. - TTS/ASR provider lookups use Object.hasOwn instead of `in`/bare indexing, so prototype-chain keys ('toString', 'constructor', ...) no longer pass the provider whitelist or resolve to Object.prototype members. - Documented the per-tab limit of the tombstone set. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(roster): replace the boolean deletion tombstone with a deletion-generation model A boolean tombstone cannot express the deletion lifecycle once a deleted id legitimately comes back (server-copy restore on revisit, backup import): after the restore lifts the flag, a pre-delete flush still in flight is indistinguishable from a post-restore edit and can overwrite the restored document with pre-delete content. Two rounds of point fixes kept leaking variants of this race, so this restructures the fence instead. deleted-stages.ts now keeps a per-stage monotonic deletion epoch plus a deleted flag. markStageDeleted bumps the epoch; unmarkStageDeleted clears only the flag and never rewinds the epoch. Every persistence path captures the epoch at the moment it captures the data it will write (flush-round snapshot, departing-stage snapshot, aggregate save entry) and re-checks "captured epoch is current AND not deleted" immediately before each individual write. Invariants: - A write captured before a deletion can never land after it, even when a same-id restore has lifted the deleted flag (the captured epoch is permanently stale). - A write capturing data after a restore observes the current epoch and persists normally. - A failed deletion lifts only the flag; the restored pending changes are re-queued as descriptors, so their flush re-captures current store state under the current epoch and is neither dropped nor a stale replay. Also fixed in the same restructuring: - Incremental tail writes (currentScene, chats) each re-check independently; a delete landing while the first tail write is awaiting now fences the chat write instead of riding the earlier check. - Deleting a classroom evicts it from the warm in-memory store, and loadFromStorage treats a warm-but-deleted stage as not loaded (discarding the ghost), so navigating Back to a deleted classroom reaches the server-restore path instead of rendering an editable ghost whose every edit is silently dropped. - importDatabase records each document's pre-import deletion state and reinstates it when a failed import rolls the document back, so an outstanding flush cannot recreate the rolled-back document. - Docstrings on the failed-deletion restore path now state that only dirt captured before the deletion is restored; edits refused during the deletion window are not part of the snapshot. New tests cover the epoch invariants at the storage layer (pre-delete rounds dropped across restores on the incremental, aggregate, and tail paths; post-restore captures landing; strictly increasing epochs), the scheduler layer (departing-retry delete+restore straddle; restored dirt flushing under the current epoch), the warm-ghost restore driven through the real loadFromStorage, and the import lift/rollback pair. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(deletion): report fenced drops, gate ghost eviction on cascade settlement Edge hardening around the deletion-epoch fence; the epoch model itself is unchanged. - An epoch-stale drop is now reported, not disguised: saveStageData / saveStageDataIncremental return a distinct 'stale-dropped' status (entry check, every in-mutation re-check, and both tail-write re-checks) instead of a shape identical to success. saveToStorage reacts by skipping all success bookkeeping - no chatSnapshot rebind to a snapshot that never landed, no pending-dirt clearing - and returns false, keeping its "true means verified write" contract honest. persistDirtySnapshot keeps its existing (correct) semantics explicitly: a drop has nothing to retry. - Warm-ghost eviction is settlement-gated: the deletion cascade records an in-flight bit (begun by deleteStageData, settled in its finally), and the deleted-warm branch of loadFromStorage keeps the warm state while the cascade is unsettled - a delete can still fail before removing the document, in which case the warm state plus the restored pending dirt is the only copy of the pre-delete edits. Once settled, the ghost discard and server-restore path behave as before. - Read-side landing re-check: loadFromStorage re-checks isStageDeleted after hydration, immediately before set(), mirroring the write-side "re-check immediately before landing" discipline - a lock-free mid-cascade read can no longer re-materialize a ghost classroom. - clearStoreForDeletedStage re-checks isStageDeleted at eviction time, so a same-id restore completing inside the cascade-tail window is never wiped. - capturedEpoch is now a required parameter on both storage entry points: the default (call-time capture) silently reopened the capture-point/ validation-point split for future callers; the type system now enforces the pairing. All production callers already passed it explicitly. New tests cover the dropped-status contract, the cascade in-flight lifecycle (including both failure shapes), keep-warm during an unsettled delete with end-to-end pending restore after a failed delete, settlement- then-eviction on success, the eviction guard against a tail-window restore, and the read-side hydration re-check. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(deletion): settle the deleted-warm load by awaiting the cascade outcome Hardening around the deletion settlement gate introduced previously; the epoch model is unchanged. - The deleted-warm branch of loadFromStorage no longer completes the load while the deletion cascade is undecided (which exposed a classroom whose edits were refused without being restorable, and left a successful deletion's eviction with no follow-up reload). It now parks on a per-stage settlement promise (stageDeletionSettled) and branches on the outcome: a failed delete keeps the warm, restored classroom; a successful delete falls through to a full cold load so the server-restore path recovers the route; a navigation that moved on during the park is stopped by the load token guard. The eviction (clearStoreForDeletedStage) deliberately stops claiming a new load token so the parked load stays current - ghost re-materialization is fenced by the read-side deleted re-checks instead. No deadlock: a parked load holds no document lock, and the cascade never waits on a load. - 'stale-dropped' now propagates through the debounced flush path: persistDirtySnapshot returns the sentinel instead of an empty failure set, and startFlushRound skips the chatSnapshot rebind on it (mirroring saveToStorage) - a falsely rebound baseline would let later chat saves no-op-skip chats that never landed and corrupt cross-tab conflict detection. Pending-map clearing keeps its empty-failure semantics (restored descriptors mint fresh revisions). - The cascade in-flight state is a counter (begin++/settle--) instead of a boolean, and deleteStageData is single-flight per stage (a concurrent second call joins the first cascade), so overlapping deletions can never expose an undecided cascade as settled. - The StaleDroppedSave docstring no longer overpromises: a tail saveCurrentScene can land after the cascade's clearCurrentScene; the orphaned cursor row is ignored by the load path rather than removed by the cascade. Net +8 tests: overlapping-cascade counting, settlement-promise resolution (success and failure), concurrent double-delete single-flight join, the parked mid-cascade Back in both outcomes plus the navigation-away guard, and the fenced flush round keeping an honest chat baseline end-to-end. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(deletion): re-verify store identity on keep-warm resume, re-run joined deletes after a mid-cascade restore Three hardening refinements on the deletion/persistence fencing: - loadFromStorage's parked keep-warm branch now mirrors the success path's store-identity guard: if a tokenless writer (e.g. a database import) replaced the stage during the park, a failed deletion falls through to the cold load instead of reporting another classroom as the kept warm state. The ghost-discard block is correspondingly gated on the deleted flag so the fall-through does not mislabel a surviving document as a ghost. - deleteStageData: a joined call carries its own deletion intent. When the first cascade fulfills but a same-id restore lifted the deleted flag in the meantime, the joined caller now runs one fresh cascade against the restored document instead of reporting the pre-restore outcome. Exactly one re-check per call (no recursion); a rejected first cascade still propagates unchanged to every joined caller. - Narrowed the failed-delete restore-ownership comments: the restore covers only the pending map plus an in-flight flush round's dirt; a departing-stage snapshot is outside that capture and is fenced and dropped by design. Comment-only, no behavior change. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(roster): gate deleted-warm handling on stage identity, keep an explicit empty roster authoritative Final-audit fixes, three items: - loadFromStorage keyed its deleted-warm settlement handling (park / keep-warm / ghost discard) behind the scenes.length > 0 shortcut, so a zero-scene warm ghost of a deleted classroom skipped it entirely: the cold load found no document, the ghost stayed in the store, and the classroom loader's server-fallback gate (!getCurrentStage()) never ran — the silently-uneditable trap this branch exists to close, in its scene-less form. Deletion handling now keys on stage identity alone, before any scene-count shortcut; the non-deleted warm skip still requires scenes. - rosterNeedsLegacyFallback collapsed "roster field absent" (document predates roster persistence) with "explicitly persisted empty roster". A future writer persisting [] on a device whose read-only mirror still held stale rows would have its emptied roster resurrected on every load. The full-lift branch now triggers only when the field is absent (undefined); an explicit [] is authoritative and never probes the mirror. No current writer produces [], but the read side no longer depends on that invariant. - The GeneratedAgentConfig docstring claimed the voice fields are not a breaking change; that only holds for this codebase's tolerant structural validators. The generated stage.schema.json sets additionalProperties: false, so consumers pinning an older published schema artifact reject documents carrying the new fields. The docstring now states both sides honestly. Tests: a zero-scene deleted warm ghost is restored through the real loadFromStorage (fails pre-fix), an explicit empty roster does not resurrect stale mirror rows (fails pre-fix), and the rosterNeedsLegacyFallback unit matrix covers undefined vs []. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(deletion): re-queue aggregate persistence when a failed deletion is recovered Direct aggregate saves (saveToStorage: generation completion, server-restore hydration) run outside the pending map and the flush round, so the deletion path's pre-delete dirt snapshot cannot describe them. When a deletion bumped the epoch while such a save was in flight, the save was correctly fenced ('stale-dropped') — but if the deletion then failed before removing the document, the recovery only re-queued the snapshotted scheduler descriptors. The fenced aggregate-only content stayed memory-only with no retry anywhere, and a later reload lost it. The failed-deletion restore now merges a full re-mark of the aggregate (structure / stage / outline / currentScene / chats plus a descriptor for every current scene) into the recovery set, still guarded on the store holding the stage. Because the flush recaptures the CURRENT store state under the CURRENT epoch, this necessarily carries whatever the fenced aggregate save held — and, for the same reason, edits refused by the scheduler during the deletion window, which previously stayed memory-only as well; the scope docstrings now say so instead of declaring them excluded. New integration coverage drives the real store scheduler and the real storage layer end to end: a genuinely fenced in-flight saveToStorage (generation completion) plus a pre-removal deletion failure ends with the completion flag durable on the next flush; a deletion-window scene edit survives the same way; and a successful deletion re-marks nothing (control). Both recovery tests fail against the previous restore logic. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> Co-authored-by: 杨慎 <117187635+cosarah@users.noreply.github.com> | 3 个月前 | |
fix: restore export after switching classrooms (#1934) * fix: reset failed outlines when loading a classroom * chore(store): write the failedOutlines comment in English Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: wyuc <wang-yc24@mails.tsinghua.edu.cn> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> | 14 天前 | |
release: OpenMAIC 1.2.0-rc.1 (server-first) (#1794) * ci: run CI for the provider-config integration branch Development of the provider configuration RFC (#1701) lands on integration/provider-config; build it on push and run CI for pull requests into it, as with earlier integration branches. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR * feat(config): capability slot registry and stage-to-slot mapping (#1726) * feat(config): capability slot registry and stage-to-slot mapping First P0 step of the provider configuration RFC (#1701, tracked in #1725): define the capability slot forest and map every LLM stage key to exactly one slot. Pure data with no callers yet, so there is no behavior change. Requirements are checked on the slot that declares them and are not passed down to child slots, so agent.title can use a model without tool calling even though agent requires it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR * test(config): pin every stage destination and the capability roots Review found that the station and root assertions derived their expectations from the module under test: re-pointing a single-stage station or dropping a capability root still passed. The tests now compare against an independently written stage table and root list, and pin agent.title as config-only. Also document that browserless outlines still run on the generate-classroom model. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR * test(config): pin slotForStage and full slot lineages Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> * feat(config): provider presets and the openmaic.yml schema (#1727) * feat(config): provider presets and the openmaic.yml schema Second P0 step of the provider configuration RFC (#1701, tracked in #1725). - lib/config/provider-presets.ts: one preset per built-in registry entry, plus the token plans as multi-capability presets whose stage recommendations become slot recommendations. Registry ids that collide across capabilities get explicit preset ids. - lib/server/model-config/openmaic-yml.ts: parse and validate the operator's openmaic.yml (or the file named by OPENMAIC_CONFIG): ${VAR} interpolation, strict schema, and cross-checks that every assignment names a declared provider whose preset offers the slot's capability. Every problem is reported with its path. - instrumentation.ts: an invalid file refuses to start. Without a file nothing changes, and nothing resolves models through the file yet. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR * fix(config): harden openmaic.yml validation after review - Look providers up by own key only, so "constructor:m" is not taken for a declared provider. - Refuse non-mapping objects YAML produces (an unquoted timestamp becomes a Date that the schema would accept as an empty object), and refuse a YAML alias that refers back to itself instead of overflowing the stack. - Accept lowercase variable names; refuse a "${" with no closing brace, checked on the text as written, never on substituted secrets. - Cross-check the entries that are valid on their own even when the schema rejects others, so every problem is reported at once without duplicate errors for declared-but-invalid providers. - Name the three valid assignment shapes when a value has none of them. - Test the boot path: an invalid file exits with code 1 before any schedule starts; a valid one boots. - Ignore a root openmaic.yml in git. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR * fix(config): keep secrets out of openmaic.yml diagnostics Review round 2: - Read only variables the environment itself has, as non-empty strings: ${constructor} or an inherited value is not a variable. - Never print a value that came from ${VAR}, and report YAML syntax errors by reason and position without js-yaml's source excerpt, which can quote a literal key. - Keep the base URL requirement on presets: SearXNG from its registry entry, and Azure OpenAI and self-hosted MinerU, which have no usable default endpoint. - Check slot names and valid model references even inside an entry that fails the schema, and report a failed placeholder once rather than again as an empty value. - Boot test for the no-file case. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR * refactor(config): validate openmaic.yml in two phases Rounds 1-3 of review kept finding edge cases in one feature: running the cross-checks on the half-valid parts of a file the schema had rejected, so that every problem showed at once. It hid problems inside a rejected entry, lost a __proto__ slot key, and built misleading provider errors from failed placeholders. That feature is gone. Validation now runs in two phases. Phase 1 is the document itself: placeholders, key names (checked on the document as written, since the schema drops a __proto__ key), and the schema. Any problem there stops before phase 2, the references between entries, which therefore only ever sees a fully valid file. Each phase reports all of its problems, one per path. YAML syntax errors now report only their position: js-yaml's reason can quote source text too (an alias or tag name), not just its excerpt. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> * feat(api)!: generate-classroom takes requirement + uploaded materials; capabilities follow server config (#1728) Narrows POST /api/generate-classroom to { requirement, materialIds? }; capabilities follow server configuration; materials go through the owner material library (upload, reference by id, delete); new GET /api/generate-classroom/capabilities; skill docs updated. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat(config): resolve a slot through the configuration layers (#1729) * feat(config): resolve a slot through the configuration layers Third P0 step of the provider configuration RFC (#1701, tracked in #1725). resolveSlot walks from a slot to its capability root and returns the first assignment it meets, consulting the deployment layer (openmaic.yml, locked) before the workspace layer at every node. An explicit null disables the subtree; nothing assigned up to the root is unassigned, with no fallback to any vendor. The result carries the provider, preset, registry entry, effective base URL, key, model, call options, fallback, where it was resolved and whether it is locked, and the requested slot's own requirements checked against the model catalogue (met, unmet or unknown). Pure and uncalled for now, so there is no behavior change. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR * fix(config): lock only written slots and trust the catalogue only where it applies Review round 1: - locked now means the requested slot itself is written in the deployment layer. Inheriting a deployment value does not lock a slot, since the workspace may still assign it (source and resolvedAt still report where the value came from). - A custom OpenAI-compatible endpoint borrows the OpenAI registry for transport only, so its preset no longer trusts that model catalogue: requirements there resolve to unknown. - The fallback is checked against the slot's requirements too, and the result exposes fallbackRequirements so retries can refuse it. - A malformed reference fails with a path-qualified SlotResolutionError that does not echo the value, which is often a misplaced key. - Requirement tests use named catalogue models instead of picking one from the catalogue at run time. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR * fix(config): order layers deployment-first and keep references out of errors Review round 2: - resolveSlot no longer depends on the order its caller passes the layers in: deployment layers always come first, for assignments and provider lookup alike. - Resolution errors name the path and the preset, never a value taken from the reference, so a key pasted into the provider position does not end up in a log. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR * test(config): cover catalogue aliases and fallback capability errors Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> * feat(classroom)!: save server-generated classrooms through server persistence (#1730) Server-side classroom generation saves the finished course create-only into the request owner's library with media in the owner's asset pool; jobs move to PostgreSQL with owner-scoped polling; /api/classroom is removed; legacy data/classrooms files are imported once in the background. Adds @openmaic/storage 0.36.0 PgAssetStore.releasePending. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat(config): translate the legacy configuration into a deployment layer (#1731) * feat(config): translate the legacy configuration into a deployment layer Deployments without openmaic.yml keep working: provider variables, server-providers.yml, DEFAULT_MODEL, MODEL_ROUTES and MODEL_FALLBACK are translated into the openmaic.yml shape and serve as the deployment layer for resolveSlot. - Providers: each configured entry becomes a provider under its preset id; force-disabled entries are left out; AliDocMind's key pair goes into credentials. - DEFAULT_MODEL goes on the llm root. Every other slot that serves stages gets what those stages used before (their route, or else the default model), written only where inheritance would give something else, so an unrouted stage under a routed parent stays on the default model. - Stages that now share a slot but had different routes, models whose provider has no server configuration, and stages that used the browser's model but would now inherit a server one become startup notices, never failures. - MODEL_FALLBACK attaches to every chat assignment without its own. - When openmaic.yml exists it wins, with a notice if legacy variables are also set. Nothing reads the layer yet; routes switch over in P1 (#1725). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR * fix(config): follow the agent driver's rules and keep notices value-free Review round 1: - The agent never used DEFAULT_MODEL: it needs a valid maic-agent-driver route and is off otherwise, so the translation writes agent: null where it would inherit a server model (and a notice for a route that does not work today). Unrouted conversation titles reuse the driver's model with thinking off, as the title generator does. - Notices name registry ids only; a credential pasted into a model variable is never repeated. - Provider entries and route options are checked against the new schema and left out with a notice (field names only) instead of producing a file that would not parse; driver-only options are dropped elsewhere. - A route with its own fallback never falls back to MODEL_FALLBACK, even when that fallback cannot carry over; the retry model is part of the comparison with the inherited value. - Force-off switches without a configured entry are reported, a legacy configuration made only of them is detected, and translating one adds a deprecation notice. - loadDeploymentLayer reads the process environment and working directory like the legacy loaders, instead of taking parameters it could only partly honor. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR * fix(config): compare routes by behavior, validate references, name only known ids Review round 2: - Stages sharing a slot are compared by what the route changes for them (model, thinking, fallback), with bare ids read as openai models and a route to DEFAULT_MODEL counted as no route; api and contextWindow are inert outside the driver. - A model reference carries over only when it names a provider from the providers section and forms a valid reference. - Section keys and force-off ids are named in notices only when they are registry ids with a preset. - Retries now follow the slot; where a call site picked its retry model by another label (scene types under scene-content, browserless generation), the change is reported. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR * fix(config): chat references need a chat entry; one notice for retries Review round 3: - A model reference carries over only when its provider was translated from the providers section, not merely declared by another section under the same id. - Routes are compared by the retry model that takes effect, so leaving out a fallback equals repeating MODEL_FALLBACK. - The per-call-site retry notices kept missing cases (streaming and calls outside server-managed routing never retry today). They are replaced by one notice, given whenever a retry model is configured, that retries now follow the slot. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> * refactor(config)!: translate only providers and the default model (#1732) * refactor(config)!: translate only providers and the default model MODEL_ROUTES does not map one to one onto slots: several stages share a slot, the agent driver and conversation titles have rules of their own, and retries are picked by call-site labels. Emulating that took most of the translation and still ended in notices that are easy to miss. The translation now carries over only the unambiguous part: providers, DEFAULT_MODEL as the llm root and MODEL_FALLBACK as its fallback (the agent stays null, as it never used DEFAULT_MODEL). A deployment that sets MODEL_ROUTES without openmaic.yml gets LegacyRoutesError asking it to write the per-stage models as slots. loadDeploymentLayer is not wired into startup yet; that happens when routes switch to resolveSlot (P1). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR * fix(config): say what a dropped MODEL_FALLBACK loses Without DEFAULT_MODEL there is no assignment to hold MODEL_FALLBACK, and calls that retried on it (server providers picked in the browser) stop retrying; the notice says so. thinkingSchema is module-private again. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> * feat(persistence): workspace model configuration with keys encrypted at rest (#1733) * feat(persistence): workspace model configuration with keys encrypted at rest The store behind the web settings of RFC #1701 (#1725, P1): one row per workspace (owner) in workspace_model_config, the same shape as openmaic.yml without policy. - Provider secrets (apiKey, credentials) are sealed per provider with AES-256-GCM under OPENMAIC_SECRET_KEY, bound to the provider id, and never stored in the config column. Without the variable, a secret is created once in data/instance-secret.key (the Docker volume). - A secret sealed under another instance secret is reported as unreadable ("enter again"), and a save that brings no new secret for that provider keeps it, so a misconfigured secret cannot destroy keys. - Saves replace the document with a compare-and-swap on the revision, after the owner's identity lock; two concurrent first saves cannot both land (PostgreSQL contract test). - A claim moves the anonymous configuration to the account unless the account has its own (core participant, order 900). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR * fix(persistence): publish the instance secret atomically; force the races in tests Review round 1: - The generated secret is written and flushed under a private name, then published with an exclusive link, so no process can read it half written and exactly one of two starting processes creates it. A file that is not a complete generated secret (empty, truncated) is refused instead of deriving a key anyone could compute. - The PostgreSQL test holds both first saves at their insert until both have read the missing row, and holds the row lock for the update case until both saves queue behind it (checked by backend pid, not by any lock waiter in the database). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR * fix(persistence): verify the written secret; require full GCM tags Review round 2: - The generated secret is written in full, read back and checked before it is published; the temporary file is removed on any failure. - Sealed values open only with a 12-byte IV and a full 16-byte tag (authTagLength), so a shortened tag cannot weaken authentication. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> * feat(config): resolve slots at request time over deployment, workspace and defaults (#1734) * feat(config): resolve slots at request time over deployment, workspace and defaults The runtime half of RFC #1701 resolution (#1725, P1): - deploymentConfig(): the deployment layer, loaded once per process. The legacy translation now splits into the deployment's providers and a separate default layer (DEFAULT_MODEL, MODEL_FALLBACK) that locks nothing and ranks below the workspace, as DEFAULT_MODEL ranked below the model a user picked. - workspaceLayer(owner) reads the web settings; requestWorkspaceId(req) names the request's owner (none for an owner minted by the request). - lookupSlot walks deployment and workspace over the whole tree first; the defaults are a second walk, so a default on a child never outranks a workspace choice higher up. - resolveStageModel builds the language model: the configured slot, else what the request still names the old way (deprecated), else the defaults, else a loud error; a slot turned off fails whatever the request names. A workspace provider's endpoint is checked like a caller-supplied one and gets the transport that refuses redirects. - resolveSlot gains the default source, and reports which layer declared the provider, its proxy and credentials. Nothing calls it yet; call sites switch next. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR * fix(config): keep request identity and workspace endpoints inside their bounds Review round 1: - A refused owner credential throws InvalidOwnerCredentialError (401) instead of resolving with the deployment's models and keys. - A retired request owner gets no workspace: canonicalizing it would hand an old anonymous cookie the claiming account's settings. Only background work, which names a stored owner, is forwarded. - A workspace provider may not be Amazon Bedrock (which falls back to the server's AWS credential chain) or set a proxy (which routes around the checked, redirect-refusing transport); the deployment still may. - Test seams for the deployment config and the workspace loader, and tests for identity, forwarding and caching. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR * fix(config): read only the named owner's settings; refuse unmet requirements Review round 2: - workspaceLayer reads exactly the owner it is given. Forwarding through a claim let a request whose owner was claimed between its check and the read see the account's settings; background work passes the owner it works for now instead. - A model the catalogue says does not meet the slot's requirement is refused before anything is built (SlotRequirementError), without consulting the request. - Tests: a database failure fails the call without falling back, a workspace provider without a key never gets the deployment's, and the transport each provider source gets. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR * test(identity): exempt the model settings lookup from cookie forwarding requestWorkspaceId resolves the owner only to pick whose model settings a generation call uses, and returns no response of its own, like the vision prompt helper already listed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> * feat(config)!: resolve every LLM call through its capability slot (#1735) * feat(config)!: resolve every LLM call through its capability slot The LLM call sites of RFC #1701 switch to slot resolution (#1725, P1): - resolveModel resolves a stage through its slot for the request's or job's workspace: the deployment and workspace configuration first; the model and key a request names (x-model, x-model-routes, body fields) only for a slot left unassigned, deprecated; then the defaults an older deployment set with DEFAULT_MODEL. Routes that read headers get this through resolveModelFromRequest; the chat routes pass their workspace; background work (agent runs, titles, generation jobs) passes the owner it works for now. - Retries follow the slot: a slot-resolved model carries its slot's fallback (lib/ai/model-fallbacks.ts), which callLLM and the outline stream use; only a model from the request path still retries on MODEL_FALLBACK. A fallback that cannot meet the slot is not used. - The agent driver resolves the agent slot: tool calling required, api defaults to openai-completions, thinking.effort still refused. Conversation titles resolve agent.title with thinking off unless the title slot sets it. - /api/generate-classroom resolves each step through its slot for the job's owner, its outline through course.outline. - MODEL_ROUTES is gone: loadDeploymentLayer runs at startup and a server that still sets it without openmaic.yml refuses to start. The pbl-chat and maic-agent stage keys, which nothing resolved, are removed. BREAKING CHANGE: MODEL_ROUTES is no longer read; write per-stage models as slots in openmaic.yml. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR * fix(config): a slot fallback needs no serverManaged stamp; follow claims per stage Review round 1: - callLLM arms a model's attached slot fallback whether or not the caller passes serverManaged (the PBL agents pass none); the stamp still gates MODEL_FALLBACK on the request path. - /api/generate-classroom resolves the owner the job works for now at each stage, so stages after a claim follow the moved settings. Streaming calls (agent driver, classroom chat, PBL streams) still do not retry on a fallback model, as before this change; that is the #1725 item on retries at every call site. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> * feat(config): resolve media and tool capabilities through their slots (#1737) * feat(config): a provider-only reference names the provider's default model Search and document providers mostly have no model to pick, so a slot may name just the provider (`webSearch: tavily`); the target then has no modelId and the adapter uses its default. Chat slots still need providerId:modelId, checked in openmaic.yml and at resolution. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR * feat(config): resolve media and tool capabilities through their slots The capability routes and server paths of RFC #1701 (#1725, P1): text to speech, speech recognition, images, video, web search and document extraction resolve through their slots, like language models. - lib/server/model-config/media.ts: the configured slot (deployment, then workspace); else the provider a request names the old way (deprecated); else the legacy defaults; else a loud error. A slot turned off fails whatever the request names, and while the legacy <CAP>_<VENDOR>_ENABLED=false switches are in effect a switched-off provider stays off whoever assigns it. A workspace endpoint is checked like a caller-supplied one and may not use a proxy. - The legacy translation assigns the media roots the provider the server picked when a request named none (first configured; web search by its old priority; DEFAULT_IMAGE_PROVIDER for images), with the first pinned model. - Routes: /api/generate/{image,video,tts,voice}, /api/transcription, /api/web-search, /api/parse-pdf and /api/extract-document. A TTS voice applies only to the provider it was chosen for; voices register on the tts slot's provider. - Server paths: agent image and video generation, scene narration, the voice catalog and registration, web search and fetch_url, material extraction (the document slot's service, the asr slot for local transcription), browserless classroom media, and the generation capabilities reported by /api/health and the classroom capabilities endpoint. - A provider-only reference (`webSearch: tavily`) names the provider's default model; chat slots still need providerId:modelId. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR * fix(config): keep slots authoritative on every media path - Workspace providers reach media, search and document services only at their preset's endpoints; a custom endpoint or proxy is deployment-only (403 INVALID_URL). - A configured or turned-off document slot decides the extraction service: deprecated request fields may only pick a self-contained extractor, and legacy operator credentials are no longer reached. - Request paths resolve their own workspace without following a claim; background extraction still does. - A configured TTS slot keeps its own model; legacy pins apply only on the deprecated and default paths. - Local transcription and agent voice registration keep the connection's network policy flags. - The agent's web search forwards the whole resolved configuration. - An unusable DEFAULT_IMAGE_PROVIDER leaves the image slot unassigned with a notice instead of switching vendors. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR * fix(config): public presets only for workspaces, plan default models - A workspace preset whose default endpoint is on the server's own network (self-hosted TTS, ASR, image) is deployment-only, and a workspace provider's endpoint runs under the public-only policy. - A provider-only reference to a token plan means the plan's own default model for that capability. - On the legacy default provider, the request's image, video and ASR model still applies through its allowlist, and image/video without a model is still MISSING_MODEL. - An asr assignment a workspace may not use no longer blocks document extraction, and a refused document service answers 403. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR * fix(config): keep legacy search and voice choices on their old paths - Classroom search honours the provider and key a request names while the webSearch slot is unassigned; only /api/web-search prefers the operator's configured backend, as before. - A TTS request's voice applies unless the request chose it for another provider. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR * fix(config): legacy search model and capability discovery errors - On the legacy default search provider, the request's search model still applies through the server's pins. - Capability discovery answers a refused credential (401) or a workspace service it may not use (403) instead of 500. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR * fix(config): classroom submission refuses a workspace service with 403 A classroom submission whose materials would go to a document or speech service the workspace may not use answers 403 INVALID_URL instead of 500, and its material check resolves the request's own workspace without following a claim. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR * fix(config): workspace image and video providers run public-only A workspace-configured image or video provider now uses the strict public transport for its requests and for the redirects its clip download follows, whatever ALLOW_LOCAL_NETWORKS says; the operator's own providers and the deprecated per-request path keep their policies. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR * Revert "fix(config): workspace image and video providers run public-only" This reverts commit 68021ded. A workspace provider cannot choose an endpoint for media, search or document services at all: a custom base URL or proxy is refused, and so is a preset whose default endpoint is on the server's own network. What remains is a preset's fixed public endpoint, the same one a deployment default uses, so these calls keep the operator's transport policy like every other preset endpoint, and the connection no longer claims a user-typed endpoint (userEndpoint is false). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR * fix(config): /api/web-search without a provider searches as before A deprecated web search request that names no provider again means the server's configured provider, else the default one with the request's key, while the webSearch slot is unassigned. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR * fix(config): browser speech recognition does not count for extraction An asr slot assigned to speech recognition that runs in the browser gives server-side extraction no transcription service, so audio uploads are not advertised or accepted for extraction. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR * fix(config): /api/parse-pdf honours an explicit local parser A request that asks for a self-contained extractor (local parsing) parses the PDF locally and sends it to no document service, whatever the document slot names, as /api/extract-document already did. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR * test(classroom): give the persistence contract its media slots The generated-classroom PostgreSQL contract configured its image and TTS providers through the legacy provider mocks, which slot resolution no longer reads; it now sets the deployment's slots. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> * feat(llm): streaming calls fall back on their slot's fallback (#1739) * feat(llm): streaming calls fall back on their slot's fallback A stream resolved through a slot that fails before any content (the request is refused, or the first part is a retryable error) runs once on the slot's fallback model. A failure after content has started still reaches the caller. The outline stream keeps its own retry and fallback handling. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR * fix(llm): the stream fallback is the call's last attempt, before any content - The primary's own SDK retries run first; the fallback runs once, as the last attempt, and a failing fallback is not retried. - No fallback once content reached the caller in any step (a tool that ran must not run again); after a fallback took over, later steps stay on it. - The fallback gets the thinking options built for its own provider and model, not the primary's. - Usage of a stream with a slot fallback is recorded per step, against the model that served the step. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR * fix(llm): stream fallback recognises transient error payloads Providers send a stream's error part as a plain { type, message } payload rather than an error; a transient type (overloaded, rate limited, server error) now counts as retryable for the slot fallback, while authentication and request errors still do not. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> * feat(config): model settings API for workspaces (#1740) * feat(config): model settings API for workspaces GET/PUT /api/model-config read and edit a workspace's slots and providers: every slot with its own assignment, effective model, source and lock state; deployment providers read-only and without credential details; workspace keys write-only and masked. Edits are checked against the whole configuration and a revision. The view carries the preset catalogue a workspace may add providers from, and each provider's models per capability, so the settings UI needs no registry code of its own. Workspaces cannot add Bedrock, self-hosted media/search/document presets or custom endpoints for anything but chat. POST /api/model-config/import merges settings a browser kept, item by item under the same checks, never replacing what the workspace or deployment already has. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR * refactor(config): preset ids as client-safe data The preset id tables move to lib/config/preset-ids.ts, which imports no registry, so browser code (the settings import) can name presets. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR * fix(config): tighten the settings API's views and checks - Effective targets name an endpoint only for the workspace's own providers; a deployment's endpoints never reach a response. - A workspace provider of a local model server preset (whose default endpoint is the server's own network) must name its own endpoint. - Every change is checked against the stored shape, so an import skips a malformed item instead of failing the batch, and PUT validates its whole body (400, never 500). - Clearing a key deletes it even when the instance can no longer open it. - A workspace provider with its own endpoint offers chat models only, and an OpenAI-compatible provider offers only the models it lists. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR * fix(config): no credentials in endpoints; key-pair presets stay in the yml - A workspace base URL cannot carry a username or password (they would be stored and shown in the clear), and views never show credentials a stored endpoint carries. - Presets that authenticate with a key pair (AliDocMind) are not offered to workspaces: the settings take one key per provider. - An import checks each proposed provider on its own, so one malformed provider is skipped instead of failing the batch. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR * fix(config): settings neither offer nor accept switched-off providers A provider the operator switched off for a capability (the legacy <CAP>_<VENDOR>_ENABLED=false switches) is left out of the capability lists, refused as an assignment, and shown as invalid where a deployment assigns it, as the calls themselves refuse it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR * fix(config): recommendations name only capabilities a preset still offers Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR * fix(config): a provider id follows the reference grammar before it is used Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR * fix(config): the catalogue lists the models a search provider offers Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> * fix(config): refuse a fallback on a slot whose calls never use one (#1743) * fix(config): refuse a fallback on a slot whose calls never use one Only language-model calls retry on a slot's fallback; a fallback on a speech, image, video, search or document slot was accepted and silently did nothing. Resolution now refuses it by path, so openmaic.yml fails at startup and the model settings refuse it on save. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR * fix(config): openmaic.yml refuses a non-chat fallback at startup The boot check parses openmaic.yml without resolving slots, so the refusal also lives in the file's cross-checks. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> * feat(settings): Models section with the course model map (#1741) * feat(settings): client for the workspace model settings A small client for /api/model-config: it caches the view per page, applies changes against the revision it read, reloads on a stale revision (409 CONFLICT) or a slot the deployment has since locked, and treats a server without persistence (404) as settings managed by the server. Pure helpers behind the settings UI live beside it: slot changes from the card picker (follow, off, a model, a fallback, the media switches), the provider form's change (keys stay write-only: keep, replace or remove), the first-run setup that adds a provider and fills only the empty, unlocked slots with its preset's recommendations, and the layout of the course model map. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR * feat(settings): Models section with the course model map A new "Models" section, first in the settings dialog and the one it opens on. It reads and writes everything through /api/model-config: - The model map: a pannable, zoomable canvas with the course pipeline as a two-row serpentine flow, the default language model above the stations that inherit from it, and the page types under the content station. Each card shows the effective model, where it comes from and a lock when the server sets it. Solid edges follow the parent, dashed ones mark a setting of the slot's own. - Editing happens in place: a picker at the card to follow the parent, pick a model a provider offers (or a provider, for search and document slots), turn the slot off, or set a fallback for chat slots. Media switches turn a slot off and back on. - While no language model is configured, the default model's card offers a first-run setup: pick a service, give its key, and its recommended models fill every empty slot. - A Providers tab lists the server's providers (read-only) and the workspace's own, which can be added, edited and removed as the server's policy allows. The generation toolbar's "configure provider" prompt opens this section. Below the sm breakpoint the settings nav becomes a strip above the panel. Strings are translated for all 12 locales. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR * fix(settings): keep what a change does not mean to touch - A provider edit carries only the fields the form shows: a hidden model list or endpoint is left out, so the server keeps it; a shown field that is emptied is removed. A pinned model list keeps its field on edit. - A key the server cannot read starts as "replace" (it can also be removed), so a key typed for it is sent instead of the broken one being kept. - Switching a media slot off remembers what it held; switching it back on restores that assignment (or nothing of its own), and when that is not known the caller asks instead of clearing the slot to the default. - The first-run assignments are checked against the provider as the server answered it: a provider with its own endpoint serves chat only, a model list the user gave wins over the recommended chat models, and `llm` is one of the provider's own chat models. The filling can be retried against a reloaded view, and a refusal keeps its reason. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR * fix(settings): recoverable first-run, root clear action, keyboard on the map - A first-run setup that added its provider but could not assign it (a stale revision, say) reports to the section, which keeps a notice with the provider and a way on (assign its models again, or add them under Providers) through any reload, instead of losing it with the form. - The picker of a root slot with a setting of its own offers to clear it, leaving the server's value or default. - A card's switch restores what the slot held; when that is unknown it opens the picker. - Keyboard focus on a card outside the view pans the map to it, and the arrow keys pan the map while it has focus. - The provider form offers only replace or remove for an unreadable key, and says that a provider with its own endpoint serves chat only. Component tests drive the provider form, the switches, the root picker, arrow-key panning and a first-run setup that meets a conflict. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR * fix(settings): keep picker row labels whole; one unreadable-key notice A long note no longer truncates the row's label in the slot picker, and the provider form stops repeating the unreadable-key warning the row already shows. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR * fix(settings): a lost answer to a write is an outcome, not a throw When the server takes a change but its answer cannot be read (a truncated body, a dropped connection), apply() no longer rejects: it reloads the view to reconcile a change that may have been saved and returns an `unconfirmed` outcome with the reloaded view. The first-run setup goes on when the reloaded view has the provider it added, counts a slot write whose answer was lost as done when the reload shows the default model set, and says so otherwise. Busy states in the provider form, provider removal, first-run setup and its notice are reset in `finally`. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR * fix(settings): keyboard roving in the slot picker The picker's rows are plain buttons (pressed for the current choice) in a group, not listbox options without the keyboard model those promise. The list is one Tab stop, the current choice or else the first row; ArrowUp and ArrowDown step, Home and End jump, Enter and Space choose. The picker opens on that row. The picker's and the card switches' busy states are reset in `finally`, and the component tests cover the lost-answer first-run paths and the picker keyboard. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR * fix(settings): a write lost in transport is unconfirmed, and first run can resume A write whose request fails in transport may still have been saved, like one whose answer is lost: apply() now reloads the settings and returns an `unconfirmed` outcome in both cases, with the reloaded view when that read worked. A first-run setup whose provider add cannot be confirmed (the reload failed too) keeps a notice that says so, and "Check again" reads the settings and either assigns the provider's models or says it was not added. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR * fix(settings): retry an unconfirmed provider add under the same id The add-provider form keeps the id it tried. When the answer was lost it closes if the reloaded settings show that provider, and otherwise retries under the same id, so a save that did land is updated rather than joined by a second provider; a genuinely new add still gets a fresh id. Component tests cover this and the first-run paths where the provider add fails in transport after the server saved it, or cannot be confirmed at all. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR * fix(settings): 5xx answers to writes are unconfirmed; reads never go back A server error or a gateway timeout can come after a write was saved, so a 5xx answer to a write is treated like a lost answer: the settings are read again and the outcome is `unconfirmed` with the reloaded view. A 4xx is still a refusal, without a reload. Reads and adopted write answers are numbered: a read's answer is dropped when a later read started or a write's answer was adopted after it began, or when its revision is older than the view held. The reads that must see a write just made (after an ambiguous write, a conflict, "Check again") start a fresh request instead of joining one begun before the write. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR * fix(settings): first run is done only once the default model is set A slot write that succeeds without setting `llm` (another session turned it off meanwhile, so only media slots were filled) no longer counts as a done setup: the notice stays and says the default model is still missing, for the user to pick on its card. Test views now carry `llm` resolved the way the server answers it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR * fix(settings): the revision decides which settings view is newer A view with a lower revision than the one held never replaces it, whether it comes from a read or a write's answer, and one with a higher revision always does, whenever it arrives; the order in which reads and adoptions were numbered only breaks ties at an equal revision (and decides while no view is held, so a read begun before the view was forgotten does not bring it back). A write whose answer is older than a view read meanwhile returns the view that is current. `adopt` takes a view or null (forget it) and is part of the client. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR * fix(settings): send each change against the view it was worked out from apply() takes the view a change was computed from and sends that view's revision, rather than the revision of whatever view is held when the write goes out. A retry of the first-run assignments that waits for a reload in flight is therefore refused (409) when the reload shows the settings changed, instead of overwriting a slot set meanwhile; the reload then feeds the next attempt. The slot picker, the card switches, the provider form, provider removal and the first-run setup all pass the view they showed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR * fix(settings): a provider edit sends only what changed, and rebases on a conflict The provider form keeps the basis of an edit: the provider as the edit began and the view it came from. A save sends only the fields changed against that basis (a key-only edit sends the key), against the basis view's revision. When the provider changed elsewhere meanwhile (409), the edit moves onto the reloaded provider, keeping only what the user changed, and the form says the provider changed before it is saved again. A 409 now returns the reloaded view to work the change out again from. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR * fix(settings): a refused provider add keeps its draft and picks a free id Only an add whose outcome is unknown (a lost answer, a transport failure, a 5xx) is reconciled by looking for its id in the reloaded settings. A confirmed refusal (a 409, another tab having added the same preset under the same id meanwhile) saved nothing of ours: the form keeps the draft, shows the conflict, and the retry uses an id still free in the reloaded view. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> * refactor(settings)!: server-side model settings only; import browser settings once (#1744) * feat(settings): one-time import of browser model settings The settings store's migration to version 5 builds an import proposal from the provider state earlier builds kept in the browser (keys, custom endpoints, the chosen model, token plan enrollment, per-capability selections) and keeps it under its own localStorage key. Once the store has hydrated, the proposal is posted to /api/model-config/import: a 2xx answer removes it (and the keys) from the browser, 400 drops it, and anything else keeps it for a later load. Per-stage routes are not imported. Clear Local Cache keeps a proposal still waiting. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR * refactor(settings): stop sending provider data from the browser Every request now leaves model and provider choice to the server, which resolves it through the workspace's capability slots: no model, key or base URL headers (x-model, x-api-key, x-model-routes, x-image-*, x-video-*), no provider, key or thinking fields in chat, TTS, voice registration, transcription, web search or document extraction bodies. The server keeps accepting them until they are retired. What the client still needs to know is read from the /api/model-config view (lib/model-settings/capabilities.ts): whether a language model is set up, which provider the tts slot names (browser speech plays locally; voice lists follow that provider), whether speech input, image, video and web search are available, and the course model the toolbar shows or edits through the settings API. The scene concurrency comes from /api/health. The unused TTS config popover and getCurrent*Config helpers are removed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR * refactor(settings)!: remove the browser provider state and its settings UI The settings store keeps only the user's own preferences (playback, narration voice and speed, speech input language, outline review, agents, layout). Providers, keys, base URLs, the model choice, thinking settings, per-stage routes, token plan enrollment and seeds, the per-capability provider configs and selections and their on/off switches are gone; the version 5 migration sets them aside for the one-time import and drops them. The narration voice now records the provider it was picked for and applies while the tts slot names it. The Token Plan, Model Services and Course Model sections and their components are removed, with apply-token-plan, the server provider sync (ServerProvidersInit, fetchServerProviders) and helpers only they used. A Voice section keeps the per-user parts of the old speech settings: speed, a narration test, the VoxCPM and Qwen voices, and the speech input language, showing which services the workspace uses. The home toolbar picks the course model through the settings API, or shows it read-only when the deployment locks it. e2e fixtures answer /api/model-config instead of seeding browser providers; the managed-provider spec, which covered the removed panel, is removed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR * feat(settings): browser speech recognition while the asr slot is unassigned Speech input worked out of the box through the browser's own recognition, which needs no server provider; it stays available while the asr slot is unassigned, and turning the slot off turns speech input off. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR * fix(settings): bind the model settings import to its owner and never lose staged keys The import now asks for the browser's legacy import binding and sends X-OpenMAIC-Legacy-Import, so owner resolution refuses it for any owner that does not hold the browser (409 LEGACY_IMPORT_NOT_BOUND keeps the proposal for a later load); the import route joins FENCED_ENDPOINTS. Its completion is the proposal's own key, apart from the course ledger. Staging reports whether it succeeded. When the proposal cannot be written (a full storage, an unreadable proposal waiting), the migration keeps the old settings in legacyModelSettings and every load retries, writing the store back without them once staged. Nothing logged quotes the proposal or an error message: fixed text, item ids, error names. Older shapes are normalised before the proposal is built (version 0 default model, the single TTS model setting, global TTS/ASR model ids, a TTS provider's model field, the flat web search key). Capabilities the user turned off are proposed as off (null), and selected server-configured media providers are named by their preset id. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR * fix(settings): retry a failed read of the model settings A failed read of /api/model-config with nothing to show is read again with a backoff (2 s up to a minute) until one succeeds, instead of leaving the page without capabilities for the rest of its life. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR * fix(settings): send no provider or model with voice registration Voice registration, deletion and auto-registration no longer send providerId or ttsModelId: the server registers with the provider and model the tts slot resolves to. The model still derives the voice id and keys the session memo. The unused OpenRouter model list hook, which called the vendor with a browser key, is removed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR * fix(settings): carry only an explicit speech input off into the import Only `asrEnabled: false` becomes an off slot (`asr: null`): without an asr slot the browser's own speech recognition would take over, which the user had turned off. The TTS, image, video and web search switches were per-browser toggles that availability following the slots replaces on purpose; turning them into lasting workspace nulls would override the deployment's defaults from then on, so they are not carried over. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR * fix(settings): gate chat and generation on the slots they resolve Chat and discussion resolve the classroom slot, and course generation its outline, actions and content slots; each is now allowed whenever those resolve to a model, even with the llm root unassigned or off (a child assigned on its own). Settings not read yet still block nothing. The toolbar offers "Set up model" only when a course cannot be generated. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR * fix(settings): import models the user added to built-in providers A built-in chat provider whose model list held models the user added (ids not in its catalogue) is proposed with those models, listed after the catalogue's: a provider's model list names the chat models it serves, so listing only the added ones would hide the catalogue. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR * fix(settings): wait for the commit explicitly in the media overlap test The overlap test counted 50 microtasks for the mocked commit to be reached; with the capability read before each pass that is no longer enough, so the test failed alone and left a pass running into the next test. It now awaits a signal from inside the commit, flushes every pending microtask before asserting, and releases and awaits both passes in `finally`. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR * fix(settings): import models the user added to an enrolled token plan An enrolled plan's chat provider whose model list held models the user added (ids not in the plan's own list) is proposed with them, listed after the plan's, by the same rule as built-in providers. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR * fix(settings): scope voice registration memos to the tts slot's provider The session memo and in-flight map of auto-voice registration were keyed on the voice and model only, so after the tts slot moved to another backend serving the same model, registration was skipped and synthesis named a voice that backend never registered. They are now scoped to the provider the slot resolves to (provider id, registry entry, endpoint) and the settings revision, captured once per registration. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR * fix(settings): show the view the model settings import produced After a successful import the page read the settings again, but that read could join the page's first read, still in flight from before the import, and leave the unassigned view in place. The import now hands over the view the import route answered, and adoptNewerView lets any read in flight finish before keeping whichever view is newer by revision, so an older answer cannot land over it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR * fix(settings): offer and resolve voices against the tts slot's model With the tts slot on a model that cannot speak some voices (an OpenAI slot on tts-1 and Marin or Cedar, which need gpt-4o-mini-tts), the picker still offered them and synthesis on the slot's model failed. The slot's model (its own, else the provider's default) is now the model voices are offered under and checked against: the voice lists hold only voices it can speak, a persisted voice it cannot speak falls back to a compatible default, and narrator and agent bindings to such a voice are not used. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR * fix(settings): drop translations only the removed provider panels used 208 keys that only the removed provider settings (model services, token plan, course model config, their dialogs and the TTS popover) used are removed from all 12 locales: API key and base URL fields, provider and model editors, connection tests, per-capability switches and the like. Each was checked to have no remaining reference, literal or through a key prefix built at run time. The TTS enablement locale test keeps the key still in use. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR * fix(config): provider options in openmaic.yml, passed to the TTS adapters A provider in openmaic.yml may carry `options`: non-secret, provider-specific settings (a map of names to strings, numbers or booleans; `${VAR}` interpolation applies). They reach the resolved slot target, the media connection and the settings view, and the server's TTS paths (the TTS and voice routes, scene narration, classroom media generation, the voice-clone tools) hand them to the adapter as its providerOptions: over a request's options for a configured slot, under them while the slot is unassigned (the deprecated path). Voice registration follows them too (a VoxCPM backend without runtime registration is refused). Workspace providers cannot set options: they are the deployment's. The legacy configuration had no equivalent. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR * fix(settings): read the VoxCPM backend from the tts slot's options The voice settings, the voice lists and auto-voice registration assumed the default VoxCPM backend once the browser no longer chose one. They now read the `backend` option of the provider the tts slot resolves to (shown in the settings view), and the registration memo is scoped to the provider's options as well. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR * fix(settings): label the preset choice when adding a provider Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR * fix(settings): media planning from the slots; stop when settings are unread The outline route decided whether to plan images and video from the client's x-image-/x-video-generation-enabled headers, and the client sent them from settings it might not have read: a course could be generated silently without media. The route now reads the workspace's image and video slots itself (an explicit `false` header still lets an API client opt out; `true` turns on nothing), and the client no longer sends them. When the model settings cannot be read even after another try, generation stops with a message instead of leaving out narration (the scene fails and generation pauses; the preview's first scene errors), and a media pass or retry stands down without marking anything as disabled. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR * fix(settings): pick the course model when none is set Without a default model the toolbar showed no picker, so the course model could only be set in Settings. It now offers the picker whenever the workspace may set the llm slot and chat models exist, with nothing selected until one is picked (which sets the slot; the server assigns nothing on its own). The legacy translation's notice for a model whose provider the server does not configure now says each workspace chooses its model in Settings → Models, or to configure the provider and set DEFAULT_MODEL. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR * fix(settings): import keyless search choices and the Claude search model A keyless search service (Brave) that the user selected with research switched on is proposed as a provider of its preset with the webSearch slot on it; untouched defaults and self-hosted services (SearXNG, whose endpoint only the deployment may set) are not. The model picked for Claude web search is carried as `claude:<model>` instead of being dropped. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR * fix(config): refuse credential-like provider option names A provider's `options` are shown in the settings view, so a name that matches key, secret, token or password is refused with a pointer to apiKey and credentials. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR * fix(settings): pin which course slots the generation gate needs courseGenerationUsable requires the outline, actions and a content slot. The agents and research slots are not required, and a test pins it: with course.agents off the roster falls back to the preset agents, and with course.research off web search runs on the raw requirement, so neither request is refused by the server. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR * docs(settings): what the import leaves behind; extraction is a slot The importer README lists what is not carried over and where it is set now: per-stage routes, the per-browser media and research switches, Baidu sub-sources, thinking settings and the VoxCPM backend (options in openmaic.yml). The document extraction README no longer documents the removed store fields and request-level provider fields: extraction is the workspace's document slot. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR * fix(settings): release the recorder lock when a start is refused startRecording returned early without clearing its lock when speech input was not set up (the asr slot off, or still unknown while the settings loaded) or the browser lacked speech recognition, so every later click did nothing until the component remounted. Each early return now releases the lock. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR * fix(settings): decide research from a successful settings read The home page saved `webSearch: undefined` into the generation session when the model settings could not be read, and the preview researched only on that saved flag, so a transient failure skipped research for the whole generation. Saving the session now needs a successful read (read again after a failure, else it stops with the "settings could not be read" message), and starting or resuming generation decides research again from the current webSearch slot. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR * test(settings): type the research decision session Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> * fix(config): a policy without workspace providers also stops using them (#1750) * fix(config): a policy without workspace providers also stops using them `policy.allowWorkspaceProviders: false` only stopped workspaces from adding or editing providers; ones added before kept serving their assignments. Resolution now leaves them out, with the workspace assignments that name them (an assignment whose fallback alone names one keeps its model), so the policy takes effect for existing workspaces too and the settings view shows what the calls use. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR * fix(config): policy keeps shared provider ids and refuses new references - A workspace provider id the deployment also declares resolves to the deployment's provider, so its references are kept under the policy. - The model settings refuse a new assignment to a workspace provider the policy no longer allows, while other edits leave dormant ones as they are. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR * fix(config): settings validate a workspace as the policy lets the calls see it Assignments the policy leaves dormant are not checked on unrelated edits. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> * docs(config): openmaic.yml, capability slots and the model settings (#1742) * docs(config): document openmaic.yml, slots and the model settings The configuration docs are rewritten around openmaic.yml: providers, the slot tree and its inheritance, assignment forms, provider-only references, fallbacks, turning a capability off, locks and the workspace model settings, policy, OPENMAIC_SECRET_KEY, what workspaces may add, the deprecated request fields, and migration from provider variables, server-providers.yml, DEFAULT_MODEL/MODEL_FALLBACK and MODEL_ROUTES (with the stage-to-slot table). The environment-variable reference stays as the legacy configuration. Deployment, supported models (a preset catalogue), getting started and VoxCPM2 point to openmaic.yml; the READMEs' quick configuration and the agent runtime example use it; the openmaic skill tells agents to configure models through openmaic.yml or the model settings. openmaic.example.yml is a starting point, and a test parses it and every documented openmaic.yml example with the real schema. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR * docs(config): translate the openmaic.yml documentation Brings the zh-CN, zh-TW, Japanese, Russian and Arabic pages in line with the English configuration, deployment, supported models, getting started and VoxCPM2 pages: the same sections, examples and tables, with the legacy environment-variable reference kept as before under its own heading. Also fixes the zh-CN VoxCPM2 link to the TTS section, which pointed at the English anchor. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR * docs(config): match the settings UI names, set a model in the quick start - Configuration: the settings tabs are "Model map" and "Providers", the lock reads "Set by the server", and the first-run setup is "Connect a service". Browser import: keys are removed from the browser once imported (a failed import keeps them), and speech input that was switched off becomes asr: null. - Getting started: the browser no longer sends a model, so the quick start sets one, with a minimal openmaic.yml or DEFAULT_MODEL next to the key, and names Connect a service in Settings > Models as the path without a file. - openmaic.yml is in .dockerignore so a file with inline keys is never baked into an image; the deployment page says to mount it at run time. All six locales are updated where the text changed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR * docs(config): scope fallbacks, trim the example, translate volume repair - Fallbacks apply to language-model slots (llm and the slots below it); a fallback on a media, search or document slot is refused at startup or on save. - openmaic.example.yml is copied as-is by the READMEs, getting started and the skill, and startup refuses any unset ${VAR}: it now has one active provider (OPENAI_API_KEY) and the llm slot, with every other provider and slot as a commented optional block. The copy instructions say so. - The translated deployment pages gain the root-owned volume repair paragraph and its chown command. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR * docs(config): configure self-hosted and custom media endpoints in openmaic.yml Workspaces cannot add custom non-chat endpoints or self-hosted media presets, so the docs no longer send users to the browser settings for them: - Configuration (legacy reference): custom OpenAI-compatible TTS and ASR endpoints and ComfyUI are declared in openmaic.yml with their baseUrl and assigned to tts / asr / image; custom chat endpoints are an openai-compatible provider in openmaic.yml or the Providers tab. The claim that ASR configuration stays in client settings is gone. - VoxCPM2 (page and READMEs) is configured in openmaic.yml, with the legacy variable as the fallback; the per-browser Base URL option is removed. - MinerU in the READMEs is a document provider in openmaic.yml. - Deployment and ComfyUI: providers declared in openmaic.yml are server-managed and may use private endpoints without ALLOW_LOCAL_NETWORKS, which only matters for endpoints a user types (chat base URLs in the model settings, deprecated request fields). All six locales are updated. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR * docs(config): document provider options and the VoxCPM2 backend option - Configuration: the provider fields table documents `options`: non-secret, provider-specific settings (string, number or boolean values, ${VAR} allowed), shown in the model settings so never a key, and deployment-only. - VoxCPM2 (page and READMEs): the backend is chosen with options.backend on the provider in openmaic.yml (vllm-omni by default, python-api, nano-vllm), not in the browser settings; voice registration works only on vllm-omni, and the troubleshooting row points at options.backend. - The example test skips openmaic.yml blocks that set provider options until the schema on this branch accepts them (marked TODO). All six locales are updated. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR * test(docs): check the VoxCPM2 examples now that provider options exist Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR * docs(config): describe the browser import, Voice settings and web search as released - Browser import: the proposal and its keys are removed after any 2xx (even with skipped items, which are kept nowhere), and after a 400 or an unreadable proposal; it stays for another attempt only on 401, 404, 409, 5xx or a network error. Lists what has to be set up again by hand (per-stage models, thinking settings, custom speech and transcription providers, AliDocMind's key pair, the VoxCPM backend, Baidu sub-sources) and that the per-browser capability switches do not carry over. - VoxCPM2 voices (page and READMEs): assign VoxCPM to tts, then Settings > Voice > VoxCPM voices, which appears only when tts resolves to voxcpm-tts. - Web search has no per-generation switch: it runs when the workspace's webSearch slot resolves, and turning it off affects later generations. - The READMEs' persistence section no longer says keys go in .env.local only. All six locales are updated. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR * docs(config): startup errors name the field, or the line for broken YAML Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> * ci: run CI for the server-first integration branch Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * ci: give the lint, typecheck and unit test job 25 minutes The job's runtime varies between about 9 and 14 minutes, and a pull request run was cancelled at the 15-minute limit after its tests had passed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * refactor(generation): extract the classic generation steps into shared server functions (#1756) Moves the classic generation steps from the API routes into lib/server/generation/steps (no behaviour change; routes are thin wrappers). Video steps report the submitted provider task with its effective endpoint and refuse to resume on a different connection. Route characterization tests pin refusals and the outline SSE stream. Part of #1754 / #1755 (E1a). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat(storage): versioned SQL migrations per store (#1757) Every PostgreSQL store declares ordered, versioned migrations recorded in openmaic_schema_migrations; v1 baselines equal earlier releases' DDL; one-time steps run once; startup refuses a database upgraded by a newer release. @openmaic/storage 0.37.0. Part of #1754 / #1755 (M; #1658 phase 7). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat(agents): agent registry on the server — built-in agents in code, custom agents in the database (#1758) Built-in agents stay in code; custom agents move from localStorage to an owner-scoped owner_agents table with an API, a server resolver (resolveAgentsForOwner) and a one-time, lock-protected legacy import. Part of #1754 / #1755 (G). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat(generation): server-side generation runs (E1b) (#1759) * feat(generation): server-side generation runs with checkpoints and takeover A run is an owner-scoped PostgreSQL record that executes the classic pipeline with the shared step functions, in the browser's order and with the context the browser threads through them, checkpointing after every step. - Schema (store generation-runs): runs with their input, state, outline and revision, agents, course id, progress, lease and takeover counters; per-step checkpoints; an ordered event log (seq per run); idempotent commands. - Engine: preparing -> outlining -> awaiting_outline_confirmation -> generating -> completed, plus paused (a step failed after its retries) and ended (the course was deleted). Every commit is fenced by the lease generation; the course document is created with the first scene, scenes are appended as they complete and generationComplete is set at the end. - Runner: a lease-coordinated worker in every process, independent of the agent runtime flag, with heartbeats, takeover of orphaned runs and a cap on repeated takeovers of one step. - API: start (per-owner active-run limit), snapshot, event stream with replay after a seq, active runs, the owner's run stream, confirm-outline and retry, all owner-scoped. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(generation): run review round 1 (deletion, discard, atomic writes, parity, streams) - Deleting a course ends its run in every state, in the deletion's own transaction; a run without a course can be discarded with DELETE /api/generation-runs/:id. Runs waiting for outline confirmation no longer count toward the per-owner limit. - The start body and an edited outline are validated against the outline schema with size, count and order limits, under a body byte cap. - Narration uses the browser's shared voice logic: the teacher's voice options (VoxCPM prompt) and the single retry after a missing Qwen clone. - Scene appends and completion commit the document change, the checkpoint and the events in one transaction fenced by the lease; completion sets only the completion flags. - A takeover-capped pause keeps a resumable state; the runner hands back a claim of a run it still executes and stops claiming in that scan; the automatic outline confirmation happens in the outline's commit; prewarmed content is aborted before a pause; narration allocations are fenced and released when their attempt fails. - Background reads follow every claim of the run's owner; run streams have backpressure, a bounded queue and a per-owner cap; finished runs keep only their final events. - Parallel mode marks a failed scene and pauses after the others; auto agent generation falls back to the learner's selected presets; the agent resolver matches the registry's signature and error. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(generation): run review round 2 (limits, one narration step, outline normalizer, streams) - Confirming an outline takes the per-owner lock and respects the limit on runs in progress (429 ACTIVE_RUN_LIMIT); starts are capped at OPENMAIC_MAX_WAITING_RUNS_PER_OWNER runs waiting for confirmation. - Narration and the scene append are one step: the clips and the scene that names them commit together, and a failure retries both. - One outline normalizer for the outline step's output and edited outlines: nulls are absent, loose optional members are dropped, hard limits stay on scene count, size, ids, orders and type; the outline step stops at the scene cap. A generated outline always confirms unchanged. - A reader behind a finished run's compacted log gets a resync frame. - Run streams end on the request's abort, the queue cap is hard (an oversized frame goes only into an empty queue), and the owner stream moves its cursor only past frames it queued, in commit order. - Course writes use the owner after every claim (transitively), completion touches the stage row so its revision trigger fires, and auto agent generation falls back to the default presets when none were selected. - A claim handed back does not count as a takeover; stop waits for a scan in flight. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(generation): run review round 3 (scene types, byte units, retention, custom voices) - The outline normalizer no longer checks a scene's type against the known ones (a bounded string is enough): as in the browser, a scene of a type no content path supports fails at its content step and follows the parity rules (serial: pause; parallel: marked, skipped, pause at the end). - The outline step's read cap and the normalizer's size cap are both UTF-8 bytes, and the normalized result is measured too. Quiz configurations are kept only when complete and usable; question counts, media requests and orders are bounded. - Finished runs are no longer compacted at their final commit: a periodic sweep compacts them after a grace period (OPENMAIC_GENERATION_RUN_RETENTION_HOURS, default 24). The events stream sends `resync` on any gap after its cursor, on every page. - Custom preset agents keep their voice metadata in the agents checkpoint, so a custom teacher narrates with its own voice. - The engine's view of the run moves only after a commit resolved; the completed-scene count comes from the checkpoints; the per-owner limit is locked and counted under the canonical owner; a pull during an in-flight read is not lost. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(generation): run end-to-end findings (scene validation, deadlines, narration MIME) - The actions step validates the complete scene (content and actions) with the document's own scene validator before its checkpoint: an invalid scene (an action without a type) is an actions failure, regenerated by the step's retries, and pauses at actions when they are exhausted, so Retry regenerates the actions. Narration only ever sees validated scenes. - Every provider-calling step runs under its browser route's budget (outline 300 s, agent profiles 120 s, content 300 s, actions 60 s, a narration clip 30 s) through a signal combined with the run's; a timeout is retryable. The steps pass the signal to their provider calls, so a lost lease or a deleted course cancels the call in flight. Retries log and record their cause. - Narration clips are stored with their real media type (mp3 as audio/mpeg; opus as audio/ogg), so they are served inline. - A scene type without a content slot of its own resolves through course.content and reaches the content step's refusal. - No lease-lost warning for a run that gave its lease up; an edited outline is numbered by its position, as the browser's editor numbers it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(generation): narration webm as audio, and a quiet stop for an ended run - Narration clips map their format with an audio-specific type: webm is audio/webm (the shared classroom map types it as video), so a webm TTS response is stored instead of being refused until the run pauses. - A worker whose run was ended on purpose (its course deleted or discarded) stops with an info line, not a lease-lost warning. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> * fix(config): use the legacy document default for automatic extraction (#1760) With only the PDF_* provider variables (no openmaic.yml), the document slot resolves to the translated legacy default, but material analysis used the slot's service only for a configured slot, so a request that named no extractor fell back to unpdf instead of the configured MinerU. Use the slot's service for a legacy default too; a provider the request names still follows the legacy rules. The translated default also follows the order earlier releases picked in the browser: MinerU Cloud, then self-hosted MinerU, ahead of the rest of the configured services. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> * feat(generation): media lane, material images as course assets, read-only course while generating (E1c) (#1761) * feat(generation): media lane, material images as course assets, read-only course while generating (E1c) Runs now generate the media the outline asks for, as the browser's media pass does: once the course exists, alongside the scenes, one item at a time in outline order, only for the kinds whose slot resolves. The bytes go to the asset pool and the placeholders in the course are rewritten to the allocated ids (a scene not written yet gets them with its own write). Every item is checkpointed: a video records its provider task at submission and a takeover resumes the wait instead of submitting again; stored bytes are checkpointed in the allocation's transaction, so a takeover places them instead of paying for them again. A media failure leaves the placeholder and does not pause the run; `retry` takes an optional media element and regenerates only it, in a run that is generating, paused or completed (a paused or completed run is claimed for its media alone, through the new `media_pending` flag, migration 2). Material images are stored as course assets by the material analysis and the outline and scenes are generated with them by id, as the content step's server-backed transport expects; no image bytes are kept in checkpoints. A course is read-only to every other writer (classroom editor saves, the Pro agent's tools) until its run completes or ends: content writes through the owner-bound document store are refused with COURSE_GENERATING (409 on the stage routes); the run's own lease-fenced writes go through. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(generation): a media retry after completion keeps the course editable The read-only guard now applies only while the run itself is not completed or ended (its first media pass included). A Retry on a completed run no longer locks the course: its result is placed with a targeted read-modify-write of the current document (`mutateScene` of the owner-bound store, one transaction under the course's ownership row) that rewrites only the slots still holding the element's placeholder and keeps the author's other edits, and touches the stage row so an open editor reloads. When the author removed the element or its scene meanwhile, the result is dropped, its bytes are released and the item fails as MEDIA_ELEMENT_REMOVED, which Retry refuses. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(generation): media lane review fixes (slots, video tasks, leases, placement, asset lifetimes) - Media slots resolve per kind: turned off or unassigned skips the kind (a Retry generates it once the slot resolves), a refusal the routes map (INVALID_URL, INVALID_CREDENTIAL) fails its items with that code, and any other fault fails the media left, never the run. - A video keeps its provider task through every failure but the provider's own final answer (ProviderTaskFailedError, now raised by the polled-task helper and the adapters for a task that failed or finished without a result) or a changed connection; a Retry waits on that task instead of paying for another. The task record is written again in place when the write fails for a passing reason. - A step Retry no longer takes the lease from a worker generating a paused run's media; that worker goes on with the run when it is done. - Placement into a completed course changes only the matched media slots (no whole-scene sanitizing), marks the item done with the last scene written, and recognizes bytes already placed before an interruption. - Allocations a live run holds (material images, held media) are kept from expiring by the runner and released when the run ends; bytes that are gone anyway fail loud instead of being dropped. - generation-complete goes through the read-only guard (409); the run snapshot reads the run and its media in one statement; migration 2 indexes the runs producing a course by stage_id. - `generating` is checkpointed, stored bytes report done only once placed, posters are released with a failed item, stored bytes survive a placement fault, and the claims and asset calls follow the run's owner through claims. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(generation): media lane round 2 (video Retry submits anew, stored bytes are never lost) - A video's provider task is kept only to survive a takeover: once the item is recorded failed, for any reason, the task is dropped and a Retry submits a new one, as the browser's does. ProviderTaskFailedError and the adapter changes are reverted; the shared polled-task path is the base's again. - Stored bytes are done only once placed: a completed run whose placement failed keeps them stored with media_pending (stored counts as work in a completed run), and the next claim places them. At completion only bytes no generated scene holds are done without a placement. - Bytes found stored before an execution are checked before they are placed: missing bytes fail the item loud (with a Retry), a missing poster is left out. A placement fault at completion keeps the bytes stored instead of ending the execution. - The keep-alive locks the asset entries it extends in id order. - Migration 2's stage index also covers completed runs with media pending, so both the guard's and the deletion's lookups use it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(generation): stop re-placing stored media whose placement keeps failing Each stored item counts the placements that failed in a row. After three the item fails with MEDIA_PLACEMENT_FAILED (retryable), its bytes are released as every failed item's are, and media_pending clears, so a completed run is no longer claimed on every scan for a placement that cannot succeed. A Retry generates the item again. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test(generation): wait for the run's own state, not a one-second budget Three media tests waited for a run to reach a state (a video stored while a later scene is held, a second video call, the keep-alive) with vi.waitFor's default one-second budget, which a loaded full suite can exceed. They now wait for that condition within the test's own budget. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> * feat(api)!: headless generate-classroom on generation runs; retire the server pipeline (E2) (#1762) * feat(api)!: headless generate-classroom on generation runs; retire the server pipeline (E2) POST /api/generate-classroom now starts a generation run of the request owner (outlineReview auto, course-specific agents, no interactive or task-engine mode) and its poll reads that run in the existing job contract. The job id is the run id; a paused run reads as failed with the failed step and can be resumed through the run's retry command. Submissions without an outline model are refused up front, and the per-owner run limit answers 429 ACTIVE_RUN_LIMIT. The separate server pipeline (classroom-generation, its media and TTS passes, the in-process job runner and the classroom_generation_jobs store) is removed with its tests. The start checks of POST /api/generation-runs move into a shared startGenerationRun helper. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(api): headless review fixes: typed model refusals, paused runs outside the limit, run report - Model configuration refusals are typed (ModelConfigurationError: a missing key before the adapter is built, a refused endpoint, options only the deployment may set), and the headless submission checks every model a run needs (outline, scene content, actions) up front, answering 400 MISSING_MODEL / MISSING_API_KEY / INVALID_URL / MODEL_CONFIG_INVALID. - Paused runs no longer count toward OPENMAIC_MAX_ACTIVE_RUNS_PER_OWNER; a step Retry enforces the limit, as confirm-outline does (429). - The job view adds runState and retryable; a paused job's error says how to resume it. - Runs record speech clips their narration left silent, and compaction keeps a summary of the media checkpoints it removes (generation-runs migration 3), so the job warning stays stable after compaction. - Remove the dead generate-classroom LLM stage key and lib/server/scene-generation.ts; pin classroom-generation-jobs as a retired store name. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(config): name the configured provider in the missing-key refusal Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(api): check scene content per type, as the content step resolves it The headless preflight resolved the parent course.content slot, while a run resolves course.content.<type> (inheriting course.content, then llm). It now refuses only when no scene type resolves, naming the first type's refusal; a submission where only some types resolve is accepted, and a scene of such a type pauses the run at its content step. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> * docs(deploy)!: long-running Node only; Vercel up to 1.1.x; deployment docs and complete changelog (F) (#1764) * build!: drop vercel.json and the Vercel build switch OpenMAIC 1.2.0 needs a long-running Node.js process: generation runs execute in a worker inside the server process. Remove vercel.json and the VERCEL conditionals in next.config.ts, so every build produces the standalone output with the sharp-libvips native libraries. BREAKING CHANGE: Vercel and other serverless hosts are not supported; serverless deployment stays available on release/1.1.x. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs(deploy): long-running Node only, Vercel up to 1.1.x, upgrading from 1.1.x - README / README-zh: the Vercel section says serverless deployment is supported up to 1.1.x, and its Deploy button deploys release/1.1.x; the header badge is removed. - Deployment docs (all six locales): Docker Compose, and the image or pnpm start with your own PostgreSQL, as the supported paths; the required configuration; the generation run variables; an "Upgrading from 1.1.x" table. Getting started and configuration no longer offer Vercel. - .env.example documents the generation run variables. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs(changelog): model configuration, generation runs and deployment entries Add the missing Unreleased entries for server-side model configuration (capability slots, openmaic.yml, Settings > Models, MODEL_ROUTES, OPENMAIC_SECRET_KEY, the removed browser provider state and the browser settings import), the generate-classroom request contract, generation runs and their media lane, the Vercel removal and the browser storage seams, so every item of the 1.2.0 breaking changes table is listed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs(deploy): shared data directory for several instances; Compose upgrade cases - Deployment docs (all locales): instances that share a database also need one shared, persistent data directory, because uploaded material files, their extraction results, agent-edited course media, usage records and the generated instance secret are kept on local disk; the standalone docker run example mounts /app/data. .env.example says the same next to OPENMAIC_SECRET_KEY. - Upgrading with Compose: a .env.local with PERSISTENCE_SHARED_OWNER_ID needs OWNER_SINGLE_USER=false, and anonymous server libraries need an explicit claim into the single user. - CHANGELOG: the job record's inputSummary is removed (it was never part of the poll response); no advice for serverless hosts; only the session-scoped material reads require the agent runtime, upload and delete need only DATABASE_URL. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> * feat(generation): the browser starts and follows generation runs (E3) (#1768) * feat(generation): the browser starts and follows generation runs (E3) The composer uploads its materials to the owner's library and starts a server-side run; the generation preview renders the run's events (steps, outline streaming, outline review with the 2.5 s auto-continue, agent cards, pause and Retry) and sends it confirm-outline and retry; the classroom follows the run's scenes, media and pauses, with media Retry as the run's command and the course read-only until the run completes. Course cards appear from run start and follow the owner's run stream. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(generation): follow run streams only while there is something to follow The home page holds the owner stream only while a run is active (an idle list looks for runs now and then), a finished run's stream closes until a media Retry wakes it, the preview opened by the composer keeps the auto-continue beat even when the outline was ready before it attached, and an empty preset selection is taught by the default presets. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(generation): say so when a step Retry is over the active-run limit Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat(generation): say a paused run's failure the way the classic preview did A failed step records the error code the classic routes answered the same failure with (and the provider's HTTP status), on the paused snapshot and its step_failed event. The preview maps it to the same sentences as before, per step; the run's own message is the fallback for an unknown code. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat(generation): an empty preset selection is taught by the default presets `{ mode: "preset", agentIds: [] }` is accepted and the run resolves it to the default preset agents (what the learner's selection starts out as), so the browser sends its selection as it is. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat(generation): show the material analysis as the classic preview did The run names whether each material is a document or audio/video when its analysis starts (`material_kinds`) and what of the materials the outline does not see in full when it ends (`material_truncated`). The preview shows "Analyzing audio/video" for audio or video, drops the analysis step once the materials are analyzed, and shows the truncation warning again. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(persistence): a browser save keeps the outline's producer A full or structural save of a course writes the browser's outline (the plan and its completion) over what a producer recorded beside it, which dropped `producer`/`producerRef`: a finished run's course then no longer sent its media Retry to the run. The producer's fields are kept. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat(generation): a run's snapshot names its failures and its material analysis `GET /api/generation-runs/:id` gives a paused run's failure and each failed or skipped media item the seq of the event that reported it (`failureSeq`), a durable identity a Retry command's id is derived from, and repeats what the material analysis reported (`materialKinds`, `materialTruncated`) so a reloaded page shows the same. `GET ?active=1` adds the owner's limits on runs, so a client can tell before uploading materials that a start would be refused. Read from the event log; no new state. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(generation): harden the run client after review - Run and owner streams: held only while a run can change on its own (a run waiting on its owner or settled is read now and then); a refused or dropped stream is reopened with a backoff from the view's seq, reading the snapshot meanwhile; a run that cannot be read is retried and shown as such, not as missing; a page shown again reads the run at once. - Auto-continue only in the tab whose composer started the run; every other tab shows the review. A confirmation that lost to another tab keeps its edits on screen and says so; a Retry that lost says so. - Retry command ids come from the failure's identity in the snapshot or the log, so a second failure is a new command. - Classroom: read-only while the run's state is unknown too; the run's last writes are read (retrying failed reads) before the course is unfenced; a classroom on the generating page moves to the first arrived scene; a learner's changes during generation (PBL progress) are held and written once the run completes, and a scene with unsaved changes is not replaced; a save of a scene still holding a placeholder writes the asset the run placed. - Media: the run's `retryable` decides the Retry; a skipped item offers the run's Retry once its slot resolves, else the disabled placeholder. - The Pro switch shows, disabled, while the course is generating. - No upload for a start the limits would refuse; upload refusals and fallbacks are translated. - e2e: the run mock streams after attach, keeps the run generating through the classroom, checks revisions and command ids, and covers pause and Retry, the run limit, a lost confirmation and a second tab. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat(generation): release the materials a run was started with when it is over A run started with `releaseMaterials` (the composer's: the materials were uploaded for that run only) releases them when it completes or ends, in the transaction that finishes it: marked deleted as the material delete path marks them, so they stop counting against the owner's quota, their bytes going with the owner's next reclaim sweep. Only materials of the run's owner; course assets copied from their images stay. Runs waiting for confirmation or paused keep them (Retry needs them); callers that reuse material ids leave the flag out. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(generation): round-2 review of the run client - A fenced course writes no course-document content, as before: the one learner write to scene content during playback, PBL progress, is synced to its durable home (the PBL runtime store) on its own while the run generates; a scene the learner changed is not replaced by a server copy. The queued-save path is gone. - The composer marks its uploads for release with the run, and deletes them itself when a start fails (a later upload, or the start). - Stream backoff: reset only by a stream that attached, jittered, and kept when the page is shown again. - A reconnect while the outline streams follows on from the view's cursor (the items in the gap are in the log only); snapshot reads are serialized and an older one is ignored. - A run course is read once more before it is unfenced, even when the run was over when the classroom opened; a scene that never reads is given up on after a few passes. - A lost confirmation's edits are not swept away by the classroom redirect. - A waiting run is read every 5 s while the page is shown. - An upload over the size limit says the limit the server enforced. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(generation): fence a run course from its load, and keep its PBL progress The course a generation run is producing is fenced as soon as it is loaded, not when the classroom's run follower answers, so a PBL scene normalizing its project on mount is not queued as a document write the server refuses; content changes queued before the fence are dropped, a scene change among them synced to the PBL runtime store. A PBL scene the classroom holds is not replaced by the server's copy (the run never changes it after appending it, and the learner's progress lives in it). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(generation): convergence review of the run client - A start releases its uploads only when the server refused it (a 4xx); a lost answer may hide a created run, which keeps them and releases them when it is over. - A run releases its materials only when no other of the owner's runs that is not over names them. - Each snapshot read is bounded (15 s, aborted, counted as a failed read), so one that never settles does not hold the reads after it; close aborts the reads outstanding. - One run-ref check for the load-time fence and the follower; loading a course without a followable run lifts a fence, and a run that answers 404 lifts it too. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> * refactor(generation)!: remove the browser generation orchestration (E4) (#1773) * refactor(generation)!: remove the browser generation orchestration (E4) Classic generation runs on the server since E3; this removes what the browser used to run itself: - The browser scene pipeline (`useSceneGenerator`), the classroom's browser resume and media pass, and the client media pass of the media orchestrator. Media Retry stays for courses no run follows. Narration for one speech line moves to `lib/audio/narration-tts.ts` (the editor's per-line regeneration). - The `sessionStorage` handoff and the old preview session types and helpers (`GenerationSessionState`, `getActiveSteps`, `foreground-retry`, `vocational-mode`), `session-sources` and `research-decision`. - The IndexedDB document and image stash (`lib/utils/image-storage`); the device cache drops its `imageFiles` table in a version 2 upgrade. - The per-step generation routes nothing in the repo calls any more: `/api/generate/scene-outlines-stream`, `/api/generate/agent-profiles`, `/api/generate/scene-content`, `/api/generate/scene-actions`, `/api/extract-document` and `/api/web-search`. Their step functions stay; the step-level tests that drove them through the routes now call the steps. - The Vercel-only `maxDuration` route hints (`next start` ignores them). - What only those left in use: the asset storage-full marker, the media document skip index, request-based server asset and vision image resolution, `llmApiError`, the client generation settings reader, the regeneration lock, `narrationPlan`, and the stage store's browser generation actions. BREAKING CHANGE: the per-step generation routes listed above are removed; start and follow a generation run instead. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat(classroom): show pending outlines no run will produce as interrupted A course the browser was generating before 1.2.0 (or whose run is gone) is no longer resumed, so its pending outlines would sit as "generating" placeholders for ever. They now show in the failed placeholder's visuals with "Generation was interrupted", with no spinner and no Retry. A course a server job produces keeps its placeholders while the job finishes it. Also corrects the README-zh description of `app/api/generate/`. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(classroom): offer Retry for media a pre-run course never generated The browser media pass used to generate the images and videos a course's outlines asked for when the course was opened. A course generated in the browser before 1.2.0 can still carry such placeholders with no failure record and no cached bytes, and without the pass nothing offered them a Retry. Opening a course no run produces now records each one as a failed, retryable task; restored refusals and cached bytes are kept, and nothing is generated until the author clicks Retry. Also adds the interrupted-generation selector to the playback chrome test's stage store mock. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> * refactor(settings)!: main's settings UI on the server-side model configuration (#1767) * feat(config): test a saved provider from the settings by its id The settings test buttons (and the model list fetch) name a provider the workspace has configured instead of sending its key and endpoint: the server resolves it from the deployment or the workspace's own configuration, under the same rules as a slot (the policy, self-hosted media presets, custom endpoints). - verify-model, verify-image-provider, verify-video-provider and verify-pdf-provider take `provider` (and `model`); probe-models takes `provider` for one of the workspace's own chat providers. - generate/tts and transcription take `previewProvider` (and `previewModel`) for a settings preview of a provider other than the slot's. - The settings view's catalogue models carry what the registry knows of them (capabilities, thinking controls, context window) and the registry entry that serves each capability, for the settings to show. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR * refactor(settings)!: bring back the Token Plan, Model Services and Course Model sections on the server's configuration The settings dialog gets its earlier information architecture and layout back: Token Plan, Model Services (one tab per capability: language models, image, video, text to speech, speech recognition, document parsing, web search, each with its list of services and their panels), Course Model Config (the pipeline with a model per stage), Skills and General. The model map with its provider list and the separate Voice section are removed. The panels read and write the workspace's model configuration on the server instead of the browser store: - A service's key, endpoint (chat services only) and model list are its workspace provider (id = the service's preset id); keys are write-only (a mask is shown; replace or remove). A newly added service fills the root slots of what it serves that have nothing set yet. - Services the deployment configures are shown read-only; key-pair, self-hosted and (under a policy without workspace providers) all other services say that only the server's configuration can set them up. - The Course Model main model is the `llm` slot (with its thinking settings); each stage is its slot (follow the main model = no assignment of its own); the media switches set their root slot to null and restore what it held; locked slots are shown disabled. - Token Plan: connecting adds the plan's provider and fills the empty, unlocked slots it recommends; disconnecting removes it. - Test buttons name the saved service; no key leaves the browser. - The narration speed and test are back in the text-to-speech panel and the recognition language in the speech recognition panel. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR * feat(toolbar): the course model picker and extractor as before, on the server's slots The home toolbar's model picker gets its thinking control back (the `llm` slot's thinking settings) and its groups in the plan-first order the settings use, and the course material popover gets its extractor select back: it sets the `document` slot (a built-in parser that needs no key is added on first use). The set-up prompt opens Model Services. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR * docs(settings): name the Token Plan, Model Services and Course Model sections The docs, READMEs and messages pointed at the Models section (model map, providers tab, "Connect a service") and the Voice section, which are gone. They now name the sections the settings have again: a plan's key in Token Plan, a service's key in Model Services, per-stage models and capability switches in Course Model Config, VoxCPM voices in Model Services → Text-to-Speech. The server-side explanations (locks, write-only keys, deployment-only services, the one-time import) stay. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR * fix(settings): web research dims only when search is off, as before Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR * fix(config): list a saved provider's models without naming a model Fetching the models of a saved chat provider passed its bare id to the chat resolver, which requires `provider:model`, so every request was refused. The provider's connection is now resolved without a model (still only the workspace's own providers, and the endpoint is still checked). The settings view test also covers an OpenAI-compatible deployment provider's listed chat models. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR * feat(settings)!: Course Model Config is the course model map Course Model Config shows the course model map again in place of the earlier pipeline panel: the default model card on top, the pipeline as a two-row serpentine with the content station expanding to its page types, editing on the card (follow the parent, a model or off, and a fallback for chat slots), "Set by the server" locks, solid and dashed lines, zoom, pan and keyboard. Its provider tab and first-run card stay out: services are set up in Model Services and Token Plan, and the map links to Model Services where it needs one. - One switch implementation (flipSwitch): off sets the slot to null, on restores what it held, else takes the first service that serves it (speech input returns to the browser's recognition), else opens the picker. What it turned off is remembered, and what it restored forgotten, only once the server confirmed the change. - Speech input shows its switch while it runs in the browser (unset), and a service can be picked for it then. - The server's providers count wherever chat models are offered (the map, the toolbar, Model Services); the map's "no model" note shows only when nothing at all offers one. - Model Services names a provider by its preset and id ("OpenAI-compatible · gateway", with a generic logo), and shows one named after a built-in service as that service's entry. - A typed key stays in the field when the server refuses it; it is cleared once saved (Doubao's paired fields too). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR * fix(settings): provider logos in the model pickers; thinking, keyless services and model discovery on the map Logos: the home toolbar's model picker shows the provider's logo again, on the pill and on every group and model, as it did before the settings restructure; so do the course model map's card picker and its read-only pill. A provider looks the same everywhere: its plan's or built-in service's logo (a provider named after one included), its preset's, or the generic service icon for a custom endpoint, as in Model Services. The naming and logo helpers move to a data-only module the toolbar can load. Review fixes: - The map's card picker sets the thinking settings of a chat slot's own model (the main model and each stage), against the view it shows; locked slots have no picker. - A service that needs no key (the browser's own speech) is offered in a media slot's picker, and the text-to-speech panel can make it the narration: the provider is added, then the slot assigned against the view the add answered. - Fetching a provider's models reports them as added only once the server saved the list; a refused or lost write leaves a failure to retry. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR * fix(config): removing a provider's key frees the slots that needed it Removing the stored key of a workspace provider left the slots assigned to it pointing at a provider that can no longer be called, so the default model (or a stage) failed at generation time while the settings still showed it in use. Removing the key now drops those assignments, as removing the provider does, and the slots follow their parents again. Only the capabilities whose provider needs a key are affected: a local model server or a keyless search keeps what it serves. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR * fix(config): connecting a token plan fills an empty web search slot A token plan's web search has no models to pick, so the plan's preset recommended nothing for the webSearch slot and connecting a plan (TokenDance, MiniMax) left search unassigned, where the browser-side Token Plan used to select the plan's search. The preset now recommends the plan's search for it, and the first-run fill assigns the provider by itself to the slot when it is still empty. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> * fix(settings): show each TTS service's full request URL The server-backed TTS panel showed only the preset base URL as the request URL. Append the path each built-in service calls, as main did, so Gemini shows /interactions and Doubao /unidirectional. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR * fix: upgrade rehearsal findings (access-code reload and import, completion flag, provider log source, COOKIE_SECURE) and upgrade docs (#1774) * fix(access-code): reload the page's data once the access code is accepted The library, folders, active runs and the workbench probe were requested before the gate passed, answered 401, and stayed empty until a reload. The guard now mounts its children again after a successful verify, and the workbench probe no longer caches a refused answer. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(persistence): mirror a saved outline's generation-complete flag A whole-document save whose outline says generationComplete now sets stage_meta.generation_complete in the same transaction, so a course the browser importer writes is marked complete like one imported from data/classrooms. saveCompletedClassroom relies on the same path. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(config): name the actual provider source in the startup log The line said "Loaded (server-providers.yml)" even when every provider came from environment variables. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(access-code): let COOKIE_SECURE=0 cover the access cookie too The access cookie was Secure in every production build, so behind plain HTTP the browser dropped it and the gate never opened, while the owner cookie already honoured COOKIE_SECURE=0. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs: upgrade, plain-HTTP cookie and migration gaps found in the 1.1.2 upgrade rehearsal - Upgrading from 1.1.x: keep data/ (or OPENMAIC_CLASSROOMS_DIR) and set the identity mode before the first 1.2.0 start, since data/classrooms imports go to system:legacy-classrooms for good in anonymous mode; spell out the pnpm start upgrade steps and the shared data directory. - Required configuration: COOKIE_SECURE=0 for plain-HTTP deployments. - Self-host: the next start / output: standalone warning is harmless; keep pnpm start on a VM. - Configuration: <PREFIX>_BASE_URL maps to baseUrl; with openmaic.yml the provider variables configure nothing, so declare every provider and slot; pbl-chat and maic-agent routes are removed. - openmaic skill: self-hosted ACCESS_CODE needs the verify cookie, not Bearer. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(legacy-browser-import): resume the import once the access code is accepted On an ACCESS_CODE-gated deployment the page's first import run is answered 401 before the visitor enters the code and backs off, so an upgrading browser saw an empty library on its first visit. The ledger now records an unauthorized pause, and accepting the code starts the import again at once (only that backoff is skipped). This page's runs are queued one after another, so the resume never runs beside the scheduled run, with or without Web Locks. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(legacy-browser-import): wait for another tab's lock on an access-granted resume A resume after the access code was accepted took the cross-tab lock with ifAvailable, so it was dropped as busy-elsewhere while another tab held it; when that tab's run was the one the gate refused, nothing retried until a later load. The resume now waits for the lock and decides again inside it: finished, an unauthorized pause to retry, or another backoff that still holds. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> * ci: stop triggering on integration/provider-config before it merges to main Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR * fix(settings): cache provider logos so switching service tabs does not blank them (#1777) Next serves public/ files with `Cache-Control: public, max-age=0`, so each logo a newly mounted list draws is revalidated before it paints. Switching tabs in Model Services mounts a fresh list, and its logos stayed blank until every revalidation came back. Cache /logos/* for a day with a week of stale-while-revalidate; the files are not content-hashed, so not immutable. Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> * fix(settings): hide the Skills section when the agent runtime is unavailable (#1778) Without the agent runtime, GET /api/agent/skills returns 404 by design, so the Settings Skills section could only ever show a load error with a retry that cannot succeed. Probe GET /api/agent/runtime once per tab and list the Skills item only when it reports `enabled` (the flag plus DATABASE_URL, the same check the skills route gates on). The item stays hidden while the answer is unknown, and a request to open the dialog on `skills` without the runtime lands on the first section. Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> * fix(generation): show the selected extractor's formats in the material upload hint (#1779) The attach popover's dropzone hint claimed documents, slides, spreadsheets and images for every extractor, so with unpdf (PDF only, plus the built-in plain-text extractor) users could pick a .docx via "All Files" and only then hit a generic "unsupported" error. - The hint now lists exactly the formats the active extractors accept (getFormatLabelsForProviders), with the per-file size limit. - The unsupported-file error names the extractor and its supported formats; it covers the file picker, drag-and-drop onto the dropzone, and the cleanup that drops attached files after switching extractors. - Format labels are file-type names shared by all locales; the list separator is localized. Strings updated in all 12 locales. Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> * fix(settings): connecting a token plan applies its recommended configuration (#1780) Since model configuration moved to the server, connecting a token plan only filled the slots that were still empty, so a workspace that had already picked models never switched to the plan's setup. The browser-side Token Plan used to make the plan's default model, its recommended course stages and its image, video, speech and search services the active selection. Connecting a plan (or saving a new key for a connected one) now applies the plan's recommendation as one slots change: the default model, each course stage the plan names, and its media and search services. When that would replace assignments the workspace made itself, the panel asks first: use the plan's recommended setup, or keep the current one and fill only the empty slots. Slots the deployment locks are never offered or changed, and a plan yields the slots a connected plan of higher priority recommends, whatever the connect order. A replaced language-model assignment keeps its fallback; thinking settings go with the model they were set for. Disconnecting still removes the provider, which frees the slots that named it. Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> * feat(home): the toolbar picker sets the default model and shows stages set separately (#1781) * feat(home): label the toolbar model as the default and show stages set separately The home toolbar picker only sets the `llm` root, but read as if it chose the model for everything. It now says what it changes: - the pill reads "Default · <model>" (the prefix is hidden on phones); - a hint after it counts the stages under `llm` whose own setting resolves to something other than the default (inheriting or matching stages do not count; deployment-set stages do), lists them in a tooltip with the map's stage names, and opens Course Model Config on a click; - the dropdown says picking here leaves stages set separately alone; - when every stage a course is always generated with (outline, each page type's content, actions) has its own setting, the pill summarises the per-stage setup (naming the Token Plan when one plan provider serves them all) and opens Course Model Config instead of offering a switch. Picking a model still changes only `llm`; the locked and first-run states are unchanged. New strings are added to all 12 locales. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR * fix(logos): size the DeepSeek logo to its own view box The SVG declared width 182 and height 29 around a 34x29 view box, so any square icon slot scaled it as a wide strip and the whale drew as a dot. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> * fix(agent): don't inherit a thinking effort the agent can't use; recover sessions after a failed first run (#1782) * fix(agent-runtime): drop the thinking effort the agent slot inherits The agent slot follows llm, so a thinking level picked for the default model (the home toolbar writes it on llm) reached the agent driver, which refuses any thinking effort because its tool calls cannot carry one. Every agent run failed for a user who had picked a thinking level. The agent slot now declares that it carries no thinking effort: - Resolution drops an effort the agent inherits from an ancestor and keeps the rest of the thinking settings; an inherited effort of none stays "thinking off". - An effort set on the agent slot itself is refused when it is saved: openmaic.yml at load and a workspace change through the settings API. The driver's own check stays as a backstop for settings stored earlier. - The model map's thinking control for the Agent card offers no effort levels: an effort model that can be switched off shows on/off, and one that cannot shows nothing. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR * fix(agent-runtime): keep a session usable after a run that failed while starting A run that fails before it completes any message (for example, while resolving its model) writes nothing to the entry tree, but its lifecycle frames are in the event log. The runner treated any lifecycle frame as proof that the tree should hold history, so every later run of that conversation failed with "tree is empty after a prior run". The empty-tree check now asks the event log whether a prior run completed a message. The runner appends every completed message to the tree right after its message_end event, so an empty tree is refused only when a message_end exists: the tree lost history it held. An empty tree after runs that never completed anything is legal, and the next run starts the conversation over: - it resumes the conversation instead of opening it again, so the opening prompt is not painted twice; - a durable message is taken as the session's opening message only when it was posted before the first run; a message posted after a failed start is delivered as a follow-up, not consumed in place of the session's prompt. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> * fix(config): workspace-provider policy covers request headers; startup secret warnings; upgrade notes (#1783) * fix(model-config): ignore request-named providers when workspace providers are off With `policy.allowWorkspaceProviders: false`, users may only use the providers openmaic.yml declares. The deprecated request paths still let a request run on its own model, key and endpoint while a slot was unassigned. Under that policy the language model and media resolvers now skip the provider a request names, document extraction keeps only a self-contained extractor from the request fields, and the header form of the provider test routes (verify-model, verify-image-provider, verify-video-provider) is refused. Behaviour is unchanged when the policy is true or unset. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR * test(model-config): pin the media defaults an upgrade keeps Before the server-side settings, the browser switched image, video and narration on at its first sync with the server whenever the server had a provider for them, and the classroom chat and the agent searched the web whenever a search provider was configured. The translated legacy defaults keep those capabilities on, unlocked, so a workspace can switch each off; an explicit openmaic.yml assignment stays as written and locked. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR * feat(secrets): warn at startup when stored keys will not open Keys saved in the model settings are sealed under OPENMAIC_SECRET_KEY, or under a secret generated in data/instance-secret.key when it is unset. On a host whose data directory does not survive a restart, or with several replicas, a new secret is generated and the stored keys stop opening; on a read-only data directory no secret can be created and saving a key fails. At boot, with one query in the background, the server now warns when: - OPENMAIC_SECRET_KEY is unset and the data directory cannot hold a new secret file; - the secret file is missing (so a new one would be generated) while the database holds keys sealed under an earlier secret; - stored keys were sealed under a different secret than the current one. Each warning says the keys have to be entered again and recommends setting OPENMAIC_SECRET_KEY. The server still starts in every case. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR * docs: upgrade notes for server-side model configuration Add breaking changes and upgrade notes to CHANGELOG.md for the move of model configuration to the server: openmaic.yml and capability slots, MODEL_ROUTES refusing to start without openmaic.yml (with the route-to-slot mapping, including maic-agent-driver -> agent), the narrowed /api/generate-classroom body, deprecated request fields and the workspace-provider policy, the media capability defaults after an upgrade, the one-time browser settings import and what it cannot carry, and OPENMAIC_SECRET_KEY persistence across restarts and replicas. The configuration and deployment docs (all languages) and .env.example now cover replicas, ephemeral and read-only data directories, the new startup warnings, and request fields being ignored under the policy. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> * fix(import): keep settings the server could not take; Azure regional endpoints; keyless compatible providers (#1784) * fix(model-config): keep what the browser import could not move, and accept Azure Speech regional endpoints The one-time import of browser model settings deleted the staged proposal, keys included, on any 2xx answer, even when the server skipped items. The version 5 store migration had already dropped the originals, so a skipped provider lost its key everywhere. An Azure TTS/STT user with a regional endpoint lost theirs this way: the server refused the endpoint as a custom media endpoint. Import: - Only what the answer shows the workspace holding leaves the browser. Every other item (skipped as invalid, an id the deployment declares, or unconfirmed because the answer could not be read) is kept in the browser as staged, with its keys, under its own key. Skips that leave nothing behind (EXISTS, a locked slot) are not kept. If the items cannot be kept, the proposal stays for a later load. - What the builder cannot propose is kept the same way at migration time: custom speech/transcription providers, AliDocMind's key pair, custom chat providers without an endpoint or of an unsupported type. - Kept items are never sent again and do not re-trigger the import. A toast tells the user once; Settings -> Model Services lists them with the reason and a copy-key button until they are discarded. Setting one up again (a new workspace provider of its preset, or the slot) clears it. Clearing the local cache keeps them. - The import answer now carries a `code` per skipped item, and a provider id the deployment declares is reported as PROVIDER_RESERVED. Azure Speech: - Workspace azure-tts / azure-asr providers take their official regional endpoint (https://<region>.tts.speech.microsoft.com, https://<region>.api.cognitive.microsoft.com or .stt.speech.microsoft.com), region [a-z0-9]+, https only, no userinfo, port, path, query or fragment. It is checked at save time and at resolution (resolve-slot marks anything else as a custom endpoint, which media resolution refuses), and stored normalised. Their registry default only names the region as a placeholder, so they now require it. The TTS/ASR panels get a regional endpoint field. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR * fix(model-config): let a keyless OpenAI-compatible provider run A self-hosted OpenAI-compatible server (Ollama, vLLM) declared in openmaic.yml as `preset: openai-compatible` without `apiKey` resolved, but building its model threw "API key required for provider: openai": the preset rides on the OpenAI registry entry, which requires a key. - The openai-compatible preset marks its key optional, and the slot model passes that to getModel (a new `requiresApiKey` override on ModelConfig). Every other preset keeps the registry's rule, so OpenAI itself still needs a key and fails with the same clear error. - A request without a key no longer carries an empty `Authorization: Bearer ` header; with a key it is sent as before. On main, a custom OpenAI-compatible provider sent from the browser was built the same way (providerType openai, registry default requiresApiKey true) and also needed a key on the server; keyless worked only for registry entries marked keyless (Ollama, Lemonade). This lets the documented keyless openmaic.yml provider work. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR * fix(model-config): carry narration, image and video switched off over to the workspace The browser import carried a capability's "on" choice but not an explicit "off" for narration, images and video. A user who had switched one off got it back on after upgrading, because the deployment's defaults now turn those capabilities on. When the old store has `ttsEnabled`, `imageGenerationEnabled` or `videoGenerationEnabled` stored as false while a usable provider for the capability was there, the import proposes `tts: null` / `image: null` / `video: null`, as `asrEnabled: false` already becomes `asr: null`. Earlier builds defaulted these switches to off and turned them on by themselves once a provider was usable (a server provider on the first load, a key the user entered), and off when none was. So `false` with a usable provider (server-configured and not switched off by the operator, or with the user's own key; browser speech synthesis does not count) is the user's choice, and `false` without one is only the default, which is not carried over. A server that gained a provider after the browser's first load cannot be told apart and is read as off: that is visible in the settings and costs nothing, while reading it as on could start paid generation the user refused. Web search off is not carried over: it only stopped course research, while chat and the agent kept searching through the same provider. A slot the deployment locks is skipped like any other import (SLOT_LOCKED). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> * fix(import): drop a browser-kept key only when the server holds the same one (#1785) * fix(import): drop a browser-kept setting only when the server holds the same one The one-time import of browser model settings could still lose a key: - A provider id the workspace already had was answered `EXISTS`, and the browser deleted its copy, even when the workspace held another key. The server now compares the proposed provider with the stored one (preset, endpoint in its normalised form, models, and the key, decrypted and compared in constant time) and answers `EXISTS_SAME` or `EXISTS_DIFFERENT`. Only `EXISTS_SAME` lets the browser drop its copy; a key this instance cannot open is never confirmed. Nothing else about the stored provider is returned. - Provider ids and slot ids shared one namespace in the answer, so a slot `tts` that was imported confirmed a refused provider `tts` and its key was deleted. The answer now names each item's kind (`{ kind: 'provider' | 'slot', id }`), and kept items are keyed by kind and id. - The notice in Settings cleared a kept provider, key included, as soon as any new workspace provider of its preset appeared, even one without a key. A kept key now leaves only when such a provider holds it as far as the view shows (key set, readable, same mask); otherwise it stays, with its copy button, until the user discards it. Key pairs and keys too short to show in a mask are never cleared this way. The key mask moves to a module the server and the browser share. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR * docs(changelog): the narration, image and video off switches are carried over The changelog still said the image, video and narration off switches were not carried over. They become `tts`/`image`/`video: null` when a usable provider was set up, as the configuration docs and the import README already say; only the research switch, and a switch off only by default, are not carried over. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR * fix(import): never clear a kept key by itself A kept provider that holds a key (or a key pair) used to leave the browser once a new workspace provider of its preset held a key whose mask matched. A last-four-characters match does not confirm the workspace holds that key, so such an item now stays, with its copy button, until the user discards it. Kept items without a key still leave by themselves once the view shows them set up again. The key mask goes back to the server module, its only user. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> * fix(import): keep browser settings on a refused or self-contradicting answer (#1787) Two more ways the one-time import of browser model settings could lose a key: - A 400 removed the proposal outright. The server refuses the whole proposal then (an unexpected top-level field is enough), so sending it again cannot succeed, but its keys existed nowhere else. Every item is now kept in the browser first, keys included, as refused with the server's message, and only then is the proposal removed; if they cannot be kept, the proposal stays. Kept items are never sent again. 401, 404, 409, 5xx and network failures still keep the proposal for a later load. - An answer that listed an item as both imported and skipped, or skipped it with different codes, let `imported` (or the last code) win, which could delete a key the server did not hold. Such an item is now kept as unconfirmed. Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> * fix(generation): keep a placeholder-free message for material formats a run refuses #1779 gave upload.unsupportedCourseMaterial {{parser}} and {{formats}} placeholders for the toolbar, which knows the selected extractor. The run start (a format the server's material policy does not list) and the material upload route's 415 use the same key with no values, so the message would have shown the raw placeholders. They now use upload.unsupportedMaterialFormat, the earlier wording in every locale. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * ci: run the prior-run-record PostgreSQL suite with the app-domain contracts #1782 added tests/agent-runtime/prior-run-record.pg.test.ts without listing it in the app-domain contract step, the only job that gives the app suites a database, so it skipped everywhere. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat(model-config): server defaults, explicit locks and settings shaped by what users can change (#1793) * feat(model-config): server defaults, explicit locks and settings shaped by what users can change openmaic.yml `slots` are now server defaults that users may change in the settings; `lock: [slot, ...]` or `lock: all` fixes slots for everyone, each with its whole subtree. Inside a locked subtree a slot resolves from the deployment alone; elsewhere each node consults the workspace's assignment and then the deployment's default. `allowUserKeys` (default true) replaces `policy.allowWorkspaceProviders`, which is refused at startup naming the new key. Legacy variables translate into the same deployment layer as defaults, so the separate defaults walk is gone; a request's deprecated model fields still replace a server default (as they did DEFAULT_MODEL), never a workspace choice or a lock. The settings view carries per-slot `source` (default / workspace / locked / inherited / unconfigured), `serverDefault` and top-level `allowUserKeys`; writes anywhere in a locked subtree answer 409 SLOT_LOCKED. The UI derives one of three shapes from the view (set it up yourself / choose a model / configured by the administrator) and renders a control only where using it can change something: Token Plan, Model Services tabs and adding services, locked cards as one read-only line, "Reset to server default", a read-only summary when everything is locked, and the home toolbar picker. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs(model-config): defaults, locks, allowUserKeys and the three settings shapes Rewrite "Locks and the model settings" as "Defaults, locks and the model settings" in every locale: `slots` are server defaults, `lock` fixes slots with their subtrees (or `lock: all`), the three shapes the settings take and the rule that a control shows only where it can change something. The Policy section becomes allowUserKeys, the resolution rules, the deprecated request fields and the migration notes follow, and the example openmaic.yml, the READMEs, the openmaic skill and the changelog describe the same behaviour. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(model-config): keep openmaic.yml defaults ahead of deprecated request fields A request's deprecated model and provider fields now answer only where nothing is assigned, or over a default translated from the legacy variables (as DEFAULT_MODEL always ranked below the model a request named). A default written in openmaic.yml stands, as it did when the file locked what it set: otherwise the legacy request paths that fill in a provider of their own (web search's fallback provider) would replace it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(settings): show a locked line's value in full and let the summary scroll A locked slot's line keeps its value on the first row and puts the lock with "Fixed by the administrator" below it, so a short value is no longer cut off by the label. The read-only summary scrolls inside the dialog instead of being clipped below the language models. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(agent-runtime): provision the session schema before the material schema On a fresh database the material extraction runner's first scan created the session-material store before anything had created agent_sessions, which its table references, and the scan failed once. The material store now waits for the agent-session store (and so its schema) first. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(settings): name deployment providers when presets are not served Provider views carry their preset's name and kind, so a deployment's providers keep their display names (MinerU, DeepSeek, Bocha) on the cards, in the pickers and in the home toolbar under allowUserKeys: false, when the preset catalogue for adding providers is empty. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat(settings): read-only diagram for locked deployments, and controls only where they apply - Configured by the administrator: Course Model Config shows the course model diagram with every card read-only and one line saying the administrator set up the models; the separate summary and its strings are gone. - The home page model picker, a shortcut for the default model, is rendered only when that model can be changed (canChangeDefaultModel): not when llm is locked, and no read-only label in its place. The set-up shortcut appears only where the settings can still set a language model. - Connecting a token plan treats a server default written on a slot as a current choice: the confirmation lists it (marked as the server default) and says the plan replaces what each stage uses now, server defaults included; keeping the current setup leaves it. - Docs and changelog follow (6 locales). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(model-config): the user's choice beats server defaults; close lock and key gaps - Outside a locked subtree a slot takes the workspace's assignment anywhere from the slot up to the root first, and only without one the server's defaults (openmaic.yml or the translated legacy variables): whatever a user changes wins, and a slot that follows its parent follows the user's parent. A deployment configured through DEFAULT_MODEL keeps the Pro agent off until someone chooses a default model, and the agent follows that choice. - A document slot that `lock: all` leaves unassigned is fixed as nothing: material extraction no longer takes a provider, key or endpoint from the deprecated request fields for it (documentStatus `locked`). - The settings view leaves out providers a workspace added before `allowUserKeys: false`, and the assignments naming them, as the calls do. - Clearing a stale workspace assignment inside a locked subtree is allowed; setting one still answers 409 SLOT_LOCKED. - Under `allowUserKeys: false` the raw key forms of verify-pdf-provider, provider/probe-models and azure-voices are refused, as the verify routes' are. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(settings): follow the user's parent, keep voices and connected plans reachable - A card whose server default is overridden by the user's choice on a parent says it follows that parent; its picker still offers the server default, written as the slot's own when clearing would follow the parent instead. - "Reset to server default" is offered only when the workspace value differs from the default (model, fallback, thinking and parameters compared). - The Text-to-Speech tab stays for narration with voices of the user's own (VoxCPM designs, Qwen clones) in every shape, opening on the service in use. - A token plan the workspace connected stays listed to update its key or disconnect, after its slots are locked. - Docs (6 locales) and changelog: the new resolution rule, and the lock example no longer puts `lock: all` beside a list in one block. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(settings): suggest a provider of one's own only where adding one can change something The read-only notice of a server-configured service says "To use your own credentials, add a separate provider" only while adding a service for that capability can change something (canAddService); the sentence is its own string in every locale. The Model Services header likewise says "fill in credentials" and "Pending setup" only for a service that can be set up here, else "Not configured". Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(settings): count only the user's choices and locks as stages set separately Under the resolver where the user's choice anywhere up the tree beats a server default, a stage on an unlocked server default follows whatever default model the user picks. The home toolbar no longer treats such stages as set separately: it keeps the default-model picker instead of "Per-stage setup", and lists only stages the workspace set itself or a lock holds. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> * feat(materials): upload and extract materials as soon as they are attached; simplify the home model picker (#1796) * feat(materials): upload and extract course materials as soon as they are attached The composer uploads a material when it is attached, and the server extracts it in the background right after the upload, so Generate starts a run from materials that are ready and the preview opens without a material analysis. Server: - owner_material extraction state idle | extracting | ready | failed, with the error, what it found (text, pages, images) and what a course leaves out of the material on its own (migration 3 adds the extractor lease columns and indexes; earlier states become idle). - A process-scoped background extractor claims extracting materials under a heartbeat lease (a crash or restart is taken over once the lease is stale), runs the material-analysis step's own extraction with its time budget, and stores the result (text, images with their bytes) next to the material's bytes. Concurrency per process: OPENMAIC_MATERIAL_EXTRACTION_CONCURRENCY (default 2). - The same bytes uploaded again by the same owner reuse a ready extraction made under the same extraction services. - POST /api/materials starts the extraction (?extract=false defers it, used by the agent workspace, which extracts what a session binds itself); GET /api/materials and GET /api/materials/{id} report the owner's uploads with their extraction; POST /api/materials/{id}/extraction extracts a failed one again; deleting a material deletes its extraction result and drops an extraction in progress. - The run's material step reads ready extractions, waits for running ones, starts idle ones and fails with a failed one's error; Retry of a run paused there extracts the failed materials again. The run tells the preview what it waits on only when it waits. Images still become course assets when the run uses them, so the produced course is unchanged and releasing a material never touches a course. Client: - Material chips show the upload progress, Parsing / Transcribing, Ready (with what a course leaves out of the material), or the failure reason with Retry and Remove; limits and formats are checked against the capabilities endpoint at attach time. Generate is disabled until every material is ready. - Materials no run took are deleted when the composer goes away. - The preview lists the analysis step only while a run waits for an extraction, and no longer shows the truncation notice. Docs: the openmaic skill's generate flow describes the extraction state and the optional wait before submitting. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat(toolbar): show the home model picker as the default model alone The home toolbar's model picker no longer carries settings jargon a learner cannot act on: - The "N stages set separately" hint next to it is gone; stages with a model of their own are shown in Course Model Config's diagram. - It is never replaced by a "Per-stage setup" summary: whenever the default model can be changed it is the picker, since changing the default still changes every slot that follows it (the classroom, the agents). It is hidden only when the default cannot be changed, as before. - The button shows the model name (and the thinking level badge) without a "Default ·" prefix; the dropdown keeps its note on what picking changes. The now unused override helpers (lib/model-settings/overrides.ts), the home picker wrapper, the picker's valuePrefix and their i18n keys are removed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(materials): fence extraction results per attempt, cancel extractors, share workers fairly Follows the cross-review of the extraction at upload: - Result writes: every claim takes its own lease (worker id plus an attempt id), a process never claims a material it already extracts, and each attempt stores its result under its own immutable key (written to a temporary file and renamed into place). The settle publishes the winning key in the same fenced write; an attempt that loses its lease deletes only its own object, and an owner claim moving the material is no longer taken for a delete. A material's results go with it (prefix delete). - Cancellation: the extractor config carries the caller's signal, and self-hosted MinerU, MinerU Cloud (polling and retries included), AliDocMind's polling and the local ffmpeg/ASR pipeline stop on it. The extraction waits until the extractor actually settles, so a deleted or timed-out material keeps its worker slot until its provider work stops. - Fairness: claims are serialized and take owners in turns, at most OPENMAIC_MATERIAL_EXTRACTION_PER_OWNER (default 2) running per owner. A run no longer charges queue time to its step budget: it waits without a deadline while its material is queued, and an extraction gets the budget once a worker claims it. - Leaks: uploads no run or agent session references are deleted after OPENMAIC_UNUSED_MATERIAL_TTL_HOURS (default 24) by an hourly sweep, which also finishes released and half-deleted materials. The composer hands its materials to the run before anything is awaited, keeps them when the page goes into the back/forward cache (and reconciles them on return), and shows attached files before the policy is read. - Reuse: an extraction is reused only for the same type and the same document service (provider, model, endpoint, options, origin) and speech service (provider, model, endpoint). - Quota: a stored result counts against the owner's byte quota, and one result is capped (OPENMAIC_MATERIAL_EXTRACTION_MAX_RESULT_MB, default 100; EXTRACTION_RESULT_TOO_LARGE). - A run's Retry restarts its failed extractions only among the materials of the owner that holds the run now; a composer Retry answered 409 follows the restarted extraction; an empty sessionId answers 400. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(materials): sweep unused uploads once at start too Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(materials): keep composer uploads alive, quota-check results, finish cancellation, sweep orphans Follows the convergence review of the extraction at upload: - The unused-upload sweep ages a material from when it was last read back (owner-material migration 4, `touched_at`): GET /api/materials/{id} refreshes it, an open and visible composer reads its materials back every 10 minutes, and Generate checks they still exist before it starts (a material that went shows as removed). - Publishing a result checks the owner's byte quota under the lock an upload's reservation takes; over it, the extraction fails with MATERIAL_QUOTA_EXCEEDED and the attempt's object is deleted. - AliDocMind sends no request after an abort: submission, polling and every result page check the signal (its SDK cannot abort a call in flight). - A cancelled ffmpeg command is terminated (SIGTERM, SIGKILL after a 2 s grace) and the rejection waits for the process to exit. - The sweep also deletes result objects under a live material's `.extraction/` that are not its published result and are older than any attempt could still settle (twice the extraction budget plus the lease). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(materials): recheck sweep and claim conditions on the locked row The unused-upload sweep chose its materials in a subquery and marked them deleted without checking again, so a read (touch) that committed while the sweep waited for the row lock still lost the material. The conditions (ready, not deleted, untouched since the cutoff, no run or session names it) are now on the UPDATE's own row, which PostgreSQL rechecks on the row's latest version after the lock; the extraction claim does the same. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> * release: 1.2.0-rc.1 Close the Unreleased section as 1.2.0-rc.1 with a short introduction, set the app version to 1.2.0-rc.1, and stop triggering CI on the integration/server-first and integration/provider-config branches, which retire with this release. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JbL3cuQFRx8d1uJVQXz7XV * docs(readme): deploy 1.1.x on Vercel from a fork instead of the deploy button Vercel's clone button reads tree/release/1.1.x as the branch release plus the directory 1.1.x, and it does not accept a tag or a commit, so the button could not deploy the 1.1.x branch. Describe the fork, default-branch and import steps instead, in both READMEs, and correct the changelog sentence. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JbL3cuQFRx8d1uJVQXz7XV * docs(readme): lead with 1.2.0 and condense the News list Replace the 1.0 highlight with a short 1.2.0-rc.1 summary, keep one line per release in News with the 0.x entries folded, and bring the Chinese README's News up to date. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JbL3cuQFRx8d1uJVQXz7XV * docs(readme): drop the 1.2.0 headline and explain server-first in News Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JbL3cuQFRx8d1uJVQXz7XV * docs(readme): remove the skill tip box Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JbL3cuQFRx8d1uJVQXz7XV * docs: add a Hosting and identity page Move the operator and embedder material that lived in the README into the docs site: Docker Compose defaults and upgrade notes, what the server stores, access to stored data, asset collection and quotas, startup checks, the agent runtime, owner identity (single-user mode, registering auth methods, the signed-JWT gateway recipe, claiming anonymous work) and the host extension hooks. The deployment pages now link to it instead of the README anchor. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JbL3cuQFRx8d1uJVQXz7XV * docs(readme): shorten the configuration and persistence sections Keep the minimum a new user needs in Quick Start: one openmaic.yml example, a table of where to read more, and the longer provider examples in a collapsed block. Docker, ACCESS_CODE, the agent workbench and server-backed persistence become short sections with an identity-mode table, linking to the new Hosting and identity page for the details. Optional local providers move after the numbered steps. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JbL3cuQFRx8d1uJVQXz7XV * fix(settings): keep the selected service in view in the Model Services list The service panel opens on the service in use, which can sit far down a long list (a connected token plan named after a built-in service is listed in registry order). Its row was below the fold while the promoted first row's ring looked like the selection, so the panel seemed to show a service the list did not highlight. Scroll the selected row into view whenever the selection changes, and give the selected row a stronger ring than the promoted one. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs(changelog): condense the 1.2.0-rc.1 notes and explain server-first Lead with highlights, breaking changes, upgrade steps and known issues; keep the detailed notes folded below. Dated 2026-10-04. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JbL3cuQFRx8d1uJVQXz7XV * fix(editor): let slide elements be dragged and resized in Safari The Pro-mode slide editor's gesture hooks (drag, resize, rotate, shape keypoint) decided between mouse and touch input with an instanceof test against the TouchEvent global. Desktop Safari does not implement the Touch Events API, so the global is undefined there and every mouse-down on an element threw a ReferenceError before the gesture was armed: no element could be moved, resized or rotated. Recognise touch events by their changedTouches list instead, through one shared helper. A unit test pins the helper in an environment without the global and fails if browser code reintroduces the instanceof check; an e2e spec removes the global before the app loads and drags and resizes an element. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs(changelog): note the Safari editor fix Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JbL3cuQFRx8d1uJVQXz7XV * fix(composer): compact material rows in the course-material popover The material cards in the composer's material popover used a larger type scale than the rest of the popover (a 14px title and 12px status beside its 12px labels and 10px hints), a large icon tile, and a progress bar loose under the content. Each material is now a compact row on the popover's type scale: the title at 12px medium with ellipsis (the full name in its tooltip), the status in 10px muted text (the upload percent, Parsing or Transcribing with a spinner, Ready with the file size), or the failure reason in the destructive colour. The icon is a small file-type icon (slides, spreadsheet, image, audio, video, document). The upload progress is a thin bar along the row's bottom edge, and a pulsing one shows an extraction in progress. Retry and Remove are compact icon buttons with their accessible names. The truncation notices and the audio/video label stay on the row, small. The drop-zone hints and the merge-order note now share one muted tone, and the list spacing matches. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(generation-preview): drop the material-analysis step from the preview Materials are uploaded and extracted as soon as they are attached, so the run's material step only reuses (or waits for) that extraction. The preview still listed it as "Analyzing documents" while the step ran, which flashed by even when every material was ready. The preview now never lists a material step: while the run is at it, the preview shows the step that comes next. A failure of the material step still pauses the run and shows in the preview with its message and Retry, like any other step's failure. Removes what only that step used: the pdf-analysis step and its scanning visualizer, the material kinds and analysis flags of the client run view, and the analyzingCourseMaterial(Desc) / analyzingMediaMaterial strings in every locale. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs(changelog): note the material rows and the preview step removal Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JbL3cuQFRx8d1uJVQXz7XV * fix(settings): hide Token Plan and Model Services when everything is locked Under `lock: all` the settings show only the read-only course model map. A token plan the workspace connected earlier changes nothing there, and narration voices are not managed in this shape for now. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JbL3cuQFRx8d1uJVQXz7XV * docs(changelog): note the full-lock settings change Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JbL3cuQFRx8d1uJVQXz7XV * fix(workspace): stop marking generated courses read-only The Pro workspace treated a course whose outline a server job produced as someone else's. Since generation runs on the server, that is every generated course, so the owner saw a "Read-only" badge on a course they can edit. Ownership now comes from the course list's isOwner alone (false for a course saved from Discover), plus a run still producing the course. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JbL3cuQFRx8d1uJVQXz7XV * docs(changelog): note the workspace read-only badge fix Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JbL3cuQFRx8d1uJVQXz7XV * fix(composer): hide the extractor picker when the document slot is locked The course material popover offered the document extractor select even when the deployment locks the document slot (`lock: [document]` or `lock: all`), where it could change nothing. It is now shown only where the user may set that slot; uploading stays available. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JbL3cuQFRx8d1uJVQXz7XV * feat(token-plan): run the Pro agent on DeepSeek V4.1 Flash with TokenDance Connecting the TokenDance plan keeps the plan's own default model and its slide and interactive models for those pages, and now also assigns the agent slot to deepseek-v4.1-flash, a general model with dependable tool calling. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JbL3cuQFRx8d1uJVQXz7XV * fix(whiteboard): hydrate learner boards without a 404 point read Every classroom mount and whiteboard open hydrates the learner board through the runtime service. When the partition listing held no whiteboard session, selectSession then fetched the deterministic session id directly, which the HTTP runtime store answers with 404 SESSION_NOT_FOUND. A board nobody has drawn on yet is the normal state, so every fresh classroom logged a failed request in the browser console, in both the legacy and the native child whiteboard harness. Hydration now reads the (stageId, learnerKey) partition listing only. A same-id session of another kind in the partition is still rejected from the listing, and an id collision the listing cannot show (a corrupt row, or an id re-keyed into another partition by a learner merge) still fails loud on the next append: its create collides and the existing create-race re-read validates the winner. The runtime HTTP contract and its 404 semantics for unknown ids are unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs(changelog): note the extractor picker, TokenDance agent and whiteboard fixes Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JbL3cuQFRx8d1uJVQXz7XV * fix(home): render the course library without waiting for every thumbnail The home page gated the whole library section on loading every course's first-slide thumbnail: loadClassrooms awaited getFirstSlideByStages, which read each listed course's full document and fetched its first slide's media bytes in one unbounded Promise.all, and `hydrated` only flipped once all of that finished. A library of a few dozen courses fired one document read per course plus every first-slide asset at once, and the list stayed hidden until the last one landed. Every run-driven or import-driven list refresh repeated the whole burst. Render the list as soon as GET /api/stages answers, and load thumbnails per card: a card (or a folder tile, for its cover candidates) asks for its thumbnail while it is near the viewport, at most four loads run at once, a card that scrolls away before its load starts withdraws it, and leaving the page aborts the loads in flight and revokes every object URL. Thumbnails are cached per course and versioned by updatedAt, so a list refresh reloads only courses that changed. Cards show a skeleton until their thumbnail resolves. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs(changelog): note the home library loading fix Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JbL3cuQFRx8d1uJVQXz7XV * feat(home): paint the hero and a library skeleton before the page loads Until the page's scripts had loaded and both GET /api/stages and /api/folders had answered, the home page showed only its grey background: the hero's entrance started from opacity 0 in the server HTML and only a JS-driven animation revealed it, and the library section was not rendered at all until both reads resolved. The hero's entrance is now CSS (same timings), so the server-rendered hero is painted and fades in without waiting for the scripts. The library section, with its action bar, is always rendered; until the reads resolve it shows a skeleton of its own layout: the same grid, with folder and course tiles built from the cards' 16:9 thumbnail and title row and the thumbnail pulse, so the cards replace it without moving anything. The grid takes the skeleton's place without an entrance fade or stagger. An empty library still shows the empty state, and a failed read the existing error. A small pre-paint script applies the stored or system dark theme before the first paint, so the server-rendered page is not painted light first in dark mode; ThemeProvider leaves the document alone until it has read the stored theme. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat(home): keep course thumbnails in the device cache across reloads Every reload of the home page read each visible course's whole document and fetched its first slide's media again: document reads carry no validators and asset bytes are served `private, no-store` (and the asset client fetches with `cache: 'no-store'`), so nothing was answered from the browser's HTTP cache. With a 33-course library a reload re-read 14 documents (1.1 MB) and 29 assets (19 MB) before the visible thumbnails showed. The thumbnail is derived data, so keep the derived result: a new `courseThumbnails` table in the device cache (`maic-device-cache`) holds, per course, the first slide and the bytes of its media, valid for exactly the course's `updatedAt`. The thumbnail loader is told the version it loads, answers from the cache when it holds that version, and otherwise reads the course as before and stores the result. A changed course is read again and its entry replaced. A thumbnail whose media could not be read (a transient error) is not stored. Entries are partitioned by owner, keyed by a one-way digest of the server-derived owner key, so a claimed or switched owner never sees another owner's thumbnails and the owner id is not written to disk. Clear Local Cache deletes the device database, these entries included. The cache is bounded (300 entries, 64 MB) and evicts the least recently used entries. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs(changelog): note the home skeleton and thumbnail cache Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(home): show a slide-shaped skeleton until a course thumbnail loads A card waiting for its thumbnail showed a flat pulse over the tile's own background, too faint to read as loading, so once the library skeleton gave way to the cards the thumbnails looked like empty grey tiles. They now show the outline of a slide (title, text lines, an image block) pulsing at a visible contrast, also while a loaded slide waits for the card's width, and the library skeleton's course tiles use the same placeholder. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JbL3cuQFRx8d1uJVQXz7XV * docs(deployment): say what a restart or rolling update does to running runs The guide said that stopping the server parks its runs. A stopping process may exit before it releases its leases, so a run resumes once its lease expires: progress pauses for about the lease TTL and the step in progress is generated again, and a run interrupted MAX_ATTEMPTS times in a row without finishing a step pauses with Retry. Say so, in every locale, and when to raise the limit. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JbL3cuQFRx8d1uJVQXz7XV * refactor(home): drop the composer's requirement draft cache The home composer kept its requirement in localStorage and restored it on the next visit, clearing it only once the generation preview confirmed the outline. Generate now creates the course card at once, so a draft has no job left, and it kept the last requirement in the composer after a run had started. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JbL3cuQFRx8d1uJVQXz7XV * fix(home): draw video thumbnails from a poster instead of loading the video Every home page load left aborted blob requests behind, one or more per course whose first slide holds a video. SlideThumbnail drew a thumbnail video as a muted <video preload="metadata"> over the video's object URL. Chromium reads an object URL whatever `preload` says, and once it has the metadata and a frame it drops the rest of the read, which DevTools lists as a failed (net::ERR_ABORTED, type media) request; non-faststart files abort twice. Safari's media stack range-reads the same URL dozens of times per thumbnail, and lists the reads it gives up on as failed requests of type "other". Nothing was revoked or remounted early: the element was still mounted with its URL alive when the browser dropped the read. A thumbnail video is now drawn as its poster image, with no media element to load anything. Generated videos usually come without a poster, so the thumbnail loader decodes the opening frame once and keeps it as one, in the device cache with the rest of the thumbnail. The decode hands the bytes to a detached <video> as a data: URL, which is decoded in memory and issues no request at all, where an object URL would leave the same aborted reads behind. Cached entries carry a derivation format; entries written before this are read as a miss and derived again. A video without a poster whose frame cannot be decoded still falls back to the <video>. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs(changelog): note the video thumbnail request fix Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JbL3cuQFRx8d1uJVQXz7XV * fix(generation): continue after the outline on the server unless the learner reviews outlines A classic run started with `outlineReview: "wait"` always, and only the preview tab that started it confirmed the outline after a 2.5 s beat. A learner who left before the outline was ready found the run waiting for a confirmation forever. The composer now starts the run with `outlineReview: "auto"` unless the learner turned on "Always review outlines before generation"; the server then confirms the outline itself and the run goes on with no page open. A run that waits shows the review in every tab and page, with no countdown. The preview of an auto run shows the outline read-only and offers the setting for the next runs instead of a review entry. Removes the client-side auto-continue (countdown, the started-here session marker) and its string. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test(e2e): give the mid-stream review time to open while the outline streams Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs(changelog): note outline auto-continue and the dropped composer draft Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JbL3cuQFRx8d1uJVQXz7XV * fix(generation): count down to the outline confirmation on the server The browser counted the 2.5 s pause before an outline continued, so a run whose page was left before the outline was ready waited for a confirmation forever. The previous fix confirmed such outlines at once, which took away the review and editing the preview offered meanwhile. The pause now runs on the server. A run started with `outlineReview: "countdown"` (the composer's default when the learner does not always review outlines) waits for its outline with an auto-confirm deadline stored on the run (generation-runs migration 4); any process's runner confirms the outlines whose deadline passed, in the same commit and event an `auto` run makes. The new `hold-outline` command turns such a run into a `wait` one, while the outline streams or during the countdown, and answers a conflict once the outline was confirmed. The preview is back to the classic flow: the outline-ready card while the run counts down, the review entry mid-stream and on that card (it holds the run first), editing and confirming with edits. It never confirms on a timer, and says so when the run went on before the hold. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs(changelog): describe the server-side outline countdown Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JbL3cuQFRx8d1uJVQXz7XV * fix(classroom): make the element reference control an icon button The playback toolbar's reference control carried a text label next to its quote icon. It is now an icon button like the whiteboard control beside it; its accessible name and tooltip keep the label. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JbL3cuQFRx8d1uJVQXz7XV * fix(workspace): handle courses that are still being generated A course a classic generation run is still producing is read-only until the run completes, but the Pro workspace treated it as an ordinary course: - the classroom pane stayed blank. The pane is edit-locked, and the edit chrome never resolves for a read-only course, so `resolveStageChromeMode` returned its neutral `loading` shell for as long as the run lasted. The pane now shows a "being generated" placeholder with the run's progress and a link to follow it (or to Retry a paused run), and mounts the course by itself when the run completes; - the course rail listed it as openable. It now reads the owner's active runs (the source the home page's cards use) and shows the run's progress where the page count goes; the row cannot be opened, dragged, renamed or deleted, and is not a drop target. A paused run's row opens the classroom where its Retry lives. The row turns ordinary, live, when the run finishes; - the `@` picker offered it. It no longer does, not even as the open course; - the agent did not know. The reader tools (read_stage, grep_stage, list_scenes, read_stage_outline, read_classroom) now start their result with a notice that the course is read-only until generation completes and must not be offered for editing; list_folder_stages and search_classrooms mark it with `generating`, `scenesCompleted` and `scenesTotal`; a classroom the user names carries the same notice. The pro-editing skill says the same in one line. Writes are still refused by the store, and the refusal text reaches the agent unchanged. The home page's run status pill moved to a shared component so the rail and the placeholder say the state the same way. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs(changelog): note generating courses in the Pro workspace Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JbL3cuQFRx8d1uJVQXz7XV * fix(workspace): list runs without a course yet in the course rail The home page shows a card for a classic run from the moment it starts; the Pro workspace's course rail showed nothing until the run created its course. - The rail now lists the owner's active runs whose course is not listed yet, from the same `useOwnerRuns` source and the same rule as the home page's pending cards (`pendingCourseRuns`, now shared with `runsByCourse`), in the same order, titled as the card (`pendingCourseName`) and labelled with the shared run status pill text. A placeholder sits at the top of the loose courses, where its course row appears once the course is listed, so it turns into the generating course row in place and then an ordinary one. It opens what the card opens (`courseRunHref`) in a new tab, is never draggable or mentionable, and its one action is the card's discard. Like the home page, placeholders are left out of a search. - A run started or discarded in one tab now tells the browser's other tabs (`runs-changed`, a BroadcastChannel), so a workspace open beside the home page reads the runs at once instead of at its next idle read. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs(changelog): note run placeholders in the Pro workspace list Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JbL3cuQFRx8d1uJVQXz7XV * fix(persistence): sanitize slide HTML on every document read and write (#1799) Course documents served by `/api/persistence` are readable by id, and the classroom renders slide text, shape text, table cells and LaTeX snapshots with `dangerouslySetInnerHTML`. `/api/classroom` already restricts that HTML to the renderer's vocabulary; the persistence document path did not. The owner-bound document store now applies the same `sanitizeSceneContent` policy to the stage and scenes on every write (save, create, putStage, putScene, mutateScene) and every read (loadDocument, getScene), so new rows are stored clean and rows written earlier are served clean. Claude-Session: https://claude.ai/code/session_01Cvq11H26QM4mfbSNQQ5FdZ Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> | 28 天前 | |
feat(persistence)!: server-backed persistence only, with a one-way legacy browser import (#1710) * ci: run CI for the persistence-default integration branch Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat(docker): start PostgreSQL by default and add a single-user owner * feat(docker): start PostgreSQL by default and add a single-user owner `docker compose up` now runs a server-backed app against a bundled PostgreSQL, started only once PostgreSQL is healthy and published on 127.0.0.1 only, with a new built-in singleUser owner auth method: every request resolves to one fixed owner that may publish, and no anonymous cookie is minted. Single-user mode (OWNER_SINGLE_USER=true, OWNER_SINGLE_USER_ID default "local") runs with or without ACCESS_CODE. Without one, the server logs one prominent startup warning that anyone who can reach it shares, edits and can delete the single library; it never inspects request peers or forwarding headers. It excludes PERSISTENCE_SHARED_OWNER_ID and follows the sharedTeam registration rules. Its principal gets a claim candidate, so a browser's earlier anonymous work is claimed into the single owner (OWNER_CLAIM_TRIGGER=auto in the Compose defaults). Compose defaults live in docker-compose.defaults.env, read before .env.local so they can be overridden. The app warns when its declared published address (OPENMAIC_PUBLISH_ADDRESS, passed by Compose) is not loopback while PostgreSQL uses the default password. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(docker): keep claims explicit and let .env.local set DATABASE_URL - Compose no longer defaults OWNER_CLAIM_TRIGGER to auto: an irreversible merge of every browser's anonymous library into the single owner must not happen on upgrade. The single-user principal still gets a claim candidate, so POST /api/identity/claim works; the docs explain it and warn about auto on a deployment several people used. - The bundled DATABASE_URL moves to docker-compose.defaults.env, read before .env.local, so an external database or a rotated password set there keeps working. The password is inserted unencoded, so it must be letters and digits (documented). - Upgrade docs no longer claim browser-stored courses are copied as they are opened: they stay in the browser, are not deleted, and are moved by the automatic browser-to-server migration shipped with this work. - Single-user mode without ACCESS_CODE now logs one warning instead of two; the docs note that the single owner id is permanent. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> * feat(persistence)!: server-backed persistence is the only persistence (#1707) * feat(persistence)!: make server-backed persistence the only persistence Courses, folders and folder membership, chat history and learner runtime, and generated media are now always stored on the server through the embedded /api/persistence endpoint. The browser keeps no durable data of its own. - Remove the build-time NEXT_PUBLIC_PERSISTENCE switch: the client bootstrap always configures the HTTP document, runtime and asset seams, and the Dockerfile, docker-compose.yml, the Pi route and the whiteboard runtime no longer read it. Unconfigured seams refuse to resolve instead of falling back to IndexedDB, and the learner key is always the server-derived one. - Remove the browser write paths: the browser document, runtime and asset stores as backends, the browser-mode branches of stage, folder, chat, media, narration and import storage, the Dexie backup export/import and the whole-database clear. The library lists through /api/stages, folders through /api/folders, and membership now goes through /api/folders/members. Regeneration always forks to a fresh asset. - Device-local state moves to its own IndexedDB database (lib/device-storage, maic-device-cache): the local media and narration cache and refused-bytes retention, staged PDF images, undo history, browser voice profiles (carried over once from the old database) and the auto-voice cache. "Clear Local Cache" clears exactly that and no longer claims to delete classrooms or chat history. - Keep the pre-server data readable for the one-way importer: the Dexie schema and read-only accessors for its tables and for the browser document, runtime and asset databases live in lib/legacy-browser-storage, which nothing on the regular paths reads and which has no write path. - Require a database: the server exits at boot without DATABASE_URL, with a message naming `pnpm db:up` and `docker compose up`. `pnpm db:up` / `pnpm db:down` start and stop the Compose postgres service alone, published on 127.0.0.1 through docker-compose.db.yml; .env.example carries the matching local DATABASE_URL. - E2E specs seed their courses through the persistence endpoint, each under a fresh id, instead of writing IndexedDB. - Guard tests pin the boot requirement, the absence of any NEXT_PUBLIC_PERSISTENCE read, and the legacy-storage boundary. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * ci: run the E2E suite against PostgreSQL The app refuses to start without DATABASE_URL, so the E2E job gets a postgres:16 service and a job-level DATABASE_URL. The unit, storage and render-service jobs are unchanged and need no database. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs: document always-on server persistence and the database requirement README (English and Chinese), the deployment guide in every locale, the extension cookbook and the changelog now describe PostgreSQL as required: `pnpm db:up` for local development, `docker compose up` for a deployment, and an external database for Vercel and other serverless hosts (the Deploy button prompts for DATABASE_URL). They list what stays in the browser, drop the NEXT_PUBLIC_PERSISTENCE instructions, and say that courses an earlier browser-only build stored stay in the browser until the one-way importer moves them. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(pbl): use the server-derived learner key for PBL v2 runtime PBL v2 synchronization, hydration and drain resolved the learner key from their default watermark KV store, which reads or mints a device key in localStorage instead of asking runtime configuration. The runtime store is the server one, which refuses any key but the owner's, so saving a course with a PBL v2 scene failed before the document write, and the minted key landed in the slot the one-way importer reads as the pre-server runtime partition. The learner key now comes from getLearnerKey(args.kv): the configured server-derived key, or an explicitly injected store. The default KV store still holds the drain watermarks and nothing else. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test(persistence): pin that clearing the cache keeps legacy databases - Seed the four pre-server databases (MAIC-Database, maic-documents, maic-runtime, maic-asset-pool) with real rows, run Clear Local Cache, and assert every database and row survives; assert the device database name differs from every legacy name. - The legacy-module import rule now also catches relative static, side-effect, dynamic and require imports at any depth. - New rule: the runtime learner key is never read from a KV store the caller made up, only from configuration or an injected store. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * build(dev-db): run the development database as its own Compose project `pnpm db:up` / `db:down` shared the checkout's default Compose project with `docker compose up`, so they recreated or stopped a running stack's database. docker-compose.db.yml now declares its own project (`openmaic-dev-db`), which gives the development database its own container and data volume; it no longer touches the stack, and the two do not share data. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs: start the development database before `pnpm dev` CONTRIBUTING, the getting-started guide in every locale and the startup modes reference now run `pnpm db:up` and set DATABASE_URL before `pnpm dev`. README, the deployment guides, the cookbook and the changelog describe `pnpm db:up` as a separate development database. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test(persistence): sharpen the legacy-storage and learner-key guards - The Clear Local Cache test closes its seeding connections to maic-documents and maic-runtime, so a regression deleting either fails on the assertion that names the database instead of by timeout. - The legacy-module and Dexie import rules accept comments inside import() / require(), so a webpackIgnore hint no longer hides one. - The learner-key rule also flags an injected-looking name that is bound to a default local store in the same file; its remaining limit is documented, with the behavioural PBL test as the guard. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * build(dev-db): pin the development database's Compose project `pnpm db:up` / `db:down` now pass `-p openmaic-dev-db`, which takes precedence over COMPOSE_PROJECT_NAME from the shell or a .env file; the file's `name:` alone could be overridden and point the scripts at a running stack's database. The compose test pins the flag. The docs (all locales) and the file header say the development database is shared by every checkout on the machine, and how to run a separate one. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> * feat(persistence): import legacy browser data into the server, once (#1708) * refactor(persistence): prepare the seams the legacy browser importer uses - Legacy module: read-only accessors for the auto-voice cache, the course ids the old media and narration tables name, the pre-server learner key, and asset bytes from the browser asset pool. - Narration adoption exports its row ownership rule so the importer applies the same one to rows of the old database. - The quiz legacy migration is split into a non-deleting core (importLegacyQuizSnapshot) and the regular path, which still deletes the keys it migrated. - The lazy document migration exports its snapshot canonicalizer. - Clear Local Cache keeps the importer's per-owner ledgers, as it keeps the pre-server learner key. - A library-changed window event lets an open library list courses that arrive in the background. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MRME5T4oEaQ4EBUzrDXMKz * feat(persistence): import legacy browser data into the server, once On the first load after the upgrade, the client moves what earlier builds kept in this browser to the server, for the owner the server resolves: courses from the browser document store and the original tables (with chat, learner runtime, playback position, roster, folders and membership, pre-runtime quiz state), their media bytes through commitToPool and the existing write-back funnels, the bytes of server courses from earlier opt-in server builds that only the old tables hold, and device-only rows (failure records, refused bytes, auto-voice clips) into the device cache. It is automatic and silent, runs after the page is idle, never writes to a legacy store, and records every step in a per-owner ledger so it resumes after a crash and never imports twice. Runs are serialized across tabs with Web Locks. A course the owner already has stays authoritative; one whose id another owner holds is imported under a derived fresh id; one the owner deleted on the server stays deleted. Transient failures retry on a later load with backoff; permanent ones are recorded per item. The module is temporary and self-contained; its README lists the removal steps. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MRME5T4oEaQ4EBUzrDXMKz * test(persistence): pin that each owner keeps its own import ledger Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MRME5T4oEaQ4EBUzrDXMKz * test(persistence): run the legacy import against the app routes and a browser A PostgreSQL suite routes every request the importer and the app seams send to the real persistence, library and folder handlers, owner resolved from the anonymous cookie: a full import, a course another owner holds, a course the owner deleted, and an existing course whose media is filled in. An end-to-end spec seeds an old pre-server database in Chromium, loads the app, and checks that the course reaches the library with its narration uploaded, stays in the browser untouched, and is visible from a fresh browser context with the same owner cookie. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MRME5T4oEaQ4EBUzrDXMKz * docs: describe the one-way import of legacy browser data The README (and its Chinese version), the deployment guide in every locale and the changelog now say what the importer does instead of announcing it: courses an earlier browser-only build kept in the browser move to the server automatically on the first load after the upgrade, and the browser copy stays untouched. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MRME5T4oEaQ4EBUzrDXMKz * fix(persistence): probe server assets through the shared asset-URL owner The importer asked the pool directly whether an id exists, which the asset-URL ownership boundary forbids outside use-asset-url. It now uses assetRefExists, the existing metadata-only probe. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MRME5T4oEaQ4EBUzrDXMKz * fix(persistence): guard the importer's pool probe like every other entry point Only a reference the pool could have issued is probed, as the lease guard requires; the importer is added to the list of guarded entry points. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MRME5T4oEaQ4EBUzrDXMKz * fix(persistence): import legacy browser data once per browser Review round 1 of the legacy browser importer. - Once per browser, not once per owner: the data belongs to whoever used the browser before the upgrade, so the first owner the import runs for claims it and any other owner gets nothing. The ledger is one key, maic:legacy-import:v2, recording the owner only as a SHA-256 digest (an anonymous owner id is a bearer credential) and a random salt. - Handoff: when the claiming owner is retired (403 OWNER_RETIRED), or the current owner holds courses or folders the importer created (a claim moved them), the current owner continues the unfinished items; courses already done are never imported again, so a claim neither duplicates nor resurrects them, and a half-copied course keeps its runtime. - Fresh ids come from the salt and the legacy id through SHA-256, not from the owner: unlinkable, unpredictable, and the same in every tab. - 401 pauses with backoff instead of closing the import; 403 FORBIDDEN_LEARNER stops the run and leaves the item pending. - Without Web Locks: ledger writes merge with the stored copy, and the library is listed again right before a course is placed, so a second tab does not import a course the first one just created. - Media the server refuses for good gets the app's failed-media record instead of a dangling reference; the legacy bytes stay. - A legacy whiteboard or PBL session is not created when the server already has an active one of that kind. - A folder that is gone leaves the course unfiled instead of failed, and a membership naming a folder the old database lacks counts as unfiled. - Narration rows from before the course column are adopted when exactly one legacy course names their key; unconvertible chat rows are noted; an unreadable document-store copy falls back to the original tables; PBL v2 documents go through the save-path strip. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MRME5T4oEaQ4EBUzrDXMKz * test(persistence): cover the importer's handoff, tabs, refusals and review gaps Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MRME5T4oEaQ4EBUzrDXMKz * docs: say the legacy import runs once per browser The README (and its Chinese version), the deployment guide in every locale, the changelog and the module README now say that the first owner to load the upgraded app in a browser claims its legacy data, what the handoff to an account does, that the ledger holds no owner id, and how refused media and the new pause and skip cases behave. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MRME5T4oEaQ4EBUzrDXMKz * fix(persistence): note runtime the importer cannot find without the old learner key Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MRME5T4oEaQ4EBUzrDXMKz * feat(identity): let an owner confirm it absorbed a given owner through a claim GET /api/identity/merged-from?salt=<hex>&digest=<hex> answers whether a claim merged into the requesting owner an owner whose id hashes to SHA-256(salt, id). It reads only the requester's own owner_merges rows, never lists or names an owner, resolves the owner like every other owner-scoped route, and is uncacheable. The one-way import of pre-server browser data uses it to decide whether a different owner may continue an import the first owner started. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MRME5T4oEaQ4EBUzrDXMKz * fix(persistence): hand the legacy import to another owner only on a confirmed claim Review round 2 of the legacy browser importer. - The only way the browser's legacy data moves from the owner that claimed it to a different owner is the server confirming, through GET /api/identity/merged-from, that the current owner absorbed that owner through a claim. The inferred signals (an owner that never wrote, a library listing that holds imported courses) and the retired flag are gone; a 403 OWNER_RETIRED only stops the run and leaves items pending. - The first owner is bound only once the run's first authenticated request of its own (the library listing) succeeded, so a run that dies before that leaves the ledger unclaimed. - The owner is recorded as SHA-256 of the per-browser salt and the owner id; the server computes the same value from the salt the browser sends. - A "not absorbed" answer is reused for an hour instead of asking on every load, and the claiming owner stored first survives another tab's save unless that tab handed the import over; completion survives too. - Run-level failures (401, OWNER_RETIRED, FORBIDDEN_LEARNER, OWNER_BUSY) from any call stop the run and leave the course pending, including the "is the id taken" read and the library re-list, which could previously mark the course failed and close the import. - Refused media without a generation request (the user's own) gets a new non-retryable ASSET_REFUSED code, so it shows as failed without a Retry that could only fail; a skipped singleton runtime session leaves a note. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MRME5T4oEaQ4EBUzrDXMKz * test(persistence): pin the importer ledger's owner merge and the singleton-skip note Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MRME5T4oEaQ4EBUzrDXMKz * docs: the legacy import moves to another owner only on a confirmed claim The README (and its Chinese version), the deployment guide in every locale, the changelog and the module README now say that the import runs once per browser and moves to another owner only when that owner claimed the original one, confirmed by GET /api/identity/merged-from; that the owner is recorded as a salted digest; and how refused user media shows. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MRME5T4oEaQ4EBUzrDXMKz * fix(persistence): never let a transient failure settle a legacy import item Review round 3 of the legacy browser importer. - A read of the old browser stores that fails in storage (an aborted IndexedDB transaction, a closed database) now pauses the run and leaves the course pending. Only a record that cannot be migrated, parsed or validated is skipped. This covers the course read (which no longer falls back to the older table copy on a storage failure), and the quiz-scene and speech indexes, which used to read a failure as "no scenes" or "no holder" and drop quiz state or narration for good. - After a session-id collision, a failed read of the session propagates and is retried; only a successful read that shows the id taken skips it. - Without Web Locks, two tabs of different owners can both find the ledger unbound. Every ledger write keeps the owner stored first, and the run now re-reads the stored ledger after binding, before folders, before each course and before each course document is created, and stops if another owner holds it. - The "not yours" answer is reused for ten minutes instead of an hour, a stored wait further ahead than the longest the importer sets (a clock that ran ahead) counts as passed, and expired answers are pruned. - Generated media refused without an error code keeps its Retry; only media nothing can regenerate gets ASSET_REFUSED. A poster that fails transiently leaves the video pending instead of being dropped. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MRME5T4oEaQ4EBUzrDXMKz * test(persistence): cover transient reads, racing owners, the not-yours cache and the claim client Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MRME5T4oEaQ4EBUzrDXMKz * docs(persistence): say what the ledger's salt protects, and the new pauses The salt defeats precomputed and cross-browser tables, not a targeted guess against one browser's ledger; the module README and the digest's comment now say so. The README also covers storage read failures, which pause instead of skipping, the two-owner race without Web Locks, and the ten-minute recheck. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MRME5T4oEaQ4EBUzrDXMKz * feat(identity): bind a browser's legacy data to one owner on the server The one-way import of pre-server browser data now asks the server whose data it is, instead of deciding in the browser. - legacy_import_bindings (browser_id primary key, owner_id, timestamps), provisioned with owner_merges on fresh and upgraded databases. - POST /api/identity/legacy-import-binding {browserId}: one atomic insert (ON CONFLICT DO NOTHING) and a read-back; answers only whether the requesting owner holds the browser. Same-origin JSON, resolved and write-fenced like every owner write, 400 for a malformed id. - A claim participant re-keys the claimed owner's bindings to the account inside the claim transaction, so the account simply holds the browser. - Owner resolution refuses any request carrying X-OpenMAIC-Legacy-Import with 409 LEGACY_IMPORT_NOT_BOUND unless the owner it resolves to holds that browser: one central fence every owner-scoped route goes through. Requests without the header are unaffected. - GET /api/identity/merged-from is removed; the binding replaces it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MRME5T4oEaQ4EBUzrDXMKz * fix(persistence): import through fenced clients bound by the server Review round 4 of the legacy browser importer. - The ledger holds a random browser id and no owner information; the owner digest, the "not yours" cache and the client-side handoff are gone. A run first asks the server to bind the browser and imports only when the requesting owner holds it. - Every importer request goes through its own clients that carry the browser id, so the server refuses any write under an owner that does not hold the browser: after a cookie switch in another tab, from a page whose memoized owner is stale, or from a tab that lost the binding race. The runtime learner key is asked for during each run, never taken from the page. 409 LEGACY_IMPORT_NOT_BOUND stops the run with items pending. - The write-back funnels, commitToPool and the PBL save-path strip take an optional store, put or runtime, so the importer passes its fenced ones. - A failed read of one legacy course holds up only what it could affect: the quiz-scene and speech indexes leave the course out as unindexed, and only quiz state or narration it could own waits. Read failures are counted per course; after five failing runs over at least a day, a course whose document-store copy cannot be read falls back to the table copy, or is skipped with the reason when there is none. - Runtime ids are rewritten by replacing only their course segment, so a short course id cannot corrupt the prefix or the learner segment. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MRME5T4oEaQ4EBUzrDXMKz * test(persistence): cover the server binding, its fence, bounded reads and short ids Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MRME5T4oEaQ4EBUzrDXMKz * docs: the server binds a browser's legacy data to its first owner The README (and its Chinese version), the deployment guide in every locale, the changelog and the module README now say: once per browser; the server binds the browser to the first owner; a claim carries the binding to the account; the importer's requests are refused for any other owner. They also describe the bounded pause for a course the old storage cannot read, and the removal steps for the server side. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MRME5T4oEaQ4EBUzrDXMKz * fix(persistence): keep a dropped asset request pending in the legacy import The asset client reports a fetch that got no answer (a dropped request, an aborted upload, the existence probe's deadline) as status 0 with HTTP_REQUEST_FAILED, which the importer read as a final refusal: the media was marked failed and the course could complete without it. Read that code as transient, keep the client's local validation failures final, and treat a 2xx/3xx answer a client could not use as transient too. Unit tests drive every client the importer uses with a rejected fetch and a non-JSON answer. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MRME5T4oEaQ4EBUzrDXMKz * fix(persistence): ask a refused binding again only every ten minutes A browser whose legacy data another owner holds sent the binding request (a write) on every page load. Record a ten-minute recheck in the ledger (clamped like every other deferral), so a claim that moves the binding is still picked up, and open the old databases only once the browser is bound. The read budget now counts consecutive failing runs (a run that reads the course resets it) and counts a first failure dated in the future from the next one. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MRME5T4oEaQ4EBUzrDXMKz * fix(identity): answer 503 with the cookie when the import fence cannot read The legacy import fence now reads the header name from the shared constant, and a database error while reading the binding answers the usual JSON error (503 PERSISTENCE_UNAVAILABLE) with the resolution's Set-Cookie values instead of escaping as a bare 500. Route tests cover that, a bind by an owner a claim retired (403 OWNER_RETIRED, no row), and one header name on both sides. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MRME5T4oEaQ4EBUzrDXMKz * test(persistence): cover dropped asset requests, the recheck and the read budget With the real asset client's error for an upload and an existence probe, the media stays pending and a later run imports it. A non-holder's five loads send one binding request and never open the old databases, and a claim still hands the import to the account after the delay. The read budget's run count and time span each hold on their own, a clock that ran ahead does not hold a course open, and a good read resets the count. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MRME5T4oEaQ4EBUzrDXMKz * docs(persistence): complete the legacy import removal steps List every file the removal touches (the claim scenarios test, the claim participant table and list, every doc), keep the bindings table for one release after the code goes because of rolling deploys and give the DROP statement for that release. Add the claim's eighth participant to the README lists, fix the fresh-id wording in the changelog, and describe the recheck delay, the transient status-0 asset failures and the consecutive read budget. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MRME5T4oEaQ4EBUzrDXMKz --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> * fix(persistence): keep legacy quiz state through Clear Local Cache until the import completes Pre-runtime quiz drafts, answers, results and attempt ids exist only in localStorage, and the one-way importer copies them to the server with their course. Clear Local Cache deleted them even while the import was still pending (it waits for an idle page and can stay pending after a failure), so that quiz progress was lost for good. Clear Local Cache now keeps the four quiz key families unless the importer's ledger records the import as complete, read through a new `legacyImportIsComplete` helper in the ledger module. The ledger key moves there too, so the two modules do not import each other. Once the import is complete, the keys are cleared as before. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MRME5T4oEaQ4EBUzrDXMKz * docs: show DATABASE_URL set in the local development setup The getting-started guide showed the required DATABASE_URL line commented out, so copying the block left the server unable to start. Each locale now shows `pnpm db:up` and, separately, the `.env.local` line to set. The `.env.example` header said every variable is optional; it now says that provider variables are, and that DATABASE_URL is required except under docker-compose.yml. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MRME5T4oEaQ4EBUzrDXMKz * docs(changelog): drop two upgrade notes the final code contradicts The Compose entry said its default DATABASE_URL overrides .env.local; the defaults file is read first, so a value in .env.local wins, as the same entry says further on. The runtime-session entry offered turning server persistence off as an upgrade path; that switch no longer exists. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MRME5T4oEaQ4EBUzrDXMKz * docs(env): restore the ACCESS_CODE warning notes in .env.example A later change to the example file dropped the lines saying that an unset ACCESS_CODE fails open, that the server then logs a one-time startup warning and GET /api/health reports accessCodeConfigured: false. They are back, and say that single-user mode logs its own warning in place of the generic one, as the startup code does. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MRME5T4oEaQ4EBUzrDXMKz * ci: run every app PostgreSQL suite in the contract job The app-domain step named its suites one by one, and seven had been left off, among them the legacy importer's route suite. No other job gives the app suites a database, so they skipped everywhere. The step now lists every app `*.pg.test.ts`; all of them work in a schema or database of their own, or truncate only their own tables, and pass before and after the storage package suite in the same database. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MRME5T4oEaQ4EBUzrDXMKz * fix(identity): establish the anonymous owner before the first API request (#1721) * fix(identity): establish the anonymous owner on the page response On a browser's first load the page sent several API requests without an owner cookie; each minted its own anonymous owner and set its own cookie, and the last answer to arrive won. Work done in between (the legacy import binding, a first course write) then belonged to an owner the browser no longer presented. The middleware now mints the anonymous cookie on document navigations that carry no valid one, with the route handlers' own value format and attributes (the cookie module is Edge-safe now: Web Crypto instead of node:crypto), and forwards it on the request so the page render resolves the same owner. API, RSC, prefetch and Server Action requests never mint there, and a valid cookie is never replaced. It mints only when neither OWNER_SINGLE_USER nor PERSISTENCE_SHARED_OWNER_ID is set; host registrations are invisible to an Edge middleware, so the README tells hosts where to skip it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MRME5T4oEaQ4EBUzrDXMKz * fix(legacy-import): bind only an owner the browser already presents A binding request whose owner was minted by that very request answers 409 OWNER_NOT_ESTABLISHED with the minted cookie and binds nothing: other cookieless requests may still be minting owners of their own, and a binding to this one could be left with an owner nobody presents. The importer treats it as transient and retries on a later load. It also says plainly when a request after its own successful bind is refused as another owner (the owner cookie changed mid-run); items stay pending. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MRME5T4oEaQ4EBUzrDXMKz * test(legacy-import): cover a first visit whose first answers arrive out of order Against the real routes and PostgreSQL: a browser without a cookie loads a page, sends two requests at once, and the first answer reaches it only after the importer bound the browser; the import completes under the one owner the page established. A second case binds nothing for an owner the bind itself minted and imports on the next run. The E2E drives the same ordering in Chromium by holding the first owner-scoped answer until the binding answered. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MRME5T4oEaQ4EBUzrDXMKz * test(legacy-import): drop an unused initial assignment Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MRME5T4oEaQ4EBUzrDXMKz * docs(changelog): note the page response now sets the anonymous owner cookie Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MRME5T4oEaQ4EBUzrDXMKz * fix(identity): let hosts turn page-response minting off The middleware cannot see owner auth methods a host registers, so a host with anonymousFallback: false still got an anonymous cookie on a cookieless page load. Beside the host credential it is a claim candidate; with OWNER_CLAIM_TRIGGER=auto it is claimed and cleared on the next request, again after every such page load. OWNER_ANONYMOUS_PREMINT (default on; true/1/false/0, anything else fails startup) is read by the middleware before minting. At startup the server warns once when a registration turns the anonymous fallback off while pre-minting is still on. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MRME5T4oEaQ4EBUzrDXMKz * docs(identity): document OWNER_ANONYMOUS_PREMINT Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MRME5T4oEaQ4EBUzrDXMKz * fix(identity): keep the anonymous identity for 400 days and renew it in use The anonymous cookie had a fixed 30-day lifetime from its mint and was never renewed, so every anonymous visitor lost their whole library 30 days after the first visit however active they were; with persistence on the server only, that affects every anonymous deployment. It now lasts 400 days (the browser cap, one shared constant), and every route handler and Server Action response that resolves to a valid anonymous cookie re-sends the same value with a fresh Max-Age (best-effort where next/headers refuses writes). Page responses never renew, so pages stay cacheable, and a host principal never renews the anonymous cookie beside it. A response that clears the cookie (a claim, a retired owner) must not renew it, whatever order a route merged its headers in: withRequestOwner, and the owner-events retired answer, keep only the clearing value for a cookie a value clears. A renewal that lands after a claim cleared the cookie is pinned by a test: the next write with it, alone or beside the account, writes nothing, and its answer clears it again without renewing it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MRME5T4oEaQ4EBUzrDXMKz * docs(identity): 400-day renewed anonymous cookie; when to turn pre-minting off Hosts set OWNER_ANONYMOUS_PREMINT=false only when their registration sets anonymousFallback: false; hosts that keep anonymous visitors need pre-minting and skip it per request in middleware for requests their methods authenticate. The CHANGELOG notes the new lifetime and renewal, and that a lost cookie means a new owner. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MRME5T4oEaQ4EBUzrDXMKz * fix(identity): forward owner cookies from routes that resolve the owner directly The Pi chat and whiteboard-visibility routes and asset-id document extraction resolved the owner themselves and never sent its Set-Cookie values back, so an anonymous identity used only through them was never renewed (and a minted one never stored). attachOwnerCookies attaches a resolution's cookies to a response a route built itself, with the clear-wins rule, before a stream's body starts. Both Pi routes answer through it, success and error alike; resolveServerAsset hands the cookies back with every answer and the extraction route attaches them. A guard test scans for every module outside the identity seam that calls the owner resolution (or the asset helper) directly and requires it to forward the cookies; the two callers that cannot are listed with the reason. Behaviour tests cover the three paths. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MRME5T4oEaQ4EBUzrDXMKz --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: wyuc <dhq1204@yahoo.com> | 1 个月前 | |
release: OpenMAIC 1.2.0-rc.1 (server-first) (#1794) * ci: run CI for the provider-config integration branch Development of the provider configuration RFC (#1701) lands on integration/provider-config; build it on push and run CI for pull requests into it, as with earlier integration branches. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR * feat(config): capability slot registry and stage-to-slot mapping (#1726) * feat(config): capability slot registry and stage-to-slot mapping First P0 step of the provider configuration RFC (#1701, tracked in #1725): define the capability slot forest and map every LLM stage key to exactly one slot. Pure data with no callers yet, so there is no behavior change. Requirements are checked on the slot that declares them and are not passed down to child slots, so agent.title can use a model without tool calling even though agent requires it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR * test(config): pin every stage destination and the capability roots Review found that the station and root assertions derived their expectations from the module under test: re-pointing a single-stage station or dropping a capability root still passed. The tests now compare against an independently written stage table and root list, and pin agent.title as config-only. Also document that browserless outlines still run on the generate-classroom model. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR * test(config): pin slotForStage and full slot lineages Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> * feat(config): provider presets and the openmaic.yml schema (#1727) * feat(config): provider presets and the openmaic.yml schema Second P0 step of the provider configuration RFC (#1701, tracked in #1725). - lib/config/provider-presets.ts: one preset per built-in registry entry, plus the token plans as multi-capability presets whose stage recommendations become slot recommendations. Registry ids that collide across capabilities get explicit preset ids. - lib/server/model-config/openmaic-yml.ts: parse and validate the operator's openmaic.yml (or the file named by OPENMAIC_CONFIG): ${VAR} interpolation, strict schema, and cross-checks that every assignment names a declared provider whose preset offers the slot's capability. Every problem is reported with its path. - instrumentation.ts: an invalid file refuses to start. Without a file nothing changes, and nothing resolves models through the file yet. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR * fix(config): harden openmaic.yml validation after review - Look providers up by own key only, so "constructor:m" is not taken for a declared provider. - Refuse non-mapping objects YAML produces (an unquoted timestamp becomes a Date that the schema would accept as an empty object), and refuse a YAML alias that refers back to itself instead of overflowing the stack. - Accept lowercase variable names; refuse a "${" with no closing brace, checked on the text as written, never on substituted secrets. - Cross-check the entries that are valid on their own even when the schema rejects others, so every problem is reported at once without duplicate errors for declared-but-invalid providers. - Name the three valid assignment shapes when a value has none of them. - Test the boot path: an invalid file exits with code 1 before any schedule starts; a valid one boots. - Ignore a root openmaic.yml in git. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR * fix(config): keep secrets out of openmaic.yml diagnostics Review round 2: - Read only variables the environment itself has, as non-empty strings: ${constructor} or an inherited value is not a variable. - Never print a value that came from ${VAR}, and report YAML syntax errors by reason and position without js-yaml's source excerpt, which can quote a literal key. - Keep the base URL requirement on presets: SearXNG from its registry entry, and Azure OpenAI and self-hosted MinerU, which have no usable default endpoint. - Check slot names and valid model references even inside an entry that fails the schema, and report a failed placeholder once rather than again as an empty value. - Boot test for the no-file case. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR * refactor(config): validate openmaic.yml in two phases Rounds 1-3 of review kept finding edge cases in one feature: running the cross-checks on the half-valid parts of a file the schema had rejected, so that every problem showed at once. It hid problems inside a rejected entry, lost a __proto__ slot key, and built misleading provider errors from failed placeholders. That feature is gone. Validation now runs in two phases. Phase 1 is the document itself: placeholders, key names (checked on the document as written, since the schema drops a __proto__ key), and the schema. Any problem there stops before phase 2, the references between entries, which therefore only ever sees a fully valid file. Each phase reports all of its problems, one per path. YAML syntax errors now report only their position: js-yaml's reason can quote source text too (an alias or tag name), not just its excerpt. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> * feat(api)!: generate-classroom takes requirement + uploaded materials; capabilities follow server config (#1728) Narrows POST /api/generate-classroom to { requirement, materialIds? }; capabilities follow server configuration; materials go through the owner material library (upload, reference by id, delete); new GET /api/generate-classroom/capabilities; skill docs updated. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat(config): resolve a slot through the configuration layers (#1729) * feat(config): resolve a slot through the configuration layers Third P0 step of the provider configuration RFC (#1701, tracked in #1725). resolveSlot walks from a slot to its capability root and returns the first assignment it meets, consulting the deployment layer (openmaic.yml, locked) before the workspace layer at every node. An explicit null disables the subtree; nothing assigned up to the root is unassigned, with no fallback to any vendor. The result carries the provider, preset, registry entry, effective base URL, key, model, call options, fallback, where it was resolved and whether it is locked, and the requested slot's own requirements checked against the model catalogue (met, unmet or unknown). Pure and uncalled for now, so there is no behavior change. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR * fix(config): lock only written slots and trust the catalogue only where it applies Review round 1: - locked now means the requested slot itself is written in the deployment layer. Inheriting a deployment value does not lock a slot, since the workspace may still assign it (source and resolvedAt still report where the value came from). - A custom OpenAI-compatible endpoint borrows the OpenAI registry for transport only, so its preset no longer trusts that model catalogue: requirements there resolve to unknown. - The fallback is checked against the slot's requirements too, and the result exposes fallbackRequirements so retries can refuse it. - A malformed reference fails with a path-qualified SlotResolutionError that does not echo the value, which is often a misplaced key. - Requirement tests use named catalogue models instead of picking one from the catalogue at run time. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR * fix(config): order layers deployment-first and keep references out of errors Review round 2: - resolveSlot no longer depends on the order its caller passes the layers in: deployment layers always come first, for assignments and provider lookup alike. - Resolution errors name the path and the preset, never a value taken from the reference, so a key pasted into the provider position does not end up in a log. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR * test(config): cover catalogue aliases and fallback capability errors Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> * feat(classroom)!: save server-generated classrooms through server persistence (#1730) Server-side classroom generation saves the finished course create-only into the request owner's library with media in the owner's asset pool; jobs move to PostgreSQL with owner-scoped polling; /api/classroom is removed; legacy data/classrooms files are imported once in the background. Adds @openmaic/storage 0.36.0 PgAssetStore.releasePending. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat(config): translate the legacy configuration into a deployment layer (#1731) * feat(config): translate the legacy configuration into a deployment layer Deployments without openmaic.yml keep working: provider variables, server-providers.yml, DEFAULT_MODEL, MODEL_ROUTES and MODEL_FALLBACK are translated into the openmaic.yml shape and serve as the deployment layer for resolveSlot. - Providers: each configured entry becomes a provider under its preset id; force-disabled entries are left out; AliDocMind's key pair goes into credentials. - DEFAULT_MODEL goes on the llm root. Every other slot that serves stages gets what those stages used before (their route, or else the default model), written only where inheritance would give something else, so an unrouted stage under a routed parent stays on the default model. - Stages that now share a slot but had different routes, models whose provider has no server configuration, and stages that used the browser's model but would now inherit a server one become startup notices, never failures. - MODEL_FALLBACK attaches to every chat assignment without its own. - When openmaic.yml exists it wins, with a notice if legacy variables are also set. Nothing reads the layer yet; routes switch over in P1 (#1725). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR * fix(config): follow the agent driver's rules and keep notices value-free Review round 1: - The agent never used DEFAULT_MODEL: it needs a valid maic-agent-driver route and is off otherwise, so the translation writes agent: null where it would inherit a server model (and a notice for a route that does not work today). Unrouted conversation titles reuse the driver's model with thinking off, as the title generator does. - Notices name registry ids only; a credential pasted into a model variable is never repeated. - Provider entries and route options are checked against the new schema and left out with a notice (field names only) instead of producing a file that would not parse; driver-only options are dropped elsewhere. - A route with its own fallback never falls back to MODEL_FALLBACK, even when that fallback cannot carry over; the retry model is part of the comparison with the inherited value. - Force-off switches without a configured entry are reported, a legacy configuration made only of them is detected, and translating one adds a deprecation notice. - loadDeploymentLayer reads the process environment and working directory like the legacy loaders, instead of taking parameters it could only partly honor. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR * fix(config): compare routes by behavior, validate references, name only known ids Review round 2: - Stages sharing a slot are compared by what the route changes for them (model, thinking, fallback), with bare ids read as openai models and a route to DEFAULT_MODEL counted as no route; api and contextWindow are inert outside the driver. - A model reference carries over only when it names a provider from the providers section and forms a valid reference. - Section keys and force-off ids are named in notices only when they are registry ids with a preset. - Retries now follow the slot; where a call site picked its retry model by another label (scene types under scene-content, browserless generation), the change is reported. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR * fix(config): chat references need a chat entry; one notice for retries Review round 3: - A model reference carries over only when its provider was translated from the providers section, not merely declared by another section under the same id. - Routes are compared by the retry model that takes effect, so leaving out a fallback equals repeating MODEL_FALLBACK. - The per-call-site retry notices kept missing cases (streaming and calls outside server-managed routing never retry today). They are replaced by one notice, given whenever a retry model is configured, that retries now follow the slot. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> * refactor(config)!: translate only providers and the default model (#1732) * refactor(config)!: translate only providers and the default model MODEL_ROUTES does not map one to one onto slots: several stages share a slot, the agent driver and conversation titles have rules of their own, and retries are picked by call-site labels. Emulating that took most of the translation and still ended in notices that are easy to miss. The translation now carries over only the unambiguous part: providers, DEFAULT_MODEL as the llm root and MODEL_FALLBACK as its fallback (the agent stays null, as it never used DEFAULT_MODEL). A deployment that sets MODEL_ROUTES without openmaic.yml gets LegacyRoutesError asking it to write the per-stage models as slots. loadDeploymentLayer is not wired into startup yet; that happens when routes switch to resolveSlot (P1). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR * fix(config): say what a dropped MODEL_FALLBACK loses Without DEFAULT_MODEL there is no assignment to hold MODEL_FALLBACK, and calls that retried on it (server providers picked in the browser) stop retrying; the notice says so. thinkingSchema is module-private again. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> * feat(persistence): workspace model configuration with keys encrypted at rest (#1733) * feat(persistence): workspace model configuration with keys encrypted at rest The store behind the web settings of RFC #1701 (#1725, P1): one row per workspace (owner) in workspace_model_config, the same shape as openmaic.yml without policy. - Provider secrets (apiKey, credentials) are sealed per provider with AES-256-GCM under OPENMAIC_SECRET_KEY, bound to the provider id, and never stored in the config column. Without the variable, a secret is created once in data/instance-secret.key (the Docker volume). - A secret sealed under another instance secret is reported as unreadable ("enter again"), and a save that brings no new secret for that provider keeps it, so a misconfigured secret cannot destroy keys. - Saves replace the document with a compare-and-swap on the revision, after the owner's identity lock; two concurrent first saves cannot both land (PostgreSQL contract test). - A claim moves the anonymous configuration to the account unless the account has its own (core participant, order 900). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR * fix(persistence): publish the instance secret atomically; force the races in tests Review round 1: - The generated secret is written and flushed under a private name, then published with an exclusive link, so no process can read it half written and exactly one of two starting processes creates it. A file that is not a complete generated secret (empty, truncated) is refused instead of deriving a key anyone could compute. - The PostgreSQL test holds both first saves at their insert until both have read the missing row, and holds the row lock for the update case until both saves queue behind it (checked by backend pid, not by any lock waiter in the database). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR * fix(persistence): verify the written secret; require full GCM tags Review round 2: - The generated secret is written in full, read back and checked before it is published; the temporary file is removed on any failure. - Sealed values open only with a 12-byte IV and a full 16-byte tag (authTagLength), so a shortened tag cannot weaken authentication. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> * feat(config): resolve slots at request time over deployment, workspace and defaults (#1734) * feat(config): resolve slots at request time over deployment, workspace and defaults The runtime half of RFC #1701 resolution (#1725, P1): - deploymentConfig(): the deployment layer, loaded once per process. The legacy translation now splits into the deployment's providers and a separate default layer (DEFAULT_MODEL, MODEL_FALLBACK) that locks nothing and ranks below the workspace, as DEFAULT_MODEL ranked below the model a user picked. - workspaceLayer(owner) reads the web settings; requestWorkspaceId(req) names the request's owner (none for an owner minted by the request). - lookupSlot walks deployment and workspace over the whole tree first; the defaults are a second walk, so a default on a child never outranks a workspace choice higher up. - resolveStageModel builds the language model: the configured slot, else what the request still names the old way (deprecated), else the defaults, else a loud error; a slot turned off fails whatever the request names. A workspace provider's endpoint is checked like a caller-supplied one and gets the transport that refuses redirects. - resolveSlot gains the default source, and reports which layer declared the provider, its proxy and credentials. Nothing calls it yet; call sites switch next. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR * fix(config): keep request identity and workspace endpoints inside their bounds Review round 1: - A refused owner credential throws InvalidOwnerCredentialError (401) instead of resolving with the deployment's models and keys. - A retired request owner gets no workspace: canonicalizing it would hand an old anonymous cookie the claiming account's settings. Only background work, which names a stored owner, is forwarded. - A workspace provider may not be Amazon Bedrock (which falls back to the server's AWS credential chain) or set a proxy (which routes around the checked, redirect-refusing transport); the deployment still may. - Test seams for the deployment config and the workspace loader, and tests for identity, forwarding and caching. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR * fix(config): read only the named owner's settings; refuse unmet requirements Review round 2: - workspaceLayer reads exactly the owner it is given. Forwarding through a claim let a request whose owner was claimed between its check and the read see the account's settings; background work passes the owner it works for now instead. - A model the catalogue says does not meet the slot's requirement is refused before anything is built (SlotRequirementError), without consulting the request. - Tests: a database failure fails the call without falling back, a workspace provider without a key never gets the deployment's, and the transport each provider source gets. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR * test(identity): exempt the model settings lookup from cookie forwarding requestWorkspaceId resolves the owner only to pick whose model settings a generation call uses, and returns no response of its own, like the vision prompt helper already listed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> * feat(config)!: resolve every LLM call through its capability slot (#1735) * feat(config)!: resolve every LLM call through its capability slot The LLM call sites of RFC #1701 switch to slot resolution (#1725, P1): - resolveModel resolves a stage through its slot for the request's or job's workspace: the deployment and workspace configuration first; the model and key a request names (x-model, x-model-routes, body fields) only for a slot left unassigned, deprecated; then the defaults an older deployment set with DEFAULT_MODEL. Routes that read headers get this through resolveModelFromRequest; the chat routes pass their workspace; background work (agent runs, titles, generation jobs) passes the owner it works for now. - Retries follow the slot: a slot-resolved model carries its slot's fallback (lib/ai/model-fallbacks.ts), which callLLM and the outline stream use; only a model from the request path still retries on MODEL_FALLBACK. A fallback that cannot meet the slot is not used. - The agent driver resolves the agent slot: tool calling required, api defaults to openai-completions, thinking.effort still refused. Conversation titles resolve agent.title with thinking off unless the title slot sets it. - /api/generate-classroom resolves each step through its slot for the job's owner, its outline through course.outline. - MODEL_ROUTES is gone: loadDeploymentLayer runs at startup and a server that still sets it without openmaic.yml refuses to start. The pbl-chat and maic-agent stage keys, which nothing resolved, are removed. BREAKING CHANGE: MODEL_ROUTES is no longer read; write per-stage models as slots in openmaic.yml. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR * fix(config): a slot fallback needs no serverManaged stamp; follow claims per stage Review round 1: - callLLM arms a model's attached slot fallback whether or not the caller passes serverManaged (the PBL agents pass none); the stamp still gates MODEL_FALLBACK on the request path. - /api/generate-classroom resolves the owner the job works for now at each stage, so stages after a claim follow the moved settings. Streaming calls (agent driver, classroom chat, PBL streams) still do not retry on a fallback model, as before this change; that is the #1725 item on retries at every call site. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> * feat(config): resolve media and tool capabilities through their slots (#1737) * feat(config): a provider-only reference names the provider's default model Search and document providers mostly have no model to pick, so a slot may name just the provider (`webSearch: tavily`); the target then has no modelId and the adapter uses its default. Chat slots still need providerId:modelId, checked in openmaic.yml and at resolution. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR * feat(config): resolve media and tool capabilities through their slots The capability routes and server paths of RFC #1701 (#1725, P1): text to speech, speech recognition, images, video, web search and document extraction resolve through their slots, like language models. - lib/server/model-config/media.ts: the configured slot (deployment, then workspace); else the provider a request names the old way (deprecated); else the legacy defaults; else a loud error. A slot turned off fails whatever the request names, and while the legacy <CAP>_<VENDOR>_ENABLED=false switches are in effect a switched-off provider stays off whoever assigns it. A workspace endpoint is checked like a caller-supplied one and may not use a proxy. - The legacy translation assigns the media roots the provider the server picked when a request named none (first configured; web search by its old priority; DEFAULT_IMAGE_PROVIDER for images), with the first pinned model. - Routes: /api/generate/{image,video,tts,voice}, /api/transcription, /api/web-search, /api/parse-pdf and /api/extract-document. A TTS voice applies only to the provider it was chosen for; voices register on the tts slot's provider. - Server paths: agent image and video generation, scene narration, the voice catalog and registration, web search and fetch_url, material extraction (the document slot's service, the asr slot for local transcription), browserless classroom media, and the generation capabilities reported by /api/health and the classroom capabilities endpoint. - A provider-only reference (`webSearch: tavily`) names the provider's default model; chat slots still need providerId:modelId. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR * fix(config): keep slots authoritative on every media path - Workspace providers reach media, search and document services only at their preset's endpoints; a custom endpoint or proxy is deployment-only (403 INVALID_URL). - A configured or turned-off document slot decides the extraction service: deprecated request fields may only pick a self-contained extractor, and legacy operator credentials are no longer reached. - Request paths resolve their own workspace without following a claim; background extraction still does. - A configured TTS slot keeps its own model; legacy pins apply only on the deprecated and default paths. - Local transcription and agent voice registration keep the connection's network policy flags. - The agent's web search forwards the whole resolved configuration. - An unusable DEFAULT_IMAGE_PROVIDER leaves the image slot unassigned with a notice instead of switching vendors. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR * fix(config): public presets only for workspaces, plan default models - A workspace preset whose default endpoint is on the server's own network (self-hosted TTS, ASR, image) is deployment-only, and a workspace provider's endpoint runs under the public-only policy. - A provider-only reference to a token plan means the plan's own default model for that capability. - On the legacy default provider, the request's image, video and ASR model still applies through its allowlist, and image/video without a model is still MISSING_MODEL. - An asr assignment a workspace may not use no longer blocks document extraction, and a refused document service answers 403. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR * fix(config): keep legacy search and voice choices on their old paths - Classroom search honours the provider and key a request names while the webSearch slot is unassigned; only /api/web-search prefers the operator's configured backend, as before. - A TTS request's voice applies unless the request chose it for another provider. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR * fix(config): legacy search model and capability discovery errors - On the legacy default search provider, the request's search model still applies through the server's pins. - Capability discovery answers a refused credential (401) or a workspace service it may not use (403) instead of 500. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR * fix(config): classroom submission refuses a workspace service with 403 A classroom submission whose materials would go to a document or speech service the workspace may not use answers 403 INVALID_URL instead of 500, and its material check resolves the request's own workspace without following a claim. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR * fix(config): workspace image and video providers run public-only A workspace-configured image or video provider now uses the strict public transport for its requests and for the redirects its clip download follows, whatever ALLOW_LOCAL_NETWORKS says; the operator's own providers and the deprecated per-request path keep their policies. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR * Revert "fix(config): workspace image and video providers run public-only" This reverts commit 68021ded. A workspace provider cannot choose an endpoint for media, search or document services at all: a custom base URL or proxy is refused, and so is a preset whose default endpoint is on the server's own network. What remains is a preset's fixed public endpoint, the same one a deployment default uses, so these calls keep the operator's transport policy like every other preset endpoint, and the connection no longer claims a user-typed endpoint (userEndpoint is false). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR * fix(config): /api/web-search without a provider searches as before A deprecated web search request that names no provider again means the server's configured provider, else the default one with the request's key, while the webSearch slot is unassigned. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR * fix(config): browser speech recognition does not count for extraction An asr slot assigned to speech recognition that runs in the browser gives server-side extraction no transcription service, so audio uploads are not advertised or accepted for extraction. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR * fix(config): /api/parse-pdf honours an explicit local parser A request that asks for a self-contained extractor (local parsing) parses the PDF locally and sends it to no document service, whatever the document slot names, as /api/extract-document already did. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR * test(classroom): give the persistence contract its media slots The generated-classroom PostgreSQL contract configured its image and TTS providers through the legacy provider mocks, which slot resolution no longer reads; it now sets the deployment's slots. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> * feat(llm): streaming calls fall back on their slot's fallback (#1739) * feat(llm): streaming calls fall back on their slot's fallback A stream resolved through a slot that fails before any content (the request is refused, or the first part is a retryable error) runs once on the slot's fallback model. A failure after content has started still reaches the caller. The outline stream keeps its own retry and fallback handling. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR * fix(llm): the stream fallback is the call's last attempt, before any content - The primary's own SDK retries run first; the fallback runs once, as the last attempt, and a failing fallback is not retried. - No fallback once content reached the caller in any step (a tool that ran must not run again); after a fallback took over, later steps stay on it. - The fallback gets the thinking options built for its own provider and model, not the primary's. - Usage of a stream with a slot fallback is recorded per step, against the model that served the step. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR * fix(llm): stream fallback recognises transient error payloads Providers send a stream's error part as a plain { type, message } payload rather than an error; a transient type (overloaded, rate limited, server error) now counts as retryable for the slot fallback, while authentication and request errors still do not. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> * feat(config): model settings API for workspaces (#1740) * feat(config): model settings API for workspaces GET/PUT /api/model-config read and edit a workspace's slots and providers: every slot with its own assignment, effective model, source and lock state; deployment providers read-only and without credential details; workspace keys write-only and masked. Edits are checked against the whole configuration and a revision. The view carries the preset catalogue a workspace may add providers from, and each provider's models per capability, so the settings UI needs no registry code of its own. Workspaces cannot add Bedrock, self-hosted media/search/document presets or custom endpoints for anything but chat. POST /api/model-config/import merges settings a browser kept, item by item under the same checks, never replacing what the workspace or deployment already has. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR * refactor(config): preset ids as client-safe data The preset id tables move to lib/config/preset-ids.ts, which imports no registry, so browser code (the settings import) can name presets. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR * fix(config): tighten the settings API's views and checks - Effective targets name an endpoint only for the workspace's own providers; a deployment's endpoints never reach a response. - A workspace provider of a local model server preset (whose default endpoint is the server's own network) must name its own endpoint. - Every change is checked against the stored shape, so an import skips a malformed item instead of failing the batch, and PUT validates its whole body (400, never 500). - Clearing a key deletes it even when the instance can no longer open it. - A workspace provider with its own endpoint offers chat models only, and an OpenAI-compatible provider offers only the models it lists. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR * fix(config): no credentials in endpoints; key-pair presets stay in the yml - A workspace base URL cannot carry a username or password (they would be stored and shown in the clear), and views never show credentials a stored endpoint carries. - Presets that authenticate with a key pair (AliDocMind) are not offered to workspaces: the settings take one key per provider. - An import checks each proposed provider on its own, so one malformed provider is skipped instead of failing the batch. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR * fix(config): settings neither offer nor accept switched-off providers A provider the operator switched off for a capability (the legacy <CAP>_<VENDOR>_ENABLED=false switches) is left out of the capability lists, refused as an assignment, and shown as invalid where a deployment assigns it, as the calls themselves refuse it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR * fix(config): recommendations name only capabilities a preset still offers Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR * fix(config): a provider id follows the reference grammar before it is used Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR * fix(config): the catalogue lists the models a search provider offers Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> * fix(config): refuse a fallback on a slot whose calls never use one (#1743) * fix(config): refuse a fallback on a slot whose calls never use one Only language-model calls retry on a slot's fallback; a fallback on a speech, image, video, search or document slot was accepted and silently did nothing. Resolution now refuses it by path, so openmaic.yml fails at startup and the model settings refuse it on save. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR * fix(config): openmaic.yml refuses a non-chat fallback at startup The boot check parses openmaic.yml without resolving slots, so the refusal also lives in the file's cross-checks. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> * feat(settings): Models section with the course model map (#1741) * feat(settings): client for the workspace model settings A small client for /api/model-config: it caches the view per page, applies changes against the revision it read, reloads on a stale revision (409 CONFLICT) or a slot the deployment has since locked, and treats a server without persistence (404) as settings managed by the server. Pure helpers behind the settings UI live beside it: slot changes from the card picker (follow, off, a model, a fallback, the media switches), the provider form's change (keys stay write-only: keep, replace or remove), the first-run setup that adds a provider and fills only the empty, unlocked slots with its preset's recommendations, and the layout of the course model map. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR * feat(settings): Models section with the course model map A new "Models" section, first in the settings dialog and the one it opens on. It reads and writes everything through /api/model-config: - The model map: a pannable, zoomable canvas with the course pipeline as a two-row serpentine flow, the default language model above the stations that inherit from it, and the page types under the content station. Each card shows the effective model, where it comes from and a lock when the server sets it. Solid edges follow the parent, dashed ones mark a setting of the slot's own. - Editing happens in place: a picker at the card to follow the parent, pick a model a provider offers (or a provider, for search and document slots), turn the slot off, or set a fallback for chat slots. Media switches turn a slot off and back on. - While no language model is configured, the default model's card offers a first-run setup: pick a service, give its key, and its recommended models fill every empty slot. - A Providers tab lists the server's providers (read-only) and the workspace's own, which can be added, edited and removed as the server's policy allows. The generation toolbar's "configure provider" prompt opens this section. Below the sm breakpoint the settings nav becomes a strip above the panel. Strings are translated for all 12 locales. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR * fix(settings): keep what a change does not mean to touch - A provider edit carries only the fields the form shows: a hidden model list or endpoint is left out, so the server keeps it; a shown field that is emptied is removed. A pinned model list keeps its field on edit. - A key the server cannot read starts as "replace" (it can also be removed), so a key typed for it is sent instead of the broken one being kept. - Switching a media slot off remembers what it held; switching it back on restores that assignment (or nothing of its own), and when that is not known the caller asks instead of clearing the slot to the default. - The first-run assignments are checked against the provider as the server answered it: a provider with its own endpoint serves chat only, a model list the user gave wins over the recommended chat models, and `llm` is one of the provider's own chat models. The filling can be retried against a reloaded view, and a refusal keeps its reason. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR * fix(settings): recoverable first-run, root clear action, keyboard on the map - A first-run setup that added its provider but could not assign it (a stale revision, say) reports to the section, which keeps a notice with the provider and a way on (assign its models again, or add them under Providers) through any reload, instead of losing it with the form. - The picker of a root slot with a setting of its own offers to clear it, leaving the server's value or default. - A card's switch restores what the slot held; when that is unknown it opens the picker. - Keyboard focus on a card outside the view pans the map to it, and the arrow keys pan the map while it has focus. - The provider form offers only replace or remove for an unreadable key, and says that a provider with its own endpoint serves chat only. Component tests drive the provider form, the switches, the root picker, arrow-key panning and a first-run setup that meets a conflict. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR * fix(settings): keep picker row labels whole; one unreadable-key notice A long note no longer truncates the row's label in the slot picker, and the provider form stops repeating the unreadable-key warning the row already shows. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR * fix(settings): a lost answer to a write is an outcome, not a throw When the server takes a change but its answer cannot be read (a truncated body, a dropped connection), apply() no longer rejects: it reloads the view to reconcile a change that may have been saved and returns an `unconfirmed` outcome with the reloaded view. The first-run setup goes on when the reloaded view has the provider it added, counts a slot write whose answer was lost as done when the reload shows the default model set, and says so otherwise. Busy states in the provider form, provider removal, first-run setup and its notice are reset in `finally`. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR * fix(settings): keyboard roving in the slot picker The picker's rows are plain buttons (pressed for the current choice) in a group, not listbox options without the keyboard model those promise. The list is one Tab stop, the current choice or else the first row; ArrowUp and ArrowDown step, Home and End jump, Enter and Space choose. The picker opens on that row. The picker's and the card switches' busy states are reset in `finally`, and the component tests cover the lost-answer first-run paths and the picker keyboard. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR * fix(settings): a write lost in transport is unconfirmed, and first run can resume A write whose request fails in transport may still have been saved, like one whose answer is lost: apply() now reloads the settings and returns an `unconfirmed` outcome in both cases, with the reloaded view when that read worked. A first-run setup whose provider add cannot be confirmed (the reload failed too) keeps a notice that says so, and "Check again" reads the settings and either assigns the provider's models or says it was not added. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR * fix(settings): retry an unconfirmed provider add under the same id The add-provider form keeps the id it tried. When the answer was lost it closes if the reloaded settings show that provider, and otherwise retries under the same id, so a save that did land is updated rather than joined by a second provider; a genuinely new add still gets a fresh id. Component tests cover this and the first-run paths where the provider add fails in transport after the server saved it, or cannot be confirmed at all. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR * fix(settings): 5xx answers to writes are unconfirmed; reads never go back A server error or a gateway timeout can come after a write was saved, so a 5xx answer to a write is treated like a lost answer: the settings are read again and the outcome is `unconfirmed` with the reloaded view. A 4xx is still a refusal, without a reload. Reads and adopted write answers are numbered: a read's answer is dropped when a later read started or a write's answer was adopted after it began, or when its revision is older than the view held. The reads that must see a write just made (after an ambiguous write, a conflict, "Check again") start a fresh request instead of joining one begun before the write. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR * fix(settings): first run is done only once the default model is set A slot write that succeeds without setting `llm` (another session turned it off meanwhile, so only media slots were filled) no longer counts as a done setup: the notice stays and says the default model is still missing, for the user to pick on its card. Test views now carry `llm` resolved the way the server answers it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR * fix(settings): the revision decides which settings view is newer A view with a lower revision than the one held never replaces it, whether it comes from a read or a write's answer, and one with a higher revision always does, whenever it arrives; the order in which reads and adoptions were numbered only breaks ties at an equal revision (and decides while no view is held, so a read begun before the view was forgotten does not bring it back). A write whose answer is older than a view read meanwhile returns the view that is current. `adopt` takes a view or null (forget it) and is part of the client. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR * fix(settings): send each change against the view it was worked out from apply() takes the view a change was computed from and sends that view's revision, rather than the revision of whatever view is held when the write goes out. A retry of the first-run assignments that waits for a reload in flight is therefore refused (409) when the reload shows the settings changed, instead of overwriting a slot set meanwhile; the reload then feeds the next attempt. The slot picker, the card switches, the provider form, provider removal and the first-run setup all pass the view they showed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR * fix(settings): a provider edit sends only what changed, and rebases on a conflict The provider form keeps the basis of an edit: the provider as the edit began and the view it came from. A save sends only the fields changed against that basis (a key-only edit sends the key), against the basis view's revision. When the provider changed elsewhere meanwhile (409), the edit moves onto the reloaded provider, keeping only what the user changed, and the form says the provider changed before it is saved again. A 409 now returns the reloaded view to work the change out again from. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR * fix(settings): a refused provider add keeps its draft and picks a free id Only an add whose outcome is unknown (a lost answer, a transport failure, a 5xx) is reconciled by looking for its id in the reloaded settings. A confirmed refusal (a 409, another tab having added the same preset under the same id meanwhile) saved nothing of ours: the form keeps the draft, shows the conflict, and the retry uses an id still free in the reloaded view. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> * refactor(settings)!: server-side model settings only; import browser settings once (#1744) * feat(settings): one-time import of browser model settings The settings store's migration to version 5 builds an import proposal from the provider state earlier builds kept in the browser (keys, custom endpoints, the chosen model, token plan enrollment, per-capability selections) and keeps it under its own localStorage key. Once the store has hydrated, the proposal is posted to /api/model-config/import: a 2xx answer removes it (and the keys) from the browser, 400 drops it, and anything else keeps it for a later load. Per-stage routes are not imported. Clear Local Cache keeps a proposal still waiting. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR * refactor(settings): stop sending provider data from the browser Every request now leaves model and provider choice to the server, which resolves it through the workspace's capability slots: no model, key or base URL headers (x-model, x-api-key, x-model-routes, x-image-*, x-video-*), no provider, key or thinking fields in chat, TTS, voice registration, transcription, web search or document extraction bodies. The server keeps accepting them until they are retired. What the client still needs to know is read from the /api/model-config view (lib/model-settings/capabilities.ts): whether a language model is set up, which provider the tts slot names (browser speech plays locally; voice lists follow that provider), whether speech input, image, video and web search are available, and the course model the toolbar shows or edits through the settings API. The scene concurrency comes from /api/health. The unused TTS config popover and getCurrent*Config helpers are removed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR * refactor(settings)!: remove the browser provider state and its settings UI The settings store keeps only the user's own preferences (playback, narration voice and speed, speech input language, outline review, agents, layout). Providers, keys, base URLs, the model choice, thinking settings, per-stage routes, token plan enrollment and seeds, the per-capability provider configs and selections and their on/off switches are gone; the version 5 migration sets them aside for the one-time import and drops them. The narration voice now records the provider it was picked for and applies while the tts slot names it. The Token Plan, Model Services and Course Model sections and their components are removed, with apply-token-plan, the server provider sync (ServerProvidersInit, fetchServerProviders) and helpers only they used. A Voice section keeps the per-user parts of the old speech settings: speed, a narration test, the VoxCPM and Qwen voices, and the speech input language, showing which services the workspace uses. The home toolbar picks the course model through the settings API, or shows it read-only when the deployment locks it. e2e fixtures answer /api/model-config instead of seeding browser providers; the managed-provider spec, which covered the removed panel, is removed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR * feat(settings): browser speech recognition while the asr slot is unassigned Speech input worked out of the box through the browser's own recognition, which needs no server provider; it stays available while the asr slot is unassigned, and turning the slot off turns speech input off. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR * fix(settings): bind the model settings import to its owner and never lose staged keys The import now asks for the browser's legacy import binding and sends X-OpenMAIC-Legacy-Import, so owner resolution refuses it for any owner that does not hold the browser (409 LEGACY_IMPORT_NOT_BOUND keeps the proposal for a later load); the import route joins FENCED_ENDPOINTS. Its completion is the proposal's own key, apart from the course ledger. Staging reports whether it succeeded. When the proposal cannot be written (a full storage, an unreadable proposal waiting), the migration keeps the old settings in legacyModelSettings and every load retries, writing the store back without them once staged. Nothing logged quotes the proposal or an error message: fixed text, item ids, error names. Older shapes are normalised before the proposal is built (version 0 default model, the single TTS model setting, global TTS/ASR model ids, a TTS provider's model field, the flat web search key). Capabilities the user turned off are proposed as off (null), and selected server-configured media providers are named by their preset id. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR * fix(settings): retry a failed read of the model settings A failed read of /api/model-config with nothing to show is read again with a backoff (2 s up to a minute) until one succeeds, instead of leaving the page without capabilities for the rest of its life. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR * fix(settings): send no provider or model with voice registration Voice registration, deletion and auto-registration no longer send providerId or ttsModelId: the server registers with the provider and model the tts slot resolves to. The model still derives the voice id and keys the session memo. The unused OpenRouter model list hook, which called the vendor with a browser key, is removed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR * fix(settings): carry only an explicit speech input off into the import Only `asrEnabled: false` becomes an off slot (`asr: null`): without an asr slot the browser's own speech recognition would take over, which the user had turned off. The TTS, image, video and web search switches were per-browser toggles that availability following the slots replaces on purpose; turning them into lasting workspace nulls would override the deployment's defaults from then on, so they are not carried over. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR * fix(settings): gate chat and generation on the slots they resolve Chat and discussion resolve the classroom slot, and course generation its outline, actions and content slots; each is now allowed whenever those resolve to a model, even with the llm root unassigned or off (a child assigned on its own). Settings not read yet still block nothing. The toolbar offers "Set up model" only when a course cannot be generated. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR * fix(settings): import models the user added to built-in providers A built-in chat provider whose model list held models the user added (ids not in its catalogue) is proposed with those models, listed after the catalogue's: a provider's model list names the chat models it serves, so listing only the added ones would hide the catalogue. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR * fix(settings): wait for the commit explicitly in the media overlap test The overlap test counted 50 microtasks for the mocked commit to be reached; with the capability read before each pass that is no longer enough, so the test failed alone and left a pass running into the next test. It now awaits a signal from inside the commit, flushes every pending microtask before asserting, and releases and awaits both passes in `finally`. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR * fix(settings): import models the user added to an enrolled token plan An enrolled plan's chat provider whose model list held models the user added (ids not in the plan's own list) is proposed with them, listed after the plan's, by the same rule as built-in providers. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR * fix(settings): scope voice registration memos to the tts slot's provider The session memo and in-flight map of auto-voice registration were keyed on the voice and model only, so after the tts slot moved to another backend serving the same model, registration was skipped and synthesis named a voice that backend never registered. They are now scoped to the provider the slot resolves to (provider id, registry entry, endpoint) and the settings revision, captured once per registration. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR * fix(settings): show the view the model settings import produced After a successful import the page read the settings again, but that read could join the page's first read, still in flight from before the import, and leave the unassigned view in place. The import now hands over the view the import route answered, and adoptNewerView lets any read in flight finish before keeping whichever view is newer by revision, so an older answer cannot land over it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR * fix(settings): offer and resolve voices against the tts slot's model With the tts slot on a model that cannot speak some voices (an OpenAI slot on tts-1 and Marin or Cedar, which need gpt-4o-mini-tts), the picker still offered them and synthesis on the slot's model failed. The slot's model (its own, else the provider's default) is now the model voices are offered under and checked against: the voice lists hold only voices it can speak, a persisted voice it cannot speak falls back to a compatible default, and narrator and agent bindings to such a voice are not used. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR * fix(settings): drop translations only the removed provider panels used 208 keys that only the removed provider settings (model services, token plan, course model config, their dialogs and the TTS popover) used are removed from all 12 locales: API key and base URL fields, provider and model editors, connection tests, per-capability switches and the like. Each was checked to have no remaining reference, literal or through a key prefix built at run time. The TTS enablement locale test keeps the key still in use. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR * fix(config): provider options in openmaic.yml, passed to the TTS adapters A provider in openmaic.yml may carry `options`: non-secret, provider-specific settings (a map of names to strings, numbers or booleans; `${VAR}` interpolation applies). They reach the resolved slot target, the media connection and the settings view, and the server's TTS paths (the TTS and voice routes, scene narration, classroom media generation, the voice-clone tools) hand them to the adapter as its providerOptions: over a request's options for a configured slot, under them while the slot is unassigned (the deprecated path). Voice registration follows them too (a VoxCPM backend without runtime registration is refused). Workspace providers cannot set options: they are the deployment's. The legacy configuration had no equivalent. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR * fix(settings): read the VoxCPM backend from the tts slot's options The voice settings, the voice lists and auto-voice registration assumed the default VoxCPM backend once the browser no longer chose one. They now read the `backend` option of the provider the tts slot resolves to (shown in the settings view), and the registration memo is scoped to the provider's options as well. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR * fix(settings): label the preset choice when adding a provider Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR * fix(settings): media planning from the slots; stop when settings are unread The outline route decided whether to plan images and video from the client's x-image-/x-video-generation-enabled headers, and the client sent them from settings it might not have read: a course could be generated silently without media. The route now reads the workspace's image and video slots itself (an explicit `false` header still lets an API client opt out; `true` turns on nothing), and the client no longer sends them. When the model settings cannot be read even after another try, generation stops with a message instead of leaving out narration (the scene fails and generation pauses; the preview's first scene errors), and a media pass or retry stands down without marking anything as disabled. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR * fix(settings): pick the course model when none is set Without a default model the toolbar showed no picker, so the course model could only be set in Settings. It now offers the picker whenever the workspace may set the llm slot and chat models exist, with nothing selected until one is picked (which sets the slot; the server assigns nothing on its own). The legacy translation's notice for a model whose provider the server does not configure now says each workspace chooses its model in Settings → Models, or to configure the provider and set DEFAULT_MODEL. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR * fix(settings): import keyless search choices and the Claude search model A keyless search service (Brave) that the user selected with research switched on is proposed as a provider of its preset with the webSearch slot on it; untouched defaults and self-hosted services (SearXNG, whose endpoint only the deployment may set) are not. The model picked for Claude web search is carried as `claude:<model>` instead of being dropped. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR * fix(config): refuse credential-like provider option names A provider's `options` are shown in the settings view, so a name that matches key, secret, token or password is refused with a pointer to apiKey and credentials. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR * fix(settings): pin which course slots the generation gate needs courseGenerationUsable requires the outline, actions and a content slot. The agents and research slots are not required, and a test pins it: with course.agents off the roster falls back to the preset agents, and with course.research off web search runs on the raw requirement, so neither request is refused by the server. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR * docs(settings): what the import leaves behind; extraction is a slot The importer README lists what is not carried over and where it is set now: per-stage routes, the per-browser media and research switches, Baidu sub-sources, thinking settings and the VoxCPM backend (options in openmaic.yml). The document extraction README no longer documents the removed store fields and request-level provider fields: extraction is the workspace's document slot. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR * fix(settings): release the recorder lock when a start is refused startRecording returned early without clearing its lock when speech input was not set up (the asr slot off, or still unknown while the settings loaded) or the browser lacked speech recognition, so every later click did nothing until the component remounted. Each early return now releases the lock. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR * fix(settings): decide research from a successful settings read The home page saved `webSearch: undefined` into the generation session when the model settings could not be read, and the preview researched only on that saved flag, so a transient failure skipped research for the whole generation. Saving the session now needs a successful read (read again after a failure, else it stops with the "settings could not be read" message), and starting or resuming generation decides research again from the current webSearch slot. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR * test(settings): type the research decision session Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> * fix(config): a policy without workspace providers also stops using them (#1750) * fix(config): a policy without workspace providers also stops using them `policy.allowWorkspaceProviders: false` only stopped workspaces from adding or editing providers; ones added before kept serving their assignments. Resolution now leaves them out, with the workspace assignments that name them (an assignment whose fallback alone names one keeps its model), so the policy takes effect for existing workspaces too and the settings view shows what the calls use. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR * fix(config): policy keeps shared provider ids and refuses new references - A workspace provider id the deployment also declares resolves to the deployment's provider, so its references are kept under the policy. - The model settings refuse a new assignment to a workspace provider the policy no longer allows, while other edits leave dormant ones as they are. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR * fix(config): settings validate a workspace as the policy lets the calls see it Assignments the policy leaves dormant are not checked on unrelated edits. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> * docs(config): openmaic.yml, capability slots and the model settings (#1742) * docs(config): document openmaic.yml, slots and the model settings The configuration docs are rewritten around openmaic.yml: providers, the slot tree and its inheritance, assignment forms, provider-only references, fallbacks, turning a capability off, locks and the workspace model settings, policy, OPENMAIC_SECRET_KEY, what workspaces may add, the deprecated request fields, and migration from provider variables, server-providers.yml, DEFAULT_MODEL/MODEL_FALLBACK and MODEL_ROUTES (with the stage-to-slot table). The environment-variable reference stays as the legacy configuration. Deployment, supported models (a preset catalogue), getting started and VoxCPM2 point to openmaic.yml; the READMEs' quick configuration and the agent runtime example use it; the openmaic skill tells agents to configure models through openmaic.yml or the model settings. openmaic.example.yml is a starting point, and a test parses it and every documented openmaic.yml example with the real schema. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR * docs(config): translate the openmaic.yml documentation Brings the zh-CN, zh-TW, Japanese, Russian and Arabic pages in line with the English configuration, deployment, supported models, getting started and VoxCPM2 pages: the same sections, examples and tables, with the legacy environment-variable reference kept as before under its own heading. Also fixes the zh-CN VoxCPM2 link to the TTS section, which pointed at the English anchor. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR * docs(config): match the settings UI names, set a model in the quick start - Configuration: the settings tabs are "Model map" and "Providers", the lock reads "Set by the server", and the first-run setup is "Connect a service". Browser import: keys are removed from the browser once imported (a failed import keeps them), and speech input that was switched off becomes asr: null. - Getting started: the browser no longer sends a model, so the quick start sets one, with a minimal openmaic.yml or DEFAULT_MODEL next to the key, and names Connect a service in Settings > Models as the path without a file. - openmaic.yml is in .dockerignore so a file with inline keys is never baked into an image; the deployment page says to mount it at run time. All six locales are updated where the text changed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR * docs(config): scope fallbacks, trim the example, translate volume repair - Fallbacks apply to language-model slots (llm and the slots below it); a fallback on a media, search or document slot is refused at startup or on save. - openmaic.example.yml is copied as-is by the READMEs, getting started and the skill, and startup refuses any unset ${VAR}: it now has one active provider (OPENAI_API_KEY) and the llm slot, with every other provider and slot as a commented optional block. The copy instructions say so. - The translated deployment pages gain the root-owned volume repair paragraph and its chown command. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR * docs(config): configure self-hosted and custom media endpoints in openmaic.yml Workspaces cannot add custom non-chat endpoints or self-hosted media presets, so the docs no longer send users to the browser settings for them: - Configuration (legacy reference): custom OpenAI-compatible TTS and ASR endpoints and ComfyUI are declared in openmaic.yml with their baseUrl and assigned to tts / asr / image; custom chat endpoints are an openai-compatible provider in openmaic.yml or the Providers tab. The claim that ASR configuration stays in client settings is gone. - VoxCPM2 (page and READMEs) is configured in openmaic.yml, with the legacy variable as the fallback; the per-browser Base URL option is removed. - MinerU in the READMEs is a document provider in openmaic.yml. - Deployment and ComfyUI: providers declared in openmaic.yml are server-managed and may use private endpoints without ALLOW_LOCAL_NETWORKS, which only matters for endpoints a user types (chat base URLs in the model settings, deprecated request fields). All six locales are updated. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR * docs(config): document provider options and the VoxCPM2 backend option - Configuration: the provider fields table documents `options`: non-secret, provider-specific settings (string, number or boolean values, ${VAR} allowed), shown in the model settings so never a key, and deployment-only. - VoxCPM2 (page and READMEs): the backend is chosen with options.backend on the provider in openmaic.yml (vllm-omni by default, python-api, nano-vllm), not in the browser settings; voice registration works only on vllm-omni, and the troubleshooting row points at options.backend. - The example test skips openmaic.yml blocks that set provider options until the schema on this branch accepts them (marked TODO). All six locales are updated. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR * test(docs): check the VoxCPM2 examples now that provider options exist Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR * docs(config): describe the browser import, Voice settings and web search as released - Browser import: the proposal and its keys are removed after any 2xx (even with skipped items, which are kept nowhere), and after a 400 or an unreadable proposal; it stays for another attempt only on 401, 404, 409, 5xx or a network error. Lists what has to be set up again by hand (per-stage models, thinking settings, custom speech and transcription providers, AliDocMind's key pair, the VoxCPM backend, Baidu sub-sources) and that the per-browser capability switches do not carry over. - VoxCPM2 voices (page and READMEs): assign VoxCPM to tts, then Settings > Voice > VoxCPM voices, which appears only when tts resolves to voxcpm-tts. - Web search has no per-generation switch: it runs when the workspace's webSearch slot resolves, and turning it off affects later generations. - The READMEs' persistence section no longer says keys go in .env.local only. All six locales are updated. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR * docs(config): startup errors name the field, or the line for broken YAML Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> * ci: run CI for the server-first integration branch Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * ci: give the lint, typecheck and unit test job 25 minutes The job's runtime varies between about 9 and 14 minutes, and a pull request run was cancelled at the 15-minute limit after its tests had passed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * refactor(generation): extract the classic generation steps into shared server functions (#1756) Moves the classic generation steps from the API routes into lib/server/generation/steps (no behaviour change; routes are thin wrappers). Video steps report the submitted provider task with its effective endpoint and refuse to resume on a different connection. Route characterization tests pin refusals and the outline SSE stream. Part of #1754 / #1755 (E1a). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat(storage): versioned SQL migrations per store (#1757) Every PostgreSQL store declares ordered, versioned migrations recorded in openmaic_schema_migrations; v1 baselines equal earlier releases' DDL; one-time steps run once; startup refuses a database upgraded by a newer release. @openmaic/storage 0.37.0. Part of #1754 / #1755 (M; #1658 phase 7). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat(agents): agent registry on the server — built-in agents in code, custom agents in the database (#1758) Built-in agents stay in code; custom agents move from localStorage to an owner-scoped owner_agents table with an API, a server resolver (resolveAgentsForOwner) and a one-time, lock-protected legacy import. Part of #1754 / #1755 (G). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat(generation): server-side generation runs (E1b) (#1759) * feat(generation): server-side generation runs with checkpoints and takeover A run is an owner-scoped PostgreSQL record that executes the classic pipeline with the shared step functions, in the browser's order and with the context the browser threads through them, checkpointing after every step. - Schema (store generation-runs): runs with their input, state, outline and revision, agents, course id, progress, lease and takeover counters; per-step checkpoints; an ordered event log (seq per run); idempotent commands. - Engine: preparing -> outlining -> awaiting_outline_confirmation -> generating -> completed, plus paused (a step failed after its retries) and ended (the course was deleted). Every commit is fenced by the lease generation; the course document is created with the first scene, scenes are appended as they complete and generationComplete is set at the end. - Runner: a lease-coordinated worker in every process, independent of the agent runtime flag, with heartbeats, takeover of orphaned runs and a cap on repeated takeovers of one step. - API: start (per-owner active-run limit), snapshot, event stream with replay after a seq, active runs, the owner's run stream, confirm-outline and retry, all owner-scoped. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(generation): run review round 1 (deletion, discard, atomic writes, parity, streams) - Deleting a course ends its run in every state, in the deletion's own transaction; a run without a course can be discarded with DELETE /api/generation-runs/:id. Runs waiting for outline confirmation no longer count toward the per-owner limit. - The start body and an edited outline are validated against the outline schema with size, count and order limits, under a body byte cap. - Narration uses the browser's shared voice logic: the teacher's voice options (VoxCPM prompt) and the single retry after a missing Qwen clone. - Scene appends and completion commit the document change, the checkpoint and the events in one transaction fenced by the lease; completion sets only the completion flags. - A takeover-capped pause keeps a resumable state; the runner hands back a claim of a run it still executes and stops claiming in that scan; the automatic outline confirmation happens in the outline's commit; prewarmed content is aborted before a pause; narration allocations are fenced and released when their attempt fails. - Background reads follow every claim of the run's owner; run streams have backpressure, a bounded queue and a per-owner cap; finished runs keep only their final events. - Parallel mode marks a failed scene and pauses after the others; auto agent generation falls back to the learner's selected presets; the agent resolver matches the registry's signature and error. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(generation): run review round 2 (limits, one narration step, outline normalizer, streams) - Confirming an outline takes the per-owner lock and respects the limit on runs in progress (429 ACTIVE_RUN_LIMIT); starts are capped at OPENMAIC_MAX_WAITING_RUNS_PER_OWNER runs waiting for confirmation. - Narration and the scene append are one step: the clips and the scene that names them commit together, and a failure retries both. - One outline normalizer for the outline step's output and edited outlines: nulls are absent, loose optional members are dropped, hard limits stay on scene count, size, ids, orders and type; the outline step stops at the scene cap. A generated outline always confirms unchanged. - A reader behind a finished run's compacted log gets a resync frame. - Run streams end on the request's abort, the queue cap is hard (an oversized frame goes only into an empty queue), and the owner stream moves its cursor only past frames it queued, in commit order. - Course writes use the owner after every claim (transitively), completion touches the stage row so its revision trigger fires, and auto agent generation falls back to the default presets when none were selected. - A claim handed back does not count as a takeover; stop waits for a scan in flight. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(generation): run review round 3 (scene types, byte units, retention, custom voices) - The outline normalizer no longer checks a scene's type against the known ones (a bounded string is enough): as in the browser, a scene of a type no content path supports fails at its content step and follows the parity rules (serial: pause; parallel: marked, skipped, pause at the end). - The outline step's read cap and the normalizer's size cap are both UTF-8 bytes, and the normalized result is measured too. Quiz configurations are kept only when complete and usable; question counts, media requests and orders are bounded. - Finished runs are no longer compacted at their final commit: a periodic sweep compacts them after a grace period (OPENMAIC_GENERATION_RUN_RETENTION_HOURS, default 24). The events stream sends `resync` on any gap after its cursor, on every page. - Custom preset agents keep their voice metadata in the agents checkpoint, so a custom teacher narrates with its own voice. - The engine's view of the run moves only after a commit resolved; the completed-scene count comes from the checkpoints; the per-owner limit is locked and counted under the canonical owner; a pull during an in-flight read is not lost. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(generation): run end-to-end findings (scene validation, deadlines, narration MIME) - The actions step validates the complete scene (content and actions) with the document's own scene validator before its checkpoint: an invalid scene (an action without a type) is an actions failure, regenerated by the step's retries, and pauses at actions when they are exhausted, so Retry regenerates the actions. Narration only ever sees validated scenes. - Every provider-calling step runs under its browser route's budget (outline 300 s, agent profiles 120 s, content 300 s, actions 60 s, a narration clip 30 s) through a signal combined with the run's; a timeout is retryable. The steps pass the signal to their provider calls, so a lost lease or a deleted course cancels the call in flight. Retries log and record their cause. - Narration clips are stored with their real media type (mp3 as audio/mpeg; opus as audio/ogg), so they are served inline. - A scene type without a content slot of its own resolves through course.content and reaches the content step's refusal. - No lease-lost warning for a run that gave its lease up; an edited outline is numbered by its position, as the browser's editor numbers it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(generation): narration webm as audio, and a quiet stop for an ended run - Narration clips map their format with an audio-specific type: webm is audio/webm (the shared classroom map types it as video), so a webm TTS response is stored instead of being refused until the run pauses. - A worker whose run was ended on purpose (its course deleted or discarded) stops with an info line, not a lease-lost warning. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> * fix(config): use the legacy document default for automatic extraction (#1760) With only the PDF_* provider variables (no openmaic.yml), the document slot resolves to the translated legacy default, but material analysis used the slot's service only for a configured slot, so a request that named no extractor fell back to unpdf instead of the configured MinerU. Use the slot's service for a legacy default too; a provider the request names still follows the legacy rules. The translated default also follows the order earlier releases picked in the browser: MinerU Cloud, then self-hosted MinerU, ahead of the rest of the configured services. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> * feat(generation): media lane, material images as course assets, read-only course while generating (E1c) (#1761) * feat(generation): media lane, material images as course assets, read-only course while generating (E1c) Runs now generate the media the outline asks for, as the browser's media pass does: once the course exists, alongside the scenes, one item at a time in outline order, only for the kinds whose slot resolves. The bytes go to the asset pool and the placeholders in the course are rewritten to the allocated ids (a scene not written yet gets them with its own write). Every item is checkpointed: a video records its provider task at submission and a takeover resumes the wait instead of submitting again; stored bytes are checkpointed in the allocation's transaction, so a takeover places them instead of paying for them again. A media failure leaves the placeholder and does not pause the run; `retry` takes an optional media element and regenerates only it, in a run that is generating, paused or completed (a paused or completed run is claimed for its media alone, through the new `media_pending` flag, migration 2). Material images are stored as course assets by the material analysis and the outline and scenes are generated with them by id, as the content step's server-backed transport expects; no image bytes are kept in checkpoints. A course is read-only to every other writer (classroom editor saves, the Pro agent's tools) until its run completes or ends: content writes through the owner-bound document store are refused with COURSE_GENERATING (409 on the stage routes); the run's own lease-fenced writes go through. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(generation): a media retry after completion keeps the course editable The read-only guard now applies only while the run itself is not completed or ended (its first media pass included). A Retry on a completed run no longer locks the course: its result is placed with a targeted read-modify-write of the current document (`mutateScene` of the owner-bound store, one transaction under the course's ownership row) that rewrites only the slots still holding the element's placeholder and keeps the author's other edits, and touches the stage row so an open editor reloads. When the author removed the element or its scene meanwhile, the result is dropped, its bytes are released and the item fails as MEDIA_ELEMENT_REMOVED, which Retry refuses. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(generation): media lane review fixes (slots, video tasks, leases, placement, asset lifetimes) - Media slots resolve per kind: turned off or unassigned skips the kind (a Retry generates it once the slot resolves), a refusal the routes map (INVALID_URL, INVALID_CREDENTIAL) fails its items with that code, and any other fault fails the media left, never the run. - A video keeps its provider task through every failure but the provider's own final answer (ProviderTaskFailedError, now raised by the polled-task helper and the adapters for a task that failed or finished without a result) or a changed connection; a Retry waits on that task instead of paying for another. The task record is written again in place when the write fails for a passing reason. - A step Retry no longer takes the lease from a worker generating a paused run's media; that worker goes on with the run when it is done. - Placement into a completed course changes only the matched media slots (no whole-scene sanitizing), marks the item done with the last scene written, and recognizes bytes already placed before an interruption. - Allocations a live run holds (material images, held media) are kept from expiring by the runner and released when the run ends; bytes that are gone anyway fail loud instead of being dropped. - generation-complete goes through the read-only guard (409); the run snapshot reads the run and its media in one statement; migration 2 indexes the runs producing a course by stage_id. - `generating` is checkpointed, stored bytes report done only once placed, posters are released with a failed item, stored bytes survive a placement fault, and the claims and asset calls follow the run's owner through claims. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(generation): media lane round 2 (video Retry submits anew, stored bytes are never lost) - A video's provider task is kept only to survive a takeover: once the item is recorded failed, for any reason, the task is dropped and a Retry submits a new one, as the browser's does. ProviderTaskFailedError and the adapter changes are reverted; the shared polled-task path is the base's again. - Stored bytes are done only once placed: a completed run whose placement failed keeps them stored with media_pending (stored counts as work in a completed run), and the next claim places them. At completion only bytes no generated scene holds are done without a placement. - Bytes found stored before an execution are checked before they are placed: missing bytes fail the item loud (with a Retry), a missing poster is left out. A placement fault at completion keeps the bytes stored instead of ending the execution. - The keep-alive locks the asset entries it extends in id order. - Migration 2's stage index also covers completed runs with media pending, so both the guard's and the deletion's lookups use it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(generation): stop re-placing stored media whose placement keeps failing Each stored item counts the placements that failed in a row. After three the item fails with MEDIA_PLACEMENT_FAILED (retryable), its bytes are released as every failed item's are, and media_pending clears, so a completed run is no longer claimed on every scan for a placement that cannot succeed. A Retry generates the item again. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test(generation): wait for the run's own state, not a one-second budget Three media tests waited for a run to reach a state (a video stored while a later scene is held, a second video call, the keep-alive) with vi.waitFor's default one-second budget, which a loaded full suite can exceed. They now wait for that condition within the test's own budget. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> * feat(api)!: headless generate-classroom on generation runs; retire the server pipeline (E2) (#1762) * feat(api)!: headless generate-classroom on generation runs; retire the server pipeline (E2) POST /api/generate-classroom now starts a generation run of the request owner (outlineReview auto, course-specific agents, no interactive or task-engine mode) and its poll reads that run in the existing job contract. The job id is the run id; a paused run reads as failed with the failed step and can be resumed through the run's retry command. Submissions without an outline model are refused up front, and the per-owner run limit answers 429 ACTIVE_RUN_LIMIT. The separate server pipeline (classroom-generation, its media and TTS passes, the in-process job runner and the classroom_generation_jobs store) is removed with its tests. The start checks of POST /api/generation-runs move into a shared startGenerationRun helper. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(api): headless review fixes: typed model refusals, paused runs outside the limit, run report - Model configuration refusals are typed (ModelConfigurationError: a missing key before the adapter is built, a refused endpoint, options only the deployment may set), and the headless submission checks every model a run needs (outline, scene content, actions) up front, answering 400 MISSING_MODEL / MISSING_API_KEY / INVALID_URL / MODEL_CONFIG_INVALID. - Paused runs no longer count toward OPENMAIC_MAX_ACTIVE_RUNS_PER_OWNER; a step Retry enforces the limit, as confirm-outline does (429). - The job view adds runState and retryable; a paused job's error says how to resume it. - Runs record speech clips their narration left silent, and compaction keeps a summary of the media checkpoints it removes (generation-runs migration 3), so the job warning stays stable after compaction. - Remove the dead generate-classroom LLM stage key and lib/server/scene-generation.ts; pin classroom-generation-jobs as a retired store name. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(config): name the configured provider in the missing-key refusal Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(api): check scene content per type, as the content step resolves it The headless preflight resolved the parent course.content slot, while a run resolves course.content.<type> (inheriting course.content, then llm). It now refuses only when no scene type resolves, naming the first type's refusal; a submission where only some types resolve is accepted, and a scene of such a type pauses the run at its content step. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> * docs(deploy)!: long-running Node only; Vercel up to 1.1.x; deployment docs and complete changelog (F) (#1764) * build!: drop vercel.json and the Vercel build switch OpenMAIC 1.2.0 needs a long-running Node.js process: generation runs execute in a worker inside the server process. Remove vercel.json and the VERCEL conditionals in next.config.ts, so every build produces the standalone output with the sharp-libvips native libraries. BREAKING CHANGE: Vercel and other serverless hosts are not supported; serverless deployment stays available on release/1.1.x. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs(deploy): long-running Node only, Vercel up to 1.1.x, upgrading from 1.1.x - README / README-zh: the Vercel section says serverless deployment is supported up to 1.1.x, and its Deploy button deploys release/1.1.x; the header badge is removed. - Deployment docs (all six locales): Docker Compose, and the image or pnpm start with your own PostgreSQL, as the supported paths; the required configuration; the generation run variables; an "Upgrading from 1.1.x" table. Getting started and configuration no longer offer Vercel. - .env.example documents the generation run variables. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs(changelog): model configuration, generation runs and deployment entries Add the missing Unreleased entries for server-side model configuration (capability slots, openmaic.yml, Settings > Models, MODEL_ROUTES, OPENMAIC_SECRET_KEY, the removed browser provider state and the browser settings import), the generate-classroom request contract, generation runs and their media lane, the Vercel removal and the browser storage seams, so every item of the 1.2.0 breaking changes table is listed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs(deploy): shared data directory for several instances; Compose upgrade cases - Deployment docs (all locales): instances that share a database also need one shared, persistent data directory, because uploaded material files, their extraction results, agent-edited course media, usage records and the generated instance secret are kept on local disk; the standalone docker run example mounts /app/data. .env.example says the same next to OPENMAIC_SECRET_KEY. - Upgrading with Compose: a .env.local with PERSISTENCE_SHARED_OWNER_ID needs OWNER_SINGLE_USER=false, and anonymous server libraries need an explicit claim into the single user. - CHANGELOG: the job record's inputSummary is removed (it was never part of the poll response); no advice for serverless hosts; only the session-scoped material reads require the agent runtime, upload and delete need only DATABASE_URL. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> * feat(generation): the browser starts and follows generation runs (E3) (#1768) * feat(generation): the browser starts and follows generation runs (E3) The composer uploads its materials to the owner's library and starts a server-side run; the generation preview renders the run's events (steps, outline streaming, outline review with the 2.5 s auto-continue, agent cards, pause and Retry) and sends it confirm-outline and retry; the classroom follows the run's scenes, media and pauses, with media Retry as the run's command and the course read-only until the run completes. Course cards appear from run start and follow the owner's run stream. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(generation): follow run streams only while there is something to follow The home page holds the owner stream only while a run is active (an idle list looks for runs now and then), a finished run's stream closes until a media Retry wakes it, the preview opened by the composer keeps the auto-continue beat even when the outline was ready before it attached, and an empty preset selection is taught by the default presets. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(generation): say so when a step Retry is over the active-run limit Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat(generation): say a paused run's failure the way the classic preview did A failed step records the error code the classic routes answered the same failure with (and the provider's HTTP status), on the paused snapshot and its step_failed event. The preview maps it to the same sentences as before, per step; the run's own message is the fallback for an unknown code. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat(generation): an empty preset selection is taught by the default presets `{ mode: "preset", agentIds: [] }` is accepted and the run resolves it to the default preset agents (what the learner's selection starts out as), so the browser sends its selection as it is. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat(generation): show the material analysis as the classic preview did The run names whether each material is a document or audio/video when its analysis starts (`material_kinds`) and what of the materials the outline does not see in full when it ends (`material_truncated`). The preview shows "Analyzing audio/video" for audio or video, drops the analysis step once the materials are analyzed, and shows the truncation warning again. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(persistence): a browser save keeps the outline's producer A full or structural save of a course writes the browser's outline (the plan and its completion) over what a producer recorded beside it, which dropped `producer`/`producerRef`: a finished run's course then no longer sent its media Retry to the run. The producer's fields are kept. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat(generation): a run's snapshot names its failures and its material analysis `GET /api/generation-runs/:id` gives a paused run's failure and each failed or skipped media item the seq of the event that reported it (`failureSeq`), a durable identity a Retry command's id is derived from, and repeats what the material analysis reported (`materialKinds`, `materialTruncated`) so a reloaded page shows the same. `GET ?active=1` adds the owner's limits on runs, so a client can tell before uploading materials that a start would be refused. Read from the event log; no new state. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(generation): harden the run client after review - Run and owner streams: held only while a run can change on its own (a run waiting on its owner or settled is read now and then); a refused or dropped stream is reopened with a backoff from the view's seq, reading the snapshot meanwhile; a run that cannot be read is retried and shown as such, not as missing; a page shown again reads the run at once. - Auto-continue only in the tab whose composer started the run; every other tab shows the review. A confirmation that lost to another tab keeps its edits on screen and says so; a Retry that lost says so. - Retry command ids come from the failure's identity in the snapshot or the log, so a second failure is a new command. - Classroom: read-only while the run's state is unknown too; the run's last writes are read (retrying failed reads) before the course is unfenced; a classroom on the generating page moves to the first arrived scene; a learner's changes during generation (PBL progress) are held and written once the run completes, and a scene with unsaved changes is not replaced; a save of a scene still holding a placeholder writes the asset the run placed. - Media: the run's `retryable` decides the Retry; a skipped item offers the run's Retry once its slot resolves, else the disabled placeholder. - The Pro switch shows, disabled, while the course is generating. - No upload for a start the limits would refuse; upload refusals and fallbacks are translated. - e2e: the run mock streams after attach, keeps the run generating through the classroom, checks revisions and command ids, and covers pause and Retry, the run limit, a lost confirmation and a second tab. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat(generation): release the materials a run was started with when it is over A run started with `releaseMaterials` (the composer's: the materials were uploaded for that run only) releases them when it completes or ends, in the transaction that finishes it: marked deleted as the material delete path marks them, so they stop counting against the owner's quota, their bytes going with the owner's next reclaim sweep. Only materials of the run's owner; course assets copied from their images stay. Runs waiting for confirmation or paused keep them (Retry needs them); callers that reuse material ids leave the flag out. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(generation): round-2 review of the run client - A fenced course writes no course-document content, as before: the one learner write to scene content during playback, PBL progress, is synced to its durable home (the PBL runtime store) on its own while the run generates; a scene the learner changed is not replaced by a server copy. The queued-save path is gone. - The composer marks its uploads for release with the run, and deletes them itself when a start fails (a later upload, or the start). - Stream backoff: reset only by a stream that attached, jittered, and kept when the page is shown again. - A reconnect while the outline streams follows on from the view's cursor (the items in the gap are in the log only); snapshot reads are serialized and an older one is ignored. - A run course is read once more before it is unfenced, even when the run was over when the classroom opened; a scene that never reads is given up on after a few passes. - A lost confirmation's edits are not swept away by the classroom redirect. - A waiting run is read every 5 s while the page is shown. - An upload over the size limit says the limit the server enforced. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(generation): fence a run course from its load, and keep its PBL progress The course a generation run is producing is fenced as soon as it is loaded, not when the classroom's run follower answers, so a PBL scene normalizing its project on mount is not queued as a document write the server refuses; content changes queued before the fence are dropped, a scene change among them synced to the PBL runtime store. A PBL scene the classroom holds is not replaced by the server's copy (the run never changes it after appending it, and the learner's progress lives in it). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(generation): convergence review of the run client - A start releases its uploads only when the server refused it (a 4xx); a lost answer may hide a created run, which keeps them and releases them when it is over. - A run releases its materials only when no other of the owner's runs that is not over names them. - Each snapshot read is bounded (15 s, aborted, counted as a failed read), so one that never settles does not hold the reads after it; close aborts the reads outstanding. - One run-ref check for the load-time fence and the follower; loading a course without a followable run lifts a fence, and a run that answers 404 lifts it too. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> * refactor(generation)!: remove the browser generation orchestration (E4) (#1773) * refactor(generation)!: remove the browser generation orchestration (E4) Classic generation runs on the server since E3; this removes what the browser used to run itself: - The browser scene pipeline (`useSceneGenerator`), the classroom's browser resume and media pass, and the client media pass of the media orchestrator. Media Retry stays for courses no run follows. Narration for one speech line moves to `lib/audio/narration-tts.ts` (the editor's per-line regeneration). - The `sessionStorage` handoff and the old preview session types and helpers (`GenerationSessionState`, `getActiveSteps`, `foreground-retry`, `vocational-mode`), `session-sources` and `research-decision`. - The IndexedDB document and image stash (`lib/utils/image-storage`); the device cache drops its `imageFiles` table in a version 2 upgrade. - The per-step generation routes nothing in the repo calls any more: `/api/generate/scene-outlines-stream`, `/api/generate/agent-profiles`, `/api/generate/scene-content`, `/api/generate/scene-actions`, `/api/extract-document` and `/api/web-search`. Their step functions stay; the step-level tests that drove them through the routes now call the steps. - The Vercel-only `maxDuration` route hints (`next start` ignores them). - What only those left in use: the asset storage-full marker, the media document skip index, request-based server asset and vision image resolution, `llmApiError`, the client generation settings reader, the regeneration lock, `narrationPlan`, and the stage store's browser generation actions. BREAKING CHANGE: the per-step generation routes listed above are removed; start and follow a generation run instead. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat(classroom): show pending outlines no run will produce as interrupted A course the browser was generating before 1.2.0 (or whose run is gone) is no longer resumed, so its pending outlines would sit as "generating" placeholders for ever. They now show in the failed placeholder's visuals with "Generation was interrupted", with no spinner and no Retry. A course a server job produces keeps its placeholders while the job finishes it. Also corrects the README-zh description of `app/api/generate/`. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(classroom): offer Retry for media a pre-run course never generated The browser media pass used to generate the images and videos a course's outlines asked for when the course was opened. A course generated in the browser before 1.2.0 can still carry such placeholders with no failure record and no cached bytes, and without the pass nothing offered them a Retry. Opening a course no run produces now records each one as a failed, retryable task; restored refusals and cached bytes are kept, and nothing is generated until the author clicks Retry. Also adds the interrupted-generation selector to the playback chrome test's stage store mock. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> * refactor(settings)!: main's settings UI on the server-side model configuration (#1767) * feat(config): test a saved provider from the settings by its id The settings test buttons (and the model list fetch) name a provider the workspace has configured instead of sending its key and endpoint: the server resolves it from the deployment or the workspace's own configuration, under the same rules as a slot (the policy, self-hosted media presets, custom endpoints). - verify-model, verify-image-provider, verify-video-provider and verify-pdf-provider take `provider` (and `model`); probe-models takes `provider` for one of the workspace's own chat providers. - generate/tts and transcription take `previewProvider` (and `previewModel`) for a settings preview of a provider other than the slot's. - The settings view's catalogue models carry what the registry knows of them (capabilities, thinking controls, context window) and the registry entry that serves each capability, for the settings to show. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR * refactor(settings)!: bring back the Token Plan, Model Services and Course Model sections on the server's configuration The settings dialog gets its earlier information architecture and layout back: Token Plan, Model Services (one tab per capability: language models, image, video, text to speech, speech recognition, document parsing, web search, each with its list of services and their panels), Course Model Config (the pipeline with a model per stage), Skills and General. The model map with its provider list and the separate Voice section are removed. The panels read and write the workspace's model configuration on the server instead of the browser store: - A service's key, endpoint (chat services only) and model list are its workspace provider (id = the service's preset id); keys are write-only (a mask is shown; replace or remove). A newly added service fills the root slots of what it serves that have nothing set yet. - Services the deployment configures are shown read-only; key-pair, self-hosted and (under a policy without workspace providers) all other services say that only the server's configuration can set them up. - The Course Model main model is the `llm` slot (with its thinking settings); each stage is its slot (follow the main model = no assignment of its own); the media switches set their root slot to null and restore what it held; locked slots are shown disabled. - Token Plan: connecting adds the plan's provider and fills the empty, unlocked slots it recommends; disconnecting removes it. - Test buttons name the saved service; no key leaves the browser. - The narration speed and test are back in the text-to-speech panel and the recognition language in the speech recognition panel. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR * feat(toolbar): the course model picker and extractor as before, on the server's slots The home toolbar's model picker gets its thinking control back (the `llm` slot's thinking settings) and its groups in the plan-first order the settings use, and the course material popover gets its extractor select back: it sets the `document` slot (a built-in parser that needs no key is added on first use). The set-up prompt opens Model Services. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR * docs(settings): name the Token Plan, Model Services and Course Model sections The docs, READMEs and messages pointed at the Models section (model map, providers tab, "Connect a service") and the Voice section, which are gone. They now name the sections the settings have again: a plan's key in Token Plan, a service's key in Model Services, per-stage models and capability switches in Course Model Config, VoxCPM voices in Model Services → Text-to-Speech. The server-side explanations (locks, write-only keys, deployment-only services, the one-time import) stay. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR * fix(settings): web research dims only when search is off, as before Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR * fix(config): list a saved provider's models without naming a model Fetching the models of a saved chat provider passed its bare id to the chat resolver, which requires `provider:model`, so every request was refused. The provider's connection is now resolved without a model (still only the workspace's own providers, and the endpoint is still checked). The settings view test also covers an OpenAI-compatible deployment provider's listed chat models. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR * feat(settings)!: Course Model Config is the course model map Course Model Config shows the course model map again in place of the earlier pipeline panel: the default model card on top, the pipeline as a two-row serpentine with the content station expanding to its page types, editing on the card (follow the parent, a model or off, and a fallback for chat slots), "Set by the server" locks, solid and dashed lines, zoom, pan and keyboard. Its provider tab and first-run card stay out: services are set up in Model Services and Token Plan, and the map links to Model Services where it needs one. - One switch implementation (flipSwitch): off sets the slot to null, on restores what it held, else takes the first service that serves it (speech input returns to the browser's recognition), else opens the picker. What it turned off is remembered, and what it restored forgotten, only once the server confirmed the change. - Speech input shows its switch while it runs in the browser (unset), and a service can be picked for it then. - The server's providers count wherever chat models are offered (the map, the toolbar, Model Services); the map's "no model" note shows only when nothing at all offers one. - Model Services names a provider by its preset and id ("OpenAI-compatible · gateway", with a generic logo), and shows one named after a built-in service as that service's entry. - A typed key stays in the field when the server refuses it; it is cleared once saved (Doubao's paired fields too). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR * fix(settings): provider logos in the model pickers; thinking, keyless services and model discovery on the map Logos: the home toolbar's model picker shows the provider's logo again, on the pill and on every group and model, as it did before the settings restructure; so do the course model map's card picker and its read-only pill. A provider looks the same everywhere: its plan's or built-in service's logo (a provider named after one included), its preset's, or the generic service icon for a custom endpoint, as in Model Services. The naming and logo helpers move to a data-only module the toolbar can load. Review fixes: - The map's card picker sets the thinking settings of a chat slot's own model (the main model and each stage), against the view it shows; locked slots have no picker. - A service that needs no key (the browser's own speech) is offered in a media slot's picker, and the text-to-speech panel can make it the narration: the provider is added, then the slot assigned against the view the add answered. - Fetching a provider's models reports them as added only once the server saved the list; a refused or lost write leaves a failure to retry. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR * fix(config): removing a provider's key frees the slots that needed it Removing the stored key of a workspace provider left the slots assigned to it pointing at a provider that can no longer be called, so the default model (or a stage) failed at generation time while the settings still showed it in use. Removing the key now drops those assignments, as removing the provider does, and the slots follow their parents again. Only the capabilities whose provider needs a key are affected: a local model server or a keyless search keeps what it serves. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR * fix(config): connecting a token plan fills an empty web search slot A token plan's web search has no models to pick, so the plan's preset recommended nothing for the webSearch slot and connecting a plan (TokenDance, MiniMax) left search unassigned, where the browser-side Token Plan used to select the plan's search. The preset now recommends the plan's search for it, and the first-run fill assigns the provider by itself to the slot when it is still empty. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> * fix(settings): show each TTS service's full request URL The server-backed TTS panel showed only the preset base URL as the request URL. Append the path each built-in service calls, as main did, so Gemini shows /interactions and Doubao /unidirectional. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR * fix: upgrade rehearsal findings (access-code reload and import, completion flag, provider log source, COOKIE_SECURE) and upgrade docs (#1774) * fix(access-code): reload the page's data once the access code is accepted The library, folders, active runs and the workbench probe were requested before the gate passed, answered 401, and stayed empty until a reload. The guard now mounts its children again after a successful verify, and the workbench probe no longer caches a refused answer. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(persistence): mirror a saved outline's generation-complete flag A whole-document save whose outline says generationComplete now sets stage_meta.generation_complete in the same transaction, so a course the browser importer writes is marked complete like one imported from data/classrooms. saveCompletedClassroom relies on the same path. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(config): name the actual provider source in the startup log The line said "Loaded (server-providers.yml)" even when every provider came from environment variables. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(access-code): let COOKIE_SECURE=0 cover the access cookie too The access cookie was Secure in every production build, so behind plain HTTP the browser dropped it and the gate never opened, while the owner cookie already honoured COOKIE_SECURE=0. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs: upgrade, plain-HTTP cookie and migration gaps found in the 1.1.2 upgrade rehearsal - Upgrading from 1.1.x: keep data/ (or OPENMAIC_CLASSROOMS_DIR) and set the identity mode before the first 1.2.0 start, since data/classrooms imports go to system:legacy-classrooms for good in anonymous mode; spell out the pnpm start upgrade steps and the shared data directory. - Required configuration: COOKIE_SECURE=0 for plain-HTTP deployments. - Self-host: the next start / output: standalone warning is harmless; keep pnpm start on a VM. - Configuration: <PREFIX>_BASE_URL maps to baseUrl; with openmaic.yml the provider variables configure nothing, so declare every provider and slot; pbl-chat and maic-agent routes are removed. - openmaic skill: self-hosted ACCESS_CODE needs the verify cookie, not Bearer. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(legacy-browser-import): resume the import once the access code is accepted On an ACCESS_CODE-gated deployment the page's first import run is answered 401 before the visitor enters the code and backs off, so an upgrading browser saw an empty library on its first visit. The ledger now records an unauthorized pause, and accepting the code starts the import again at once (only that backoff is skipped). This page's runs are queued one after another, so the resume never runs beside the scheduled run, with or without Web Locks. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(legacy-browser-import): wait for another tab's lock on an access-granted resume A resume after the access code was accepted took the cross-tab lock with ifAvailable, so it was dropped as busy-elsewhere while another tab held it; when that tab's run was the one the gate refused, nothing retried until a later load. The resume now waits for the lock and decides again inside it: finished, an unauthorized pause to retry, or another backoff that still holds. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> * ci: stop triggering on integration/provider-config before it merges to main Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR * fix(settings): cache provider logos so switching service tabs does not blank them (#1777) Next serves public/ files with `Cache-Control: public, max-age=0`, so each logo a newly mounted list draws is revalidated before it paints. Switching tabs in Model Services mounts a fresh list, and its logos stayed blank until every revalidation came back. Cache /logos/* for a day with a week of stale-while-revalidate; the files are not content-hashed, so not immutable. Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> * fix(settings): hide the Skills section when the agent runtime is unavailable (#1778) Without the agent runtime, GET /api/agent/skills returns 404 by design, so the Settings Skills section could only ever show a load error with a retry that cannot succeed. Probe GET /api/agent/runtime once per tab and list the Skills item only when it reports `enabled` (the flag plus DATABASE_URL, the same check the skills route gates on). The item stays hidden while the answer is unknown, and a request to open the dialog on `skills` without the runtime lands on the first section. Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> * fix(generation): show the selected extractor's formats in the material upload hint (#1779) The attach popover's dropzone hint claimed documents, slides, spreadsheets and images for every extractor, so with unpdf (PDF only, plus the built-in plain-text extractor) users could pick a .docx via "All Files" and only then hit a generic "unsupported" error. - The hint now lists exactly the formats the active extractors accept (getFormatLabelsForProviders), with the per-file size limit. - The unsupported-file error names the extractor and its supported formats; it covers the file picker, drag-and-drop onto the dropzone, and the cleanup that drops attached files after switching extractors. - Format labels are file-type names shared by all locales; the list separator is localized. Strings updated in all 12 locales. Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> * fix(settings): connecting a token plan applies its recommended configuration (#1780) Since model configuration moved to the server, connecting a token plan only filled the slots that were still empty, so a workspace that had already picked models never switched to the plan's setup. The browser-side Token Plan used to make the plan's default model, its recommended course stages and its image, video, speech and search services the active selection. Connecting a plan (or saving a new key for a connected one) now applies the plan's recommendation as one slots change: the default model, each course stage the plan names, and its media and search services. When that would replace assignments the workspace made itself, the panel asks first: use the plan's recommended setup, or keep the current one and fill only the empty slots. Slots the deployment locks are never offered or changed, and a plan yields the slots a connected plan of higher priority recommends, whatever the connect order. A replaced language-model assignment keeps its fallback; thinking settings go with the model they were set for. Disconnecting still removes the provider, which frees the slots that named it. Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> * feat(home): the toolbar picker sets the default model and shows stages set separately (#1781) * feat(home): label the toolbar model as the default and show stages set separately The home toolbar picker only sets the `llm` root, but read as if it chose the model for everything. It now says what it changes: - the pill reads "Default · <model>" (the prefix is hidden on phones); - a hint after it counts the stages under `llm` whose own setting resolves to something other than the default (inheriting or matching stages do not count; deployment-set stages do), lists them in a tooltip with the map's stage names, and opens Course Model Config on a click; - the dropdown says picking here leaves stages set separately alone; - when every stage a course is always generated with (outline, each page type's content, actions) has its own setting, the pill summarises the per-stage setup (naming the Token Plan when one plan provider serves them all) and opens Course Model Config instead of offering a switch. Picking a model still changes only `llm`; the locked and first-run states are unchanged. New strings are added to all 12 locales. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR * fix(logos): size the DeepSeek logo to its own view box The SVG declared width 182 and height 29 around a 34x29 view box, so any square icon slot scaled it as a wide strip and the whale drew as a dot. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> * fix(agent): don't inherit a thinking effort the agent can't use; recover sessions after a failed first run (#1782) * fix(agent-runtime): drop the thinking effort the agent slot inherits The agent slot follows llm, so a thinking level picked for the default model (the home toolbar writes it on llm) reached the agent driver, which refuses any thinking effort because its tool calls cannot carry one. Every agent run failed for a user who had picked a thinking level. The agent slot now declares that it carries no thinking effort: - Resolution drops an effort the agent inherits from an ancestor and keeps the rest of the thinking settings; an inherited effort of none stays "thinking off". - An effort set on the agent slot itself is refused when it is saved: openmaic.yml at load and a workspace change through the settings API. The driver's own check stays as a backstop for settings stored earlier. - The model map's thinking control for the Agent card offers no effort levels: an effort model that can be switched off shows on/off, and one that cannot shows nothing. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR * fix(agent-runtime): keep a session usable after a run that failed while starting A run that fails before it completes any message (for example, while resolving its model) writes nothing to the entry tree, but its lifecycle frames are in the event log. The runner treated any lifecycle frame as proof that the tree should hold history, so every later run of that conversation failed with "tree is empty after a prior run". The empty-tree check now asks the event log whether a prior run completed a message. The runner appends every completed message to the tree right after its message_end event, so an empty tree is refused only when a message_end exists: the tree lost history it held. An empty tree after runs that never completed anything is legal, and the next run starts the conversation over: - it resumes the conversation instead of opening it again, so the opening prompt is not painted twice; - a durable message is taken as the session's opening message only when it was posted before the first run; a message posted after a failed start is delivered as a follow-up, not consumed in place of the session's prompt. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> * fix(config): workspace-provider policy covers request headers; startup secret warnings; upgrade notes (#1783) * fix(model-config): ignore request-named providers when workspace providers are off With `policy.allowWorkspaceProviders: false`, users may only use the providers openmaic.yml declares. The deprecated request paths still let a request run on its own model, key and endpoint while a slot was unassigned. Under that policy the language model and media resolvers now skip the provider a request names, document extraction keeps only a self-contained extractor from the request fields, and the header form of the provider test routes (verify-model, verify-image-provider, verify-video-provider) is refused. Behaviour is unchanged when the policy is true or unset. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR * test(model-config): pin the media defaults an upgrade keeps Before the server-side settings, the browser switched image, video and narration on at its first sync with the server whenever the server had a provider for them, and the classroom chat and the agent searched the web whenever a search provider was configured. The translated legacy defaults keep those capabilities on, unlocked, so a workspace can switch each off; an explicit openmaic.yml assignment stays as written and locked. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR * feat(secrets): warn at startup when stored keys will not open Keys saved in the model settings are sealed under OPENMAIC_SECRET_KEY, or under a secret generated in data/instance-secret.key when it is unset. On a host whose data directory does not survive a restart, or with several replicas, a new secret is generated and the stored keys stop opening; on a read-only data directory no secret can be created and saving a key fails. At boot, with one query in the background, the server now warns when: - OPENMAIC_SECRET_KEY is unset and the data directory cannot hold a new secret file; - the secret file is missing (so a new one would be generated) while the database holds keys sealed under an earlier secret; - stored keys were sealed under a different secret than the current one. Each warning says the keys have to be entered again and recommends setting OPENMAIC_SECRET_KEY. The server still starts in every case. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR * docs: upgrade notes for server-side model configuration Add breaking changes and upgrade notes to CHANGELOG.md for the move of model configuration to the server: openmaic.yml and capability slots, MODEL_ROUTES refusing to start without openmaic.yml (with the route-to-slot mapping, including maic-agent-driver -> agent), the narrowed /api/generate-classroom body, deprecated request fields and the workspace-provider policy, the media capability defaults after an upgrade, the one-time browser settings import and what it cannot carry, and OPENMAIC_SECRET_KEY persistence across restarts and replicas. The configuration and deployment docs (all languages) and .env.example now cover replicas, ephemeral and read-only data directories, the new startup warnings, and request fields being ignored under the policy. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> * fix(import): keep settings the server could not take; Azure regional endpoints; keyless compatible providers (#1784) * fix(model-config): keep what the browser import could not move, and accept Azure Speech regional endpoints The one-time import of browser model settings deleted the staged proposal, keys included, on any 2xx answer, even when the server skipped items. The version 5 store migration had already dropped the originals, so a skipped provider lost its key everywhere. An Azure TTS/STT user with a regional endpoint lost theirs this way: the server refused the endpoint as a custom media endpoint. Import: - Only what the answer shows the workspace holding leaves the browser. Every other item (skipped as invalid, an id the deployment declares, or unconfirmed because the answer could not be read) is kept in the browser as staged, with its keys, under its own key. Skips that leave nothing behind (EXISTS, a locked slot) are not kept. If the items cannot be kept, the proposal stays for a later load. - What the builder cannot propose is kept the same way at migration time: custom speech/transcription providers, AliDocMind's key pair, custom chat providers without an endpoint or of an unsupported type. - Kept items are never sent again and do not re-trigger the import. A toast tells the user once; Settings -> Model Services lists them with the reason and a copy-key button until they are discarded. Setting one up again (a new workspace provider of its preset, or the slot) clears it. Clearing the local cache keeps them. - The import answer now carries a `code` per skipped item, and a provider id the deployment declares is reported as PROVIDER_RESERVED. Azure Speech: - Workspace azure-tts / azure-asr providers take their official regional endpoint (https://<region>.tts.speech.microsoft.com, https://<region>.api.cognitive.microsoft.com or .stt.speech.microsoft.com), region [a-z0-9]+, https only, no userinfo, port, path, query or fragment. It is checked at save time and at resolution (resolve-slot marks anything else as a custom endpoint, which media resolution refuses), and stored normalised. Their registry default only names the region as a placeholder, so they now require it. The TTS/ASR panels get a regional endpoint field. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR * fix(model-config): let a keyless OpenAI-compatible provider run A self-hosted OpenAI-compatible server (Ollama, vLLM) declared in openmaic.yml as `preset: openai-compatible` without `apiKey` resolved, but building its model threw "API key required for provider: openai": the preset rides on the OpenAI registry entry, which requires a key. - The openai-compatible preset marks its key optional, and the slot model passes that to getModel (a new `requiresApiKey` override on ModelConfig). Every other preset keeps the registry's rule, so OpenAI itself still needs a key and fails with the same clear error. - A request without a key no longer carries an empty `Authorization: Bearer ` header; with a key it is sent as before. On main, a custom OpenAI-compatible provider sent from the browser was built the same way (providerType openai, registry default requiresApiKey true) and also needed a key on the server; keyless worked only for registry entries marked keyless (Ollama, Lemonade). This lets the documented keyless openmaic.yml provider work. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR * fix(model-config): carry narration, image and video switched off over to the workspace The browser import carried a capability's "on" choice but not an explicit "off" for narration, images and video. A user who had switched one off got it back on after upgrading, because the deployment's defaults now turn those capabilities on. When the old store has `ttsEnabled`, `imageGenerationEnabled` or `videoGenerationEnabled` stored as false while a usable provider for the capability was there, the import proposes `tts: null` / `image: null` / `video: null`, as `asrEnabled: false` already becomes `asr: null`. Earlier builds defaulted these switches to off and turned them on by themselves once a provider was usable (a server provider on the first load, a key the user entered), and off when none was. So `false` with a usable provider (server-configured and not switched off by the operator, or with the user's own key; browser speech synthesis does not count) is the user's choice, and `false` without one is only the default, which is not carried over. A server that gained a provider after the browser's first load cannot be told apart and is read as off: that is visible in the settings and costs nothing, while reading it as on could start paid generation the user refused. Web search off is not carried over: it only stopped course research, while chat and the agent kept searching through the same provider. A slot the deployment locks is skipped like any other import (SLOT_LOCKED). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> * fix(import): drop a browser-kept key only when the server holds the same one (#1785) * fix(import): drop a browser-kept setting only when the server holds the same one The one-time import of browser model settings could still lose a key: - A provider id the workspace already had was answered `EXISTS`, and the browser deleted its copy, even when the workspace held another key. The server now compares the proposed provider with the stored one (preset, endpoint in its normalised form, models, and the key, decrypted and compared in constant time) and answers `EXISTS_SAME` or `EXISTS_DIFFERENT`. Only `EXISTS_SAME` lets the browser drop its copy; a key this instance cannot open is never confirmed. Nothing else about the stored provider is returned. - Provider ids and slot ids shared one namespace in the answer, so a slot `tts` that was imported confirmed a refused provider `tts` and its key was deleted. The answer now names each item's kind (`{ kind: 'provider' | 'slot', id }`), and kept items are keyed by kind and id. - The notice in Settings cleared a kept provider, key included, as soon as any new workspace provider of its preset appeared, even one without a key. A kept key now leaves only when such a provider holds it as far as the view shows (key set, readable, same mask); otherwise it stays, with its copy button, until the user discards it. Key pairs and keys too short to show in a mask are never cleared this way. The key mask moves to a module the server and the browser share. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR * docs(changelog): the narration, image and video off switches are carried over The changelog still said the image, video and narration off switches were not carried over. They become `tts`/`image`/`video: null` when a usable provider was set up, as the configuration docs and the import README already say; only the research switch, and a switch off only by default, are not carried over. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR * fix(import): never clear a kept key by itself A kept provider that holds a key (or a key pair) used to leave the browser once a new workspace provider of its preset held a key whose mask matched. A last-four-characters match does not confirm the workspace holds that key, so such an item now stays, with its copy button, until the user discards it. Kept items without a key still leave by themselves once the view shows them set up again. The key mask goes back to the server module, its only user. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> * fix(import): keep browser settings on a refused or self-contradicting answer (#1787) Two more ways the one-time import of browser model settings could lose a key: - A 400 removed the proposal outright. The server refuses the whole proposal then (an unexpected top-level field is enough), so sending it again cannot succeed, but its keys existed nowhere else. Every item is now kept in the browser first, keys included, as refused with the server's message, and only then is the proposal removed; if they cannot be kept, the proposal stays. Kept items are never sent again. 401, 404, 409, 5xx and network failures still keep the proposal for a later load. - An answer that listed an item as both imported and skipped, or skipped it with different codes, let `imported` (or the last code) win, which could delete a key the server did not hold. Such an item is now kept as unconfirmed. Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> * fix(generation): keep a placeholder-free message for material formats a run refuses #1779 gave upload.unsupportedCourseMaterial {{parser}} and {{formats}} placeholders for the toolbar, which knows the selected extractor. The run start (a format the server's material policy does not list) and the material upload route's 415 use the same key with no values, so the message would have shown the raw placeholders. They now use upload.unsupportedMaterialFormat, the earlier wording in every locale. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * ci: run the prior-run-record PostgreSQL suite with the app-domain contracts #1782 added tests/agent-runtime/prior-run-record.pg.test.ts without listing it in the app-domain contract step, the only job that gives the app suites a database, so it skipped everywhere. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat(model-config): server defaults, explicit locks and settings shaped by what users can change (#1793) * feat(model-config): server defaults, explicit locks and settings shaped by what users can change openmaic.yml `slots` are now server defaults that users may change in the settings; `lock: [slot, ...]` or `lock: all` fixes slots for everyone, each with its whole subtree. Inside a locked subtree a slot resolves from the deployment alone; elsewhere each node consults the workspace's assignment and then the deployment's default. `allowUserKeys` (default true) replaces `policy.allowWorkspaceProviders`, which is refused at startup naming the new key. Legacy variables translate into the same deployment layer as defaults, so the separate defaults walk is gone; a request's deprecated model fields still replace a server default (as they did DEFAULT_MODEL), never a workspace choice or a lock. The settings view carries per-slot `source` (default / workspace / locked / inherited / unconfigured), `serverDefault` and top-level `allowUserKeys`; writes anywhere in a locked subtree answer 409 SLOT_LOCKED. The UI derives one of three shapes from the view (set it up yourself / choose a model / configured by the administrator) and renders a control only where using it can change something: Token Plan, Model Services tabs and adding services, locked cards as one read-only line, "Reset to server default", a read-only summary when everything is locked, and the home toolbar picker. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs(model-config): defaults, locks, allowUserKeys and the three settings shapes Rewrite "Locks and the model settings" as "Defaults, locks and the model settings" in every locale: `slots` are server defaults, `lock` fixes slots with their subtrees (or `lock: all`), the three shapes the settings take and the rule that a control shows only where it can change something. The Policy section becomes allowUserKeys, the resolution rules, the deprecated request fields and the migration notes follow, and the example openmaic.yml, the READMEs, the openmaic skill and the changelog describe the same behaviour. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(model-config): keep openmaic.yml defaults ahead of deprecated request fields A request's deprecated model and provider fields now answer only where nothing is assigned, or over a default translated from the legacy variables (as DEFAULT_MODEL always ranked below the model a request named). A default written in openmaic.yml stands, as it did when the file locked what it set: otherwise the legacy request paths that fill in a provider of their own (web search's fallback provider) would replace it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(settings): show a locked line's value in full and let the summary scroll A locked slot's line keeps its value on the first row and puts the lock with "Fixed by the administrator" below it, so a short value is no longer cut off by the label. The read-only summary scrolls inside the dialog instead of being clipped below the language models. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(agent-runtime): provision the session schema before the material schema On a fresh database the material extraction runner's first scan created the session-material store before anything had created agent_sessions, which its table references, and the scan failed once. The material store now waits for the agent-session store (and so its schema) first. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(settings): name deployment providers when presets are not served Provider views carry their preset's name and kind, so a deployment's providers keep their display names (MinerU, DeepSeek, Bocha) on the cards, in the pickers and in the home toolbar under allowUserKeys: false, when the preset catalogue for adding providers is empty. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat(settings): read-only diagram for locked deployments, and controls only where they apply - Configured by the administrator: Course Model Config shows the course model diagram with every card read-only and one line saying the administrator set up the models; the separate summary and its strings are gone. - The home page model picker, a shortcut for the default model, is rendered only when that model can be changed (canChangeDefaultModel): not when llm is locked, and no read-only label in its place. The set-up shortcut appears only where the settings can still set a language model. - Connecting a token plan treats a server default written on a slot as a current choice: the confirmation lists it (marked as the server default) and says the plan replaces what each stage uses now, server defaults included; keeping the current setup leaves it. - Docs and changelog follow (6 locales). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(model-config): the user's choice beats server defaults; close lock and key gaps - Outside a locked subtree a slot takes the workspace's assignment anywhere from the slot up to the root first, and only without one the server's defaults (openmaic.yml or the translated legacy variables): whatever a user changes wins, and a slot that follows its parent follows the user's parent. A deployment configured through DEFAULT_MODEL keeps the Pro agent off until someone chooses a default model, and the agent follows that choice. - A document slot that `lock: all` leaves unassigned is fixed as nothing: material extraction no longer takes a provider, key or endpoint from the deprecated request fields for it (documentStatus `locked`). - The settings view leaves out providers a workspace added before `allowUserKeys: false`, and the assignments naming them, as the calls do. - Clearing a stale workspace assignment inside a locked subtree is allowed; setting one still answers 409 SLOT_LOCKED. - Under `allowUserKeys: false` the raw key forms of verify-pdf-provider, provider/probe-models and azure-voices are refused, as the verify routes' are. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(settings): follow the user's parent, keep voices and connected plans reachable - A card whose server default is overridden by the user's choice on a parent says it follows that parent; its picker still offers the server default, written as the slot's own when clearing would follow the parent instead. - "Reset to server default" is offered only when the workspace value differs from the default (model, fallback, thinking and parameters compared). - The Text-to-Speech tab stays for narration with voices of the user's own (VoxCPM designs, Qwen clones) in every shape, opening on the service in use. - A token plan the workspace connected stays listed to update its key or disconnect, after its slots are locked. - Docs (6 locales) and changelog: the new resolution rule, and the lock example no longer puts `lock: all` beside a list in one block. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(settings): suggest a provider of one's own only where adding one can change something The read-only notice of a server-configured service says "To use your own credentials, add a separate provider" only while adding a service for that capability can change something (canAddService); the sentence is its own string in every locale. The Model Services header likewise says "fill in credentials" and "Pending setup" only for a service that can be set up here, else "Not configured". Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(settings): count only the user's choices and locks as stages set separately Under the resolver where the user's choice anywhere up the tree beats a server default, a stage on an unlocked server default follows whatever default model the user picks. The home toolbar no longer treats such stages as set separately: it keeps the default-model picker instead of "Per-stage setup", and lists only stages the workspace set itself or a lock holds. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> * feat(materials): upload and extract materials as soon as they are attached; simplify the home model picker (#1796) * feat(materials): upload and extract course materials as soon as they are attached The composer uploads a material when it is attached, and the server extracts it in the background right after the upload, so Generate starts a run from materials that are ready and the preview opens without a material analysis. Server: - owner_material extraction state idle | extracting | ready | failed, with the error, what it found (text, pages, images) and what a course leaves out of the material on its own (migration 3 adds the extractor lease columns and indexes; earlier states become idle). - A process-scoped background extractor claims extracting materials under a heartbeat lease (a crash or restart is taken over once the lease is stale), runs the material-analysis step's own extraction with its time budget, and stores the result (text, images with their bytes) next to the material's bytes. Concurrency per process: OPENMAIC_MATERIAL_EXTRACTION_CONCURRENCY (default 2). - The same bytes uploaded again by the same owner reuse a ready extraction made under the same extraction services. - POST /api/materials starts the extraction (?extract=false defers it, used by the agent workspace, which extracts what a session binds itself); GET /api/materials and GET /api/materials/{id} report the owner's uploads with their extraction; POST /api/materials/{id}/extraction extracts a failed one again; deleting a material deletes its extraction result and drops an extraction in progress. - The run's material step reads ready extractions, waits for running ones, starts idle ones and fails with a failed one's error; Retry of a run paused there extracts the failed materials again. The run tells the preview what it waits on only when it waits. Images still become course assets when the run uses them, so the produced course is unchanged and releasing a material never touches a course. Client: - Material chips show the upload progress, Parsing / Transcribing, Ready (with what a course leaves out of the material), or the failure reason with Retry and Remove; limits and formats are checked against the capabilities endpoint at attach time. Generate is disabled until every material is ready. - Materials no run took are deleted when the composer goes away. - The preview lists the analysis step only while a run waits for an extraction, and no longer shows the truncation notice. Docs: the openmaic skill's generate flow describes the extraction state and the optional wait before submitting. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat(toolbar): show the home model picker as the default model alone The home toolbar's model picker no longer carries settings jargon a learner cannot act on: - The "N stages set separately" hint next to it is gone; stages with a model of their own are shown in Course Model Config's diagram. - It is never replaced by a "Per-stage setup" summary: whenever the default model can be changed it is the picker, since changing the default still changes every slot that follows it (the classroom, the agents). It is hidden only when the default cannot be changed, as before. - The button shows the model name (and the thinking level badge) without a "Default ·" prefix; the dropdown keeps its note on what picking changes. The now unused override helpers (lib/model-settings/overrides.ts), the home picker wrapper, the picker's valuePrefix and their i18n keys are removed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(materials): fence extraction results per attempt, cancel extractors, share workers fairly Follows the cross-review of the extraction at upload: - Result writes: every claim takes its own lease (worker id plus an attempt id), a process never claims a material it already extracts, and each attempt stores its result under its own immutable key (written to a temporary file and renamed into place). The settle publishes the winning key in the same fenced write; an attempt that loses its lease deletes only its own object, and an owner claim moving the material is no longer taken for a delete. A material's results go with it (prefix delete). - Cancellation: the extractor config carries the caller's signal, and self-hosted MinerU, MinerU Cloud (polling and retries included), AliDocMind's polling and the local ffmpeg/ASR pipeline stop on it. The extraction waits until the extractor actually settles, so a deleted or timed-out material keeps its worker slot until its provider work stops. - Fairness: claims are serialized and take owners in turns, at most OPENMAIC_MATERIAL_EXTRACTION_PER_OWNER (default 2) running per owner. A run no longer charges queue time to its step budget: it waits without a deadline while its material is queued, and an extraction gets the budget once a worker claims it. - Leaks: uploads no run or agent session references are deleted after OPENMAIC_UNUSED_MATERIAL_TTL_HOURS (default 24) by an hourly sweep, which also finishes released and half-deleted materials. The composer hands its materials to the run before anything is awaited, keeps them when the page goes into the back/forward cache (and reconciles them on return), and shows attached files before the policy is read. - Reuse: an extraction is reused only for the same type and the same document service (provider, model, endpoint, options, origin) and speech service (provider, model, endpoint). - Quota: a stored result counts against the owner's byte quota, and one result is capped (OPENMAIC_MATERIAL_EXTRACTION_MAX_RESULT_MB, default 100; EXTRACTION_RESULT_TOO_LARGE). - A run's Retry restarts its failed extractions only among the materials of the owner that holds the run now; a composer Retry answered 409 follows the restarted extraction; an empty sessionId answers 400. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(materials): sweep unused uploads once at start too Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(materials): keep composer uploads alive, quota-check results, finish cancellation, sweep orphans Follows the convergence review of the extraction at upload: - The unused-upload sweep ages a material from when it was last read back (owner-material migration 4, `touched_at`): GET /api/materials/{id} refreshes it, an open and visible composer reads its materials back every 10 minutes, and Generate checks they still exist before it starts (a material that went shows as removed). - Publishing a result checks the owner's byte quota under the lock an upload's reservation takes; over it, the extraction fails with MATERIAL_QUOTA_EXCEEDED and the attempt's object is deleted. - AliDocMind sends no request after an abort: submission, polling and every result page check the signal (its SDK cannot abort a call in flight). - A cancelled ffmpeg command is terminated (SIGTERM, SIGKILL after a 2 s grace) and the rejection waits for the process to exit. - The sweep also deletes result objects under a live material's `.extraction/` that are not its published result and are older than any attempt could still settle (twice the extraction budget plus the lease). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(materials): recheck sweep and claim conditions on the locked row The unused-upload sweep chose its materials in a subquery and marked them deleted without checking again, so a read (touch) that committed while the sweep waited for the row lock still lost the material. The conditions (ready, not deleted, untouched since the cutoff, no run or session names it) are now on the UPDATE's own row, which PostgreSQL rechecks on the row's latest version after the lock; the extraction claim does the same. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> * release: 1.2.0-rc.1 Close the Unreleased section as 1.2.0-rc.1 with a short introduction, set the app version to 1.2.0-rc.1, and stop triggering CI on the integration/server-first and integration/provider-config branches, which retire with this release. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JbL3cuQFRx8d1uJVQXz7XV * docs(readme): deploy 1.1.x on Vercel from a fork instead of the deploy button Vercel's clone button reads tree/release/1.1.x as the branch release plus the directory 1.1.x, and it does not accept a tag or a commit, so the button could not deploy the 1.1.x branch. Describe the fork, default-branch and import steps instead, in both READMEs, and correct the changelog sentence. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JbL3cuQFRx8d1uJVQXz7XV * docs(readme): lead with 1.2.0 and condense the News list Replace the 1.0 highlight with a short 1.2.0-rc.1 summary, keep one line per release in News with the 0.x entries folded, and bring the Chinese README's News up to date. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JbL3cuQFRx8d1uJVQXz7XV * docs(readme): drop the 1.2.0 headline and explain server-first in News Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JbL3cuQFRx8d1uJVQXz7XV * docs(readme): remove the skill tip box Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JbL3cuQFRx8d1uJVQXz7XV * docs: add a Hosting and identity page Move the operator and embedder material that lived in the README into the docs site: Docker Compose defaults and upgrade notes, what the server stores, access to stored data, asset collection and quotas, startup checks, the agent runtime, owner identity (single-user mode, registering auth methods, the signed-JWT gateway recipe, claiming anonymous work) and the host extension hooks. The deployment pages now link to it instead of the README anchor. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JbL3cuQFRx8d1uJVQXz7XV * docs(readme): shorten the configuration and persistence sections Keep the minimum a new user needs in Quick Start: one openmaic.yml example, a table of where to read more, and the longer provider examples in a collapsed block. Docker, ACCESS_CODE, the agent workbench and server-backed persistence become short sections with an identity-mode table, linking to the new Hosting and identity page for the details. Optional local providers move after the numbered steps. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JbL3cuQFRx8d1uJVQXz7XV * fix(settings): keep the selected service in view in the Model Services list The service panel opens on the service in use, which can sit far down a long list (a connected token plan named after a built-in service is listed in registry order). Its row was below the fold while the promoted first row's ring looked like the selection, so the panel seemed to show a service the list did not highlight. Scroll the selected row into view whenever the selection changes, and give the selected row a stronger ring than the promoted one. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs(changelog): condense the 1.2.0-rc.1 notes and explain server-first Lead with highlights, breaking changes, upgrade steps and known issues; keep the detailed notes folded below. Dated 2026-10-04. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JbL3cuQFRx8d1uJVQXz7XV * fix(editor): let slide elements be dragged and resized in Safari The Pro-mode slide editor's gesture hooks (drag, resize, rotate, shape keypoint) decided between mouse and touch input with an instanceof test against the TouchEvent global. Desktop Safari does not implement the Touch Events API, so the global is undefined there and every mouse-down on an element threw a ReferenceError before the gesture was armed: no element could be moved, resized or rotated. Recognise touch events by their changedTouches list instead, through one shared helper. A unit test pins the helper in an environment without the global and fails if browser code reintroduces the instanceof check; an e2e spec removes the global before the app loads and drags and resizes an element. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs(changelog): note the Safari editor fix Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JbL3cuQFRx8d1uJVQXz7XV * fix(composer): compact material rows in the course-material popover The material cards in the composer's material popover used a larger type scale than the rest of the popover (a 14px title and 12px status beside its 12px labels and 10px hints), a large icon tile, and a progress bar loose under the content. Each material is now a compact row on the popover's type scale: the title at 12px medium with ellipsis (the full name in its tooltip), the status in 10px muted text (the upload percent, Parsing or Transcribing with a spinner, Ready with the file size), or the failure reason in the destructive colour. The icon is a small file-type icon (slides, spreadsheet, image, audio, video, document). The upload progress is a thin bar along the row's bottom edge, and a pulsing one shows an extraction in progress. Retry and Remove are compact icon buttons with their accessible names. The truncation notices and the audio/video label stay on the row, small. The drop-zone hints and the merge-order note now share one muted tone, and the list spacing matches. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(generation-preview): drop the material-analysis step from the preview Materials are uploaded and extracted as soon as they are attached, so the run's material step only reuses (or waits for) that extraction. The preview still listed it as "Analyzing documents" while the step ran, which flashed by even when every material was ready. The preview now never lists a material step: while the run is at it, the preview shows the step that comes next. A failure of the material step still pauses the run and shows in the preview with its message and Retry, like any other step's failure. Removes what only that step used: the pdf-analysis step and its scanning visualizer, the material kinds and analysis flags of the client run view, and the analyzingCourseMaterial(Desc) / analyzingMediaMaterial strings in every locale. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs(changelog): note the material rows and the preview step removal Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JbL3cuQFRx8d1uJVQXz7XV * fix(settings): hide Token Plan and Model Services when everything is locked Under `lock: all` the settings show only the read-only course model map. A token plan the workspace connected earlier changes nothing there, and narration voices are not managed in this shape for now. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JbL3cuQFRx8d1uJVQXz7XV * docs(changelog): note the full-lock settings change Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JbL3cuQFRx8d1uJVQXz7XV * fix(workspace): stop marking generated courses read-only The Pro workspace treated a course whose outline a server job produced as someone else's. Since generation runs on the server, that is every generated course, so the owner saw a "Read-only" badge on a course they can edit. Ownership now comes from the course list's isOwner alone (false for a course saved from Discover), plus a run still producing the course. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JbL3cuQFRx8d1uJVQXz7XV * docs(changelog): note the workspace read-only badge fix Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JbL3cuQFRx8d1uJVQXz7XV * fix(composer): hide the extractor picker when the document slot is locked The course material popover offered the document extractor select even when the deployment locks the document slot (`lock: [document]` or `lock: all`), where it could change nothing. It is now shown only where the user may set that slot; uploading stays available. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JbL3cuQFRx8d1uJVQXz7XV * feat(token-plan): run the Pro agent on DeepSeek V4.1 Flash with TokenDance Connecting the TokenDance plan keeps the plan's own default model and its slide and interactive models for those pages, and now also assigns the agent slot to deepseek-v4.1-flash, a general model with dependable tool calling. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JbL3cuQFRx8d1uJVQXz7XV * fix(whiteboard): hydrate learner boards without a 404 point read Every classroom mount and whiteboard open hydrates the learner board through the runtime service. When the partition listing held no whiteboard session, selectSession then fetched the deterministic session id directly, which the HTTP runtime store answers with 404 SESSION_NOT_FOUND. A board nobody has drawn on yet is the normal state, so every fresh classroom logged a failed request in the browser console, in both the legacy and the native child whiteboard harness. Hydration now reads the (stageId, learnerKey) partition listing only. A same-id session of another kind in the partition is still rejected from the listing, and an id collision the listing cannot show (a corrupt row, or an id re-keyed into another partition by a learner merge) still fails loud on the next append: its create collides and the existing create-race re-read validates the winner. The runtime HTTP contract and its 404 semantics for unknown ids are unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs(changelog): note the extractor picker, TokenDance agent and whiteboard fixes Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JbL3cuQFRx8d1uJVQXz7XV * fix(home): render the course library without waiting for every thumbnail The home page gated the whole library section on loading every course's first-slide thumbnail: loadClassrooms awaited getFirstSlideByStages, which read each listed course's full document and fetched its first slide's media bytes in one unbounded Promise.all, and `hydrated` only flipped once all of that finished. A library of a few dozen courses fired one document read per course plus every first-slide asset at once, and the list stayed hidden until the last one landed. Every run-driven or import-driven list refresh repeated the whole burst. Render the list as soon as GET /api/stages answers, and load thumbnails per card: a card (or a folder tile, for its cover candidates) asks for its thumbnail while it is near the viewport, at most four loads run at once, a card that scrolls away before its load starts withdraws it, and leaving the page aborts the loads in flight and revokes every object URL. Thumbnails are cached per course and versioned by updatedAt, so a list refresh reloads only courses that changed. Cards show a skeleton until their thumbnail resolves. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs(changelog): note the home library loading fix Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JbL3cuQFRx8d1uJVQXz7XV * feat(home): paint the hero and a library skeleton before the page loads Until the page's scripts had loaded and both GET /api/stages and /api/folders had answered, the home page showed only its grey background: the hero's entrance started from opacity 0 in the server HTML and only a JS-driven animation revealed it, and the library section was not rendered at all until both reads resolved. The hero's entrance is now CSS (same timings), so the server-rendered hero is painted and fades in without waiting for the scripts. The library section, with its action bar, is always rendered; until the reads resolve it shows a skeleton of its own layout: the same grid, with folder and course tiles built from the cards' 16:9 thumbnail and title row and the thumbnail pulse, so the cards replace it without moving anything. The grid takes the skeleton's place without an entrance fade or stagger. An empty library still shows the empty state, and a failed read the existing error. A small pre-paint script applies the stored or system dark theme before the first paint, so the server-rendered page is not painted light first in dark mode; ThemeProvider leaves the document alone until it has read the stored theme. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat(home): keep course thumbnails in the device cache across reloads Every reload of the home page read each visible course's whole document and fetched its first slide's media again: document reads carry no validators and asset bytes are served `private, no-store` (and the asset client fetches with `cache: 'no-store'`), so nothing was answered from the browser's HTTP cache. With a 33-course library a reload re-read 14 documents (1.1 MB) and 29 assets (19 MB) before the visible thumbnails showed. The thumbnail is derived data, so keep the derived result: a new `courseThumbnails` table in the device cache (`maic-device-cache`) holds, per course, the first slide and the bytes of its media, valid for exactly the course's `updatedAt`. The thumbnail loader is told the version it loads, answers from the cache when it holds that version, and otherwise reads the course as before and stores the result. A changed course is read again and its entry replaced. A thumbnail whose media could not be read (a transient error) is not stored. Entries are partitioned by owner, keyed by a one-way digest of the server-derived owner key, so a claimed or switched owner never sees another owner's thumbnails and the owner id is not written to disk. Clear Local Cache deletes the device database, these entries included. The cache is bounded (300 entries, 64 MB) and evicts the least recently used entries. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs(changelog): note the home skeleton and thumbnail cache Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(home): show a slide-shaped skeleton until a course thumbnail loads A card waiting for its thumbnail showed a flat pulse over the tile's own background, too faint to read as loading, so once the library skeleton gave way to the cards the thumbnails looked like empty grey tiles. They now show the outline of a slide (title, text lines, an image block) pulsing at a visible contrast, also while a loaded slide waits for the card's width, and the library skeleton's course tiles use the same placeholder. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JbL3cuQFRx8d1uJVQXz7XV * docs(deployment): say what a restart or rolling update does to running runs The guide said that stopping the server parks its runs. A stopping process may exit before it releases its leases, so a run resumes once its lease expires: progress pauses for about the lease TTL and the step in progress is generated again, and a run interrupted MAX_ATTEMPTS times in a row without finishing a step pauses with Retry. Say so, in every locale, and when to raise the limit. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JbL3cuQFRx8d1uJVQXz7XV * refactor(home): drop the composer's requirement draft cache The home composer kept its requirement in localStorage and restored it on the next visit, clearing it only once the generation preview confirmed the outline. Generate now creates the course card at once, so a draft has no job left, and it kept the last requirement in the composer after a run had started. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JbL3cuQFRx8d1uJVQXz7XV * fix(home): draw video thumbnails from a poster instead of loading the video Every home page load left aborted blob requests behind, one or more per course whose first slide holds a video. SlideThumbnail drew a thumbnail video as a muted <video preload="metadata"> over the video's object URL. Chromium reads an object URL whatever `preload` says, and once it has the metadata and a frame it drops the rest of the read, which DevTools lists as a failed (net::ERR_ABORTED, type media) request; non-faststart files abort twice. Safari's media stack range-reads the same URL dozens of times per thumbnail, and lists the reads it gives up on as failed requests of type "other". Nothing was revoked or remounted early: the element was still mounted with its URL alive when the browser dropped the read. A thumbnail video is now drawn as its poster image, with no media element to load anything. Generated videos usually come without a poster, so the thumbnail loader decodes the opening frame once and keeps it as one, in the device cache with the rest of the thumbnail. The decode hands the bytes to a detached <video> as a data: URL, which is decoded in memory and issues no request at all, where an object URL would leave the same aborted reads behind. Cached entries carry a derivation format; entries written before this are read as a miss and derived again. A video without a poster whose frame cannot be decoded still falls back to the <video>. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs(changelog): note the video thumbnail request fix Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JbL3cuQFRx8d1uJVQXz7XV * fix(generation): continue after the outline on the server unless the learner reviews outlines A classic run started with `outlineReview: "wait"` always, and only the preview tab that started it confirmed the outline after a 2.5 s beat. A learner who left before the outline was ready found the run waiting for a confirmation forever. The composer now starts the run with `outlineReview: "auto"` unless the learner turned on "Always review outlines before generation"; the server then confirms the outline itself and the run goes on with no page open. A run that waits shows the review in every tab and page, with no countdown. The preview of an auto run shows the outline read-only and offers the setting for the next runs instead of a review entry. Removes the client-side auto-continue (countdown, the started-here session marker) and its string. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test(e2e): give the mid-stream review time to open while the outline streams Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs(changelog): note outline auto-continue and the dropped composer draft Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JbL3cuQFRx8d1uJVQXz7XV * fix(generation): count down to the outline confirmation on the server The browser counted the 2.5 s pause before an outline continued, so a run whose page was left before the outline was ready waited for a confirmation forever. The previous fix confirmed such outlines at once, which took away the review and editing the preview offered meanwhile. The pause now runs on the server. A run started with `outlineReview: "countdown"` (the composer's default when the learner does not always review outlines) waits for its outline with an auto-confirm deadline stored on the run (generation-runs migration 4); any process's runner confirms the outlines whose deadline passed, in the same commit and event an `auto` run makes. The new `hold-outline` command turns such a run into a `wait` one, while the outline streams or during the countdown, and answers a conflict once the outline was confirmed. The preview is back to the classic flow: the outline-ready card while the run counts down, the review entry mid-stream and on that card (it holds the run first), editing and confirming with edits. It never confirms on a timer, and says so when the run went on before the hold. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs(changelog): describe the server-side outline countdown Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JbL3cuQFRx8d1uJVQXz7XV * fix(classroom): make the element reference control an icon button The playback toolbar's reference control carried a text label next to its quote icon. It is now an icon button like the whiteboard control beside it; its accessible name and tooltip keep the label. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JbL3cuQFRx8d1uJVQXz7XV * fix(workspace): handle courses that are still being generated A course a classic generation run is still producing is read-only until the run completes, but the Pro workspace treated it as an ordinary course: - the classroom pane stayed blank. The pane is edit-locked, and the edit chrome never resolves for a read-only course, so `resolveStageChromeMode` returned its neutral `loading` shell for as long as the run lasted. The pane now shows a "being generated" placeholder with the run's progress and a link to follow it (or to Retry a paused run), and mounts the course by itself when the run completes; - the course rail listed it as openable. It now reads the owner's active runs (the source the home page's cards use) and shows the run's progress where the page count goes; the row cannot be opened, dragged, renamed or deleted, and is not a drop target. A paused run's row opens the classroom where its Retry lives. The row turns ordinary, live, when the run finishes; - the `@` picker offered it. It no longer does, not even as the open course; - the agent did not know. The reader tools (read_stage, grep_stage, list_scenes, read_stage_outline, read_classroom) now start their result with a notice that the course is read-only until generation completes and must not be offered for editing; list_folder_stages and search_classrooms mark it with `generating`, `scenesCompleted` and `scenesTotal`; a classroom the user names carries the same notice. The pro-editing skill says the same in one line. Writes are still refused by the store, and the refusal text reaches the agent unchanged. The home page's run status pill moved to a shared component so the rail and the placeholder say the state the same way. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs(changelog): note generating courses in the Pro workspace Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JbL3cuQFRx8d1uJVQXz7XV * fix(workspace): list runs without a course yet in the course rail The home page shows a card for a classic run from the moment it starts; the Pro workspace's course rail showed nothing until the run created its course. - The rail now lists the owner's active runs whose course is not listed yet, from the same `useOwnerRuns` source and the same rule as the home page's pending cards (`pendingCourseRuns`, now shared with `runsByCourse`), in the same order, titled as the card (`pendingCourseName`) and labelled with the shared run status pill text. A placeholder sits at the top of the loose courses, where its course row appears once the course is listed, so it turns into the generating course row in place and then an ordinary one. It opens what the card opens (`courseRunHref`) in a new tab, is never draggable or mentionable, and its one action is the card's discard. Like the home page, placeholders are left out of a search. - A run started or discarded in one tab now tells the browser's other tabs (`runs-changed`, a BroadcastChannel), so a workspace open beside the home page reads the runs at once instead of at its next idle read. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs(changelog): note run placeholders in the Pro workspace list Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JbL3cuQFRx8d1uJVQXz7XV * fix(persistence): sanitize slide HTML on every document read and write (#1799) Course documents served by `/api/persistence` are readable by id, and the classroom renders slide text, shape text, table cells and LaTeX snapshots with `dangerouslySetInnerHTML`. `/api/classroom` already restricts that HTML to the renderer's vocabulary; the persistence document path did not. The owner-bound document store now applies the same `sanitizeSceneContent` policy to the stage and scenes on every write (save, create, putStage, putScene, mutateScene) and every read (loadDocument, getScene), so new rows are stored clean and rows written earlier are served clean. Claude-Session: https://claude.ai/code/session_01Cvq11H26QM4mfbSNQQ5FdZ Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> | 28 天前 | |
release: OpenMAIC 1.0.0 — the agent workbench (#1228) * feat(storage): add an agent-session store with PG backend and layered contracts (#1163) * feat(storage): add agent-session store with PG backend and layered contracts * test(storage): avoid BigInt literals for pre-ES2020 root typecheck * fix(storage): close agent-session store review findings * docs(storage): align hook ordering and contention-probe claims with the code * ci: run on the agent-workbench integration branch * chore(storage): bump to 0.5.0 for the agent-session store * fix(storage): carry replay compaction across page boundaries * feat(agent): add the driver model contract and stage route dialect (#1165) * feat(agent): add the driver model contract and stage route dialect * fix(agent): validate route context windows and clarify dialect precedence * feat(agent): adapt the agent-session store and runtime foundations (#1167) * feat(agent): adapt the agent-session store and runtime foundations * feat(agent): resolve request owner identity via an anonymous cookie * docs(agent): document the opt-in compaction default and harden edge cases * feat(agent): add the background session runner (#1169) * feat(agent): add the background session runner * feat(agent): wire the runner into startup behind feature flags * fix(agent): stop clean interruptions from consuming the attempt budget * fix(storage): charge the attempt budget for abandoned leases but not clean parks * docs(storage): document the attempt-charging contract and decouple its tests * feat(agent): add agent session and owner event streams (#1170) * feat(agent): add agent session and owner event streams * fix(agent): close the session-existence oracle and document the owner seam * feat(agent): add agent session lifecycle routes (#1171) * feat(agent): add agent session lifecycle routes * fix(agent): validate session-create input and preserve the owner cookie on errors * refactor(storage): drop the unused active-stage API from the agent-session contract (#1174) * refactor(storage): drop the unused active-stage API from the agent-session contract Tools address stages explicitly on every call, so the store keeps no mutable session-level stage pointer. Removes resolveActiveStage and setActiveStage from the store interface, their PG implementations, the active_stage_changed lifecycle event, the session_active_stage owner event variant, and the contract tests pinning them. The active_stage_id column and the DDL check constraint stay untouched for schema compatibility. * chore(storage): bump @openmaic/storage to 0.7.0 for the contract removal * docs: document the agent runtime configuration surface (#1176) * fix(agent): repair orphaned and late tool results across interruption boundaries (#1180) * fix(agent): repair orphaned and late tool results across interruption boundaries A crash, shutdown, or provider failure can leave the durable transcript with tool calls that have no result, or with results ordered illegally for the provider. Three failure modes were fixed: - Orphaned tool calls: a run that died between an assistant tool-call frame and its result left a dangling call in the entry tree. Resume no longer synthesizes and persists receipts for it: interrupted results are a read-time provider view owned by a shared read-boundary repair, which returns the original array for a healthy transcript and never mutates the tree. - Late parallel results: a parallel tool can finish while pi unwinds an aborted assistant frame, leaving result(A), assistant(aborted), result(B) in durable order. Strict providers reject non-contiguous results, so the read-boundary repair moves existing results next to their owning assistant frame (in call order), omits incomplete unwind frames, and synthesizes receipts only for genuinely missing calls. - Interrupted calls at the write boundary: a call still in flight when the run winds down (shutdown, lease loss, cancellation, provider failure) had no receipt at all. The runner now tracks in-flight calls from their assistant frames and, before the terminal flush, appends an interrupted-result receipt for each still-orphaned call through the same attempt-fenced write chain, so a lease-stealing zombie never writes and the next claim sees a provider-safe transcript. * test(agent): pin the runner wiring for interruption-boundary tool repair * feat(agent): add neutral tool foundation libraries (#1184) * feat(agent): register a web_search tool on the session runner (#1185) * feat(storage): add a per-session URL trust gate (#1186) * feat(agent): add the skills system (#1189) * feat(agent): add the skills system (builtin directories and durable user skills) * fix(storage): serialize the user-skill quota check-and-insert per owner Two concurrent creates at the 50-skill boundary both counted 49 rows and both inserted (READ COMMITTED, no lock), overshooting the quota contract. The create transaction now takes a per-owner pg_advisory_xact_lock first, and the same-name idempotency check runs before the count check so an at-least-once retry of the create that committed as the owner's 50th row still returns its durable receipt instead of a quota error. The 23505 backstop is retained for writes that do not take the lock. * fix(agent): share unstorable-character validation and align skill lookup * feat(agent): add session materials and a fetch_url tool behind the URL trust gate (#1190) * feat(agent): add session materials and a fetch_url tool behind the URL trust gate * fix(agent): harden session material fetching * feat(storage): add an ownership scope to stage documents (#1191) * feat(agent): add material read and search tools (#1192) * feat(agent): add stage read and patch tools (#1194) * feat(agent): add page generation and deck editing tools (#1198) * test(storage): keep the PG contract suite order-independent (#1200) * fix(agent): revoke deleted-session URL authority and reject private ISATAP endpoints (#1199) * fix(storage): revoke deleted session URL authority * fix(ssrf): reject private ISATAP endpoints in strict fetches * chore(storage): bump to 0.11.1 for the session-URL authority fix * feat(agent): add roster and voice registration tools (#1201) * feat(agent): add folder organisation tools (#1202) * feat(api): add stage and material HTTP routes (#1203) * feat(workbench): add the client data layer (#1204) * feat(workbench): add the client data layer * docs(workbench): write the ported comments in English * chore(edit): remove the in-editor agent panel (#1210) * chore(edit): remove the in-editor agent panel * style: apply prettier formatting * fix(agent): report the runtime as unusable without a database (#1207) * fix(agent): report the runtime as unusable without a database * style: apply prettier formatting * feat(agent): add image, video and pptx import tools (#1211) * feat(workbench): add the agent chat surface (#1205) * feat(workbench): add the agent chat surface * docs(workbench): write the ported comments in English * fix(workbench): label the folder and rename tools on the timeline * fix(workbench): label the roster and voice tools on the timeline The reconciliation test iterates every tool the runner registers and requires a display label of its own. The roster and voice-clone tools (list_voices, set_roster, clip_audio, register_voice) reached the integration base with the roster/voice-registration tools but never gained presentation rows, so they fell through to the default branch and rendered their wire names. Port their rows from the reference implementation (labels and i18n keys verbatim) and extend the reconciliation allowlist with ROSTER_TOOL_NAMES and VOICE_CLONE_TOOL_NAMES, so a future tool cannot enter the product without a label. * feat(agent): add the material extraction lifecycle (#1212) * feat(storage): add material extraction lifecycle * feat(agent): execute queued material extraction * style: apply prettier formatting * style: satisfy prefer-const in the extraction runner * test: give material fixtures the extraction lifecycle fields The media-tools slice and the extraction lifecycle slice were each green in isolation but never compiled together: the lifecycle made derivedFrom and extraction required on AgentSessionMaterial while the media-tool fixtures predate them. * chore: remove stray task notes * fix(workbench): label the extraction lifecycle tools on the timeline * feat(workbench): add the workspace shell (#1206) * feat(workbench): add the workspace shell * docs(workbench): write the ported comments in English * i18n(workbench): align workspace keys across locales * fix(workbench): adopt the landed data layer and label the extraction tools - replace the sibling-slice seam stubs with the real data-layer modules - drop ambient declarations now shadowed by landed files - port timeline labels for the extraction lifecycle tools from the reference - align the new i18n keys across all locales * ci: retrigger * feat(api): folder routes, stage-meta viewer surfaces, and the material upload contract (#1215) * fix(storage): restore capability-based stage access * fix(api): bind document access to request owner * fix(agent): restore three-state stage access on the tool layer Port probeStageAccess and the three-state StageAccess (owned / foreign / missing / tombstoned) and gate every stageId-bearing stage tool on an owned probe, mirroring the reference per tool: - move_to_folder, rename_stage, read_stage_outline refuse a non-owned stage with the single not-yours message before touching the store. - The course/DSL toolset and the roster toolset are wrapped by withOwnerStageAuthorization: read_stage, patch_stage, grep_stage and every writer refuse a foreign stage with the same message and refusal shape. - Scene preview keeps its own probe and its own refusal text, and is registered beside the course toolset (never double-gated). - The runner injects one probe factory at the three call sites. Tests: the dsl cross-owner test premise (a foreign stage is readable by id) encoded an invented capability-read policy that the reference does not have at the tool layer; it now asserts foreign read/patch/grep are all refused while the owner still reads. Curriculum cross-owner assertions were already the reference's and now pass with the probes in place. * docs: correct per-file test counts in the fidelity report * test: fix type errors in stage-access fidelity test * test: adapt media-tool and gate suites to the owner-scoped store seam * feat(api): add owner-scoped course-folder HTTP routes Port the reference implementation's /api/folders family (list, create, rename, delete with ungroup/remove modes, and folder membership) onto the owner-bound document store, replacing its provider-based auth with the existing withRequestOwnerId / owner-scoped store seams. The storage package's folder store grows the pieces the routes need: DocumentFolder.order (schema column + max+1 assignment + ordering), renameFolder, deleteFolder(mode) with captured member ids, and setStageFolder(stageId, folderId | null) with idempotent un-filing. FolderNameError moves into folder-name-validation.ts (stage-storage re-exports it, keeping import sites intact). Every route gates on the configured agent runtime (plain 404 when off or unconfigured), keeps the reference's machine codes and envelopes, and is covered by gate tests plus a behavior suite. * feat(api): add stage-meta viewer surfaces for the classroom Port the reference implementation's viewer-facing stage state — can-edit / collected / published / generation-complete — on top of the stage-access base (stage_meta + tombstones). stage_meta gains published_at and generation_complete columns plus a stage_bookmarks table; the reference's deployment-specific origin/claimed_at columns are stripped. New gated routes: GET /api/stage-meta/[stageId] (per-viewer facts, 404 for absent/tombstoned, never returns the owner id), GET /api/stages/[id]/status, POST generation-complete / publish / unpublish (owner-only), POST /api/bookmarks. The resolver lives in lib/server/stage-access.ts. Wiring: a fetchStageMeta client with the reference's three-outcome contract, stage-store isOwner/isBookmarked/readOnly fields (upstream single-user defaults, no-op until the sidecar answers) plus setViewerAccess, the classroom apply path computing readOnly = !(isOwner || isBookmarked), the Stage editability gate, and a sidecar probe after each classroom load. A sidecar 'absent' answer keeps the editable default here because the classroom also serves local-only courses; server writes stay owner-enforced. * feat(api): port the reference material upload contract Rewrite POST /api/materials to the reference implementation's upload shape so the workbench uploader (uploadWorkbenchMaterial, which posts no session id and expects a flat 201 view) works unchanged: owner-scoped upload with mime normalization/validation (415), per-class size caps checked on the declared content-length and the streamed body (413), empty body (400), quota (429), sha256 reserve->store->finalize lifecycle with abandon on failure, flat { materialId, originalName, bytes, mime, extraction } 201, and an x-request-id echo. Adds the owner-scoped material library (owner_material table + quota + 24h lazy sweep, bytes in the host's asset registry as the neutral replacement for the reference's object-storage byte path) and the material cap configuration. The session-scoped GET list is left as-is; the reference's owner-material extraction worker is not ported (the branch's session-material extraction lifecycle already covers extraction). Gate tests now cover all 23 persistence routes across the three runtime env states; the materials behavior suite pins the new contract. * feat(media): add an optional local ffmpeg media extractor (#1213) Adds a local ffmpeg/ffprobe pipeline as a second media extraction provider behind the extractor registry, ported faithfully from the reference implementation: duration probing, keyframe-safe chunking, per-chunk ASR with timeout and deadline budgets, and timestamped transcript assembly. - Availability probing feeds the registry's candidate selection: the provider simply is not a candidate when ffmpeg/ffprobe are absent. - With neither ffmpeg nor a cloud provider configured, extraction fails with an actionable message naming both enablement paths. - Media materials route through the same extraction lifecycle and lease fence as documents; no parallel queue. - Tests inject the executable resolver so the missing-ffmpeg path is the default-tested one; the real pipeline test is skip-if-unavailable. - @openmaic/storage 0.13.0 -> 0.14.0 (media routing in the material lifecycle surface). * feat(storage): per-scene monotonic revisions via database triggers (#1214) * feat(storage): per-scene monotonic revisions via database triggers Restore the reference implementation's freshness granularity: a per-scene monotonic revision maintained by database triggers, so every writer (HTTP routes, agent tools, jobs, manual SQL) bumps it without application cooperation. - Companion revision tables + trigger functions in the storage package's idempotent schema bootstrap, with the lock-order invariant, pg_notify wakeup and the suppression switch for batch writers. - ensureDocumentSchema gained a dollar-quote-aware statement splitter. - The freshness and manifest routes serve per-scene revisions. - Mutation-verified: dropping the triggers turns the revision tests red. - @openmaic/storage 0.13.0 -> 0.14.0. * fix: forward the freshness manifest through the owner-bound store * feat(workbench): add the Pro entry points and preserve the mode-transition semantics (#1208) * feat(workbench): add the Pro entry points * feat(workbench): preserve Pro mode transition semantics * fix(workbench): drop ambient declarations shadowed by landed slices * fix(workbench): drop ambient declarations shadowed by the landed shell * feat: port workspace shell sibling modules Port the 16 leaf modules the Pro workspace shell imports but that were only ambient-declared, replacing the compile-time bridge with real implementations adapted from the sibling-slice reference: pure workbench helpers (session title, rail tab, course-chat bootstrap, created-course tabs, course-tabs memory, workspace navigation, pane navigation, pro-edit sizing, existing-course minting, first-message session), the neutral brand context and course-rename server API, the server-action session delete, the home discovery hook, the classroom pane host with its load-policy leaf, the theme toggle and floating-layer owner, plus the floating-layer-owner wiring the dialog/dropdown/tooltip portals stamp. Also add the workbench-shell locale copy for all 12 locales, port the reference tests for the ported modules, and drop types/workbench-sibling-slices.d.ts now that every declaration has a real implementation. * docs: keep ported comments in English and deployment-neutral * docs: announce 1.0.0 and refresh the feature overview (#1216) * docs: announce 1.0.0 and refresh the feature overview * docs: finalize 1.0.0 README after feature merge * fix(agent): control-plane routes answer 404, not 500, without a database The agent control-plane routes gated only on the runtime flag, so an enabled-but-unconfigured deployment (flag on, DATABASE_URL empty) answered 500 from a store that cannot connect. Gate them on the configured check instead, matching the stage/material routes: the whole surface is cleanly absent until both the flag and the database are present. The status probe keeps reporting both bits. * test: mock both runtime gate exports in the control-plane route suites * fix(agent): abort in-flight TTS on cancel and bound each provider request with a timeout (#1217) The generate_tts / scene-tts path checked the runner's AbortSignal between actions but never created the provider HTTP requests with it, so a session cancel left a hung synthesis fetch in flight until a restart repaired the tool result. Thread the signal end-to-end: TTSModelConfig carries an optional signal, generateTTS combines it with a per-request timeout (TTS_REQUEST_TIMEOUT_MS, default 30s, ported from the reference runtime's TTS bounds) via AbortSignal.any, and every provider fetch (openai, azure, glm, qwen incl. voice-clone + audio download, voxcpm, minimax, doubao, elevenlabs, lemonade) is created with that signal. A timeout now fails the tool call with TTSRequestTimeoutError (a clear retryable error) instead of wedging the session; a caller cancel propagates as the interruption so the runner settles the session as cancelled without a restart. Tests: hung-provider simulation rejects at the timeout with the retryable error; abort mid-flight aborts the captured request signal and surfaces the interrupted shape; removing the signal wiring makes the abort tests fail (red), restoring them turns green. * fix(workbench): PG-mode home listing via owner stages; keep the interrupted terminal course card (#1218) Finding 1: with server persistence on, listStages resolved to the generic GET /api/persistence/documents listing, which the capability model deliberately answers 403 FORBIDDEN_DOCUMENTS for (reads by id, listings owner-only). The home/workspace library now lists through the owner-scoped GET /api/stages surface (same anonymous-owner cookie the workbench uses) when server persistence is enabled; the server-side 403 is untouched. Finding 2: a run interrupted (session_interrupted) and repaired (session_resumed) that ends cancelled before agent_end stranded its pending classroom sightings, so the timeline's terminal card lost the course the answer produced. session_end (cancelled) now flushes the pending sightings into the same course card set agent_end paints, before the stopped caption. * chore(workbench): remove the bookmark concept and the saved-courses drawer (#1219) * chore(classroom): remove the bookmark ('collected') concept entirely The stage-meta viewer port introduced a bookmark surface (stage_bookmarks table, POST /api/bookmarks, the isBookmarked sidecar field, and a readOnly rule that let a saved course stay editable). The product has no such concept, so remove it as a closure: - delete the /api/bookmarks route and the stage_bookmarks table plus its query helpers from the persistence bootstrap - drop isBookmarked from GET /api/stage-meta/[stageId] - simplify the classroom read-only rule to readOnly = !isOwner across the sidecar client, ownership signal, classroom load, stage store and the classroom page - keep publish/unpublish, generation-complete, isOwner and isPublic exactly as they were - update the gate and stage-meta route suites and the README mentions The workspace rail's Bookmark glyphs and comments describe the upstream saved-courses (favorites) section, which is driven by isOwner and renders no collect affordance; they are kept as unrelated homonyms. * chore(workbench): remove the saved-courses drawer UI The first pass removed the bookmark data model but kept the rail's "Saved courses" drawer, judging it a separate surface driven by `isOwner === false`. The home/workspace listing is owner-scoped, so that flag can never occur: `allSaved` is permanently empty and the drawer (plus the collapsed-rail Bookmark mini-button) is a dead affordance. Remove it: the SavedDrawer component and its mount, the savedOpen / savedSection state, the allSaved / matchedSaved derivations, the 'saved' variant of the course-list renderers, the mini Bookmark glyph, the drawer-only CSS, and the drawer's i18n keys from all 12 locales. The courses tab is now exactly one folders tree. The authored/favorites split in workspace-tree.ts goes with it; the tree module no longer reads `isOwner`. The discovery course type keeps the field — the shell still reads it for read-only gating. Upstream has no collect concept; the drawer could only ever render empty here. The reference implementation HAS this drawer (its favorites come from its account system), so this removal is a deliberate upstream product decision, not a fidelity bug. * fix(workbench): restore the attach entry, add the rail settings entry, pin all three entry points (#1221) * fix(workbench): restore the composer attach entry by gating it on the live runtime The AttachButton's rollout probe read a `materialsEnabled` field that this branch's /api/agent/runtime never answers (the materials routes gate on the runtime itself, like the stages), so the gate could never pass and the attach button never rendered — the Pro launch and chat composers showed only the @-mention and enhance glyphs. Substitute the field with the runtime's `enabled` value, which IS the upload action's precondition: POST /api/materials answers 404 whenever it is false, so the render condition now equals the action precondition (no dead button). The button's label (`proMode.attach`) is a user-visible string that becomes visible again; port the reference implementation's own translations verbatim into the 11 locales that still carried the Chinese copy. * feat(workbench): add the settings entry to the rail's bottom-left cluster The reference's rail foot carries a cluster of utilities (its saved-courses drawer, the language switcher, the display toggle). This branch removed the drawer — it could only ever render empty here — and the product decision is to fill that freed spot with the settings entry. Add a settings trigger to the foot cluster (expanded rail, beside the language and display toggles, and on the collapsed strip) and mount the model/provider SettingsDialog in the rail, wired to the trigger. It is the same dialog the classic home opens from its header pill; the workspace had no settings entry of its own, so nothing is duplicated within a surface. * test(workbench): pin the restored upload, attach, and settings entry points Covers the three restored entry points: - the courses-tab upload control: rendered beside the course name filter, wired to the discovery hook's ZIP import trigger, disabled while an import runs, and gated by the same condition as its action (the courses tab); - the composer attach control: an actual render of AttachButton under both probe answers (visible when the runtime says the upload path is live, hidden otherwise), its mounts in the launch and chat composers, the branch's runtime-field substitution in the probe, and the reference's own `proMode.attach` copy in all 12 locales; - the settings entry: the trigger in the rail's foot cluster (expanded and collapsed), beside the language and display toggles, opening the SettingsDialog the rail mounts. * chore(config): the Pro workbench flag implies the MAIC Editor gate (#1223) A workbench build without the editor toggle has no way to edit a course: enabling NEXT_PUBLIC_PRO_WORKBENCH_ENABLED while forgetting NEXT_PUBLIC_MAIC_EDITOR_ENABLED produced exactly that split-brain bundle. The workbench IS Pro mode, so its flag now implies the editor gate; the standalone flag remains for deployments that want the classroom editor without the workbench. Documents both flags in .env.example. * fix(agent): wake SSE tails and the runner on durable deltas (streaming fidelity) (#1222) The Pro workbench chat did not stream: the session/owner SSE routes polled the durable event log on a 5s/30s clock with no wakeup, so message_update deltas (written at 150ms cadence) reached the browser in poll-sized blocks and the thinking strip only mounted after the whole reasoning text had accumulated. Port the reference's LISTEN/NOTIFY delta path: - storage: add in-transaction wake hooks (onSessionEventAppended, onOwnerEventAppended, onCancelRequested) so a host queues pg_notify in the same transaction as the durable append; align readEventsAfterForReplay to rank the bounded page so the first delta after the cursor is always kept (the live tail can never starve). Bump @openmaic/storage to 0.18.0. - app: port the process-wide event-notify bus (dedicated LISTEN client, self-check probe, reconnect backoff; notify through the storage transaction surface), wire the store hooks, subscribe both SSE routes before the initial read with the reference's initializing gate, and give the runner one {kind:'session'} subscription whose wake runs the cancel check and the message drain. Polls stay as the lossy-NOTIFY backstop. - lifecycle: start/stop the bus from instrumentation. Tests: storage hook + compaction contract; route wakeup latency; runner wakeup wiring with a fake agent; bus unit tests; PG contracts proving a real append wakes the routes and a live SSE route forwards a message_update on the wakeup, and that a rolled-back append never wakes. Also fix the pre-existing park-attempt-budget PG test TRUNCATE (missing CASCADE against newer FK tables). * fix(storage): asset writes self-deadlocked against pooled PostgreSQL (#1225) * fix(storage): refuse the non-transactional byte-write deadlock configuration A byte store whose plain write() runs on its own pooled connection cannot be invoked from inside a registry write transaction: after the transaction has claimed the blob-row lock, that write blocks on the lock the transaction just took while the transaction waits on the write - a self-deadlock PostgreSQL cannot detect (one side is idle in transaction). There is no lock-safe ordering for such a writer: bytes must be written after the row claim (writing before it lets the collector delete the bytes while the upsert waits), and any second-connection write after the claim is the deadlock. The configuration is therefore detected and refused: - AssetByteStore gains writesOutsideRegistryDatabase?: true, declaring that the layer's plain byte operations cannot contend for the registry's row locks. - PgAssetStore refuses put()/replace() up front (and defends coordinatedWrite) when the byte store has no writeWith and does not declare the flag, throwing a clear configuration error before any row is claimed. - The collector mirrors the guard on its delete path (deleteWith or a declared out-of-registry layer, else a configuration error). - The object store declares the flag (its out-of-transaction write remains legitimate); the in-registry PostgreSQL byte column provides writeWith / deleteWith instead. - Write transactions (put/replace/remove) set SET LOCAL lock_timeout = 30s so any future lock-contention variant fails loudly instead of hanging. Bumps @openmaic/storage to 0.18.0. * fix(persistence): forward the transactional byte methods through the lazy asset byte-store wrapper The no-bucket case of lazyAssetByteStore returned a bare { write, read, delete } and dropped writeWith/readWith even though the underlying PgAssetByteStore has them. The registry's hasTransactionalWriter duck check then failed and put() fell back to the byte store's own pooled connection, which blocks forever on the blob-row lock the registry transaction just took when the bytes live in the same PostgreSQL - the production self-deadlock. The no-bucket layer is statically PgAssetByteStore, so its transaction-pinned methods are forwarded eagerly (typed against the real signatures via PgForwardedByteStore). The bucket case keeps its lazy-probing semantics: no transactional writer exists there, the signed-URL method stays absent or lazy exactly as documented, and the wrapper now declares writesOutsideRegistryDatabase so the registry may run the plain write inside its transaction. New tests pin the wrapper's transactional capability red-to-green and assert put()/resolve() route byte traffic through the transaction-pinned queryable. * fix(home): cap the generate-prep ingest drain at 3s so Generate never waits the full server budget The classic home flow's Generate click drained in-flight ingests for the full 15s server budget. Cap the wait at GENERATE_DRAIN_CAP_MS (3000ms, documented as a UX bound) and reuse the existing timeout fallback: sources that miss the cap proceed on the legacy byte path and each late-resolving id is released. * chore(storage): bump to 0.19.0 over the concurrently landed 0.18.0 * fix(agent): bound every tool call with a timeout; never resurrect a cancelled session (#1226) * fix(agent): bound every tool call with a global timeout and settle it on cancel A tool await that neither resolves nor rejects wedges the session forever: the lease keeps heartbeating and the driver never reaches its next cancel checkpoint. Race every tool execution (in buildAgent) against a hard budget (OPENMAIC_AGENT_TOOL_TIMEOUT_MS, default 10 min, per-tool overrides for known long runners) and against the caller's AbortSignal, so even a signal-ignoring await cannot keep a cancelled session running. On timeout the call rejects with AgentToolTimeoutError; the agent loop turns the rejection into a structured error tool-result the agent can retry or proceed from, and the abort signal is delivered to the tool's in-flight work through a derived controller. Zombie-tool updates after settlement are dropped. * fix(storage): never re-lease a cancel-requested session; settle it as cancelled on claim The claim scan treated a session with cancel_requested_at set as a normal claim candidate: after a restart it re-leased the same session for attempt N+1 and resumed generating despite the pending cancel. claimNextSession now settles such candidates as cancelled under the claim lock (status cancelled, attempt reset, lease and cancel request cleared, terminal session_end event and owner projection) instead of leasing them, then keeps scanning. Bump @openmaic/storage to 0.18.0. * docs: takeaway-style 1.0.0 announcement with bilingual guide links The 1.0.0 head is now a short takeaway block — badge links to the official user guides (English and Chinese), five one-line highlights, and pointers into Features and the workbench setup section — instead of six dense paragraphs. The detailed provider-neutrality and freshness notes move into the Features workbench section, phrased database- neutrally (the announcement no longer names a specific database). Release date corrected to August 27. * fix(workbench): restore editor chrome, mode transition, streaming, materials, mentions, folders (#1229) * fix(workbench): wire workspace folder routes * fix(editor): restore reference workbench chrome * fix(workbench): persist composer materials and course refs * fix(workbench): preserve live reasoning frames * fix(persistence): back off failed streaming saves * chore(workbench): retire stale slice seams * test(editor): cover element pin layer * chore(storage): bump to 0.21.0 for the user-message ref/material fields * chore(editor): translate ported code comments to English * fix(agent): fence durable tool writes and consume cancel requests atomically (#1230) * fix(agent): enforce provider force-off in agent tools and scrub vendor identity from tool results (#1231) * fix(materials): serialize per-owner quota reservations and make crashed uploads reclaimable (#1232) * fix(editor): resolve dock-bar i18n keys, remove dock height drag, wire element referencing (#1233) * fix(workbench): send the opening session message exactly once with refs intact (#1234) * feat(editor): port timeline TTS preview single-flight and voice-all state latching (#1235) * fix(media): restore the reference classic media chain (#1236) * fix(import): adapt imported PPTX canvas size so decks render without overflow (#1237) * fix(editor): complete element referencing — renderer DOM contract and GenUI picking aligned with the reference (#1238) * test(providers): reconcile the provider-config vendor-token debt count after the main merge The integration line's AK/SK fallback for the managed document provider adds occurrences that main's allowlist snapshot predates. Same mixed-composition debt category the group already documents; no new vendor behavior. * test(providers): reconcile vendor-token debt counts with the integration line The main-merge brought main's neutrality-guard snapshot next to integration features it predates (media-extractor fallback chain, local voice-profile deletion semantics, the enabled-TTS helper). Same debt categories the guard already documents; counts updated to the guard's own tally and two grouped entries added. No new vendor behavior. * fix(agent): carry reasoning through the completions dialect so the thinking strip renders (#1239) * feat(skills): add Feynman and spiral curriculum methods (#1240) * feat(agent): port missing reference tools and skills (parity audit) (#1241) * feat(media): retire asset-registry wiring; media and materials follow the reference byte model (#1242) * fix(classroom): center adapted canvases in the stage and send back navigation home during generation (#1243) * feat(settings): skill management with real list, download, delete, and upload (#1244) * feat(settings): skill management section with real list, detail, and zip download * feat(skills): owner skill delete and upload across storage, API, and settings * fixup! feat(settings): skill management section with real list, detail, and zip download chore: neutralize a reference note in the settings header comment * fix(media): persist origin-independent classroom-media references from the agent runtime (#1245) * feat(editor): float the insert toolbar in the outer frame with collapse (#1246) The insert strip was bounded to the slide card, so it could only ever sit on top of slide content: the card's overflow clipped it and it could not be parked in the padding beside the slide. Move it into the studio frame the element picker's panel already roams (CanvasOverlayPortal + the frame selector), so both canvas overlays share one bounding container and their handles behave the same. While picking, the strip rises over the picker and goes inert, which is the z-order CANVAS_OVERLAY_Z already documents. Add a fold beside the grip: the chevron collapses the strip to that grip row and back, with the buttons unmounted rather than hidden. The fold is session-local state owned by EditShell, next to the drag offset, so a surface swap keeps it; nothing is persisted. Expanding a strip parked at the bottom edge re-clamps through the same bounds rule the keyboard move uses. Co-authored-by: Claude Fable 5 <noreply@anthropic.com> * fix(workbench): align the chat timeline's left edge with the composer (#1247) * fix(agent): fence session claims while an ask_user question is outstanding (#1248) * fix(agent): settle-time rescue tracks real delivery instead of a count offset (#1249) * fix(persistence): migrate owner_material to oss_key and drop legacy asset_id (#1250) * docs(readme): surface the 1.0.0 user guide badges at the top (#1253) * fix(workbench): show newly created folders in the sidebar without reload (#1254) * docs(readme): add the release version prefix and drop the opt-in framing * fix(workbench): single-source the chat gutter so timeline and composer share a left edge (#1255) The transcript and the composer each established their own column: their own `px-*` gutter and their own `mx-auto w-full max-w-*` centering wrapper. Equal padding values were never enough, because the two columns are centered inside different containing blocks — the transcript's is a scroll container, whose content box is narrower than the composer footer's by the scrollbar's width: transcript text left = pad + (pane - 2*pad - scrollbar - measure) / 2 composer box left = pad + (pane - 2*pad - measure) / 2 The padding cancels out of the difference and what remains is `-scrollbar/2` at every padding value, so the transcript sat half a scrollbar to the left of the composer and tuning the two paddings against each other could not move it. The column is now established once, by the nearest common ancestor of both (`chatColumn`), and the scroll viewport and the composer footer are siblings inside it that add no horizontal inset of their own. The cap carries the gutter on top of the 760px reading measure, so the text column keeps its width. The handed-over question row drops the padding that indented it past the agent's prose; framed rows keep their own inner padding, which is what a card's border sitting on the column edge means. Co-authored-by: Claude Fable 5 <noreply@anthropic.com> * fix(workbench): lock pane-embedded classroom to edit mode (#1256) The workspace right pane painted the full learning chrome — speed control, play button, learner avatars, mic bar — for a course the agent had just created, then flipped to edit once the first scene landed. resolveStageChromeMode treated playback as the DEFAULT branch for a hosted classroom, so every shortfall fell into it: a course whose tab opens at stage_link time has no scenes yet, so currentSceneId is null and isHostedSceneEditable is false. A folded pane parked the playback root behind the fold and cross-faded it out over the pane on unfold, and a failed editor chunk dropped into playback permanently. Lock it at the pane instead of defaulting per entry path: - WorkbenchPanelProvider — the single element that mounts a classroom into the workspace — publishes editPinned (visible && !playback). Every entry path passes through it, so none of them decides. - The hosted resolution can no longer degrade to playback. Start Learning (workbenchLearning, new input, split out from pane visibility) is the one door; everything else resolves between the neutral loading shell and edit. - Stage's chrome dispatch is exhaustive on chromeMode, so the playback root is no longer the else-branch of a condition about the current scene. No flicker: chromeMode is resolved during render, and preloadEditor now answers synchronously (isEditorPreloaded) so a remount with the chunk already registered paints edit on the first frame. A failed import is no longer cached forever, so the lock cannot strand the pane. Standalone classrooms keep their stored mode unchanged. --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> | 2 个月前 | |
fix(export): surface video render rejection reasons (#1414) * fix(export): surface video render rejection reasons * fix(export): keep render diagnostics out of user toasts --------- Co-authored-by: wyuc <wang-yc24@mails.tsinghua.edu.cn> | 1 个月前 | |
fix(playback): queue widget messages until iframe ready (#1532) * fix(playback): queue widget messages until iframe ready * fix(playback): preserve iframe queue in StrictMode --------- Co-authored-by: wyuc <wang-yc24@mails.tsinghua.edu.cn> | 1 个月前 |
| 文件 | 最后提交记录 | 最后更新时间 |
|---|---|---|
| 1 个月前 | ||
| 20 天前 | ||
| 3 个月前 | ||
| 30 天前 | ||
| 1 个月前 | ||
| 9 天前 | ||
| 3 个月前 | ||
| 2 个月前 | ||
| 1 个月前 | ||
| 3 个月前 | ||
| 14 天前 | ||
| 28 天前 | ||
| 1 个月前 | ||
| 28 天前 | ||
| 2 个月前 | ||
| 1 个月前 | ||
| 1 个月前 |