Commit Graph
174 Commits
Author SHA1 Message Date
wyucandClaude Opus 5.5 5312c2b4b4 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>
2026-10-02 20:12:43 +08:00
fa47efe1b3 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>
2026-09-29 17:51:03 +08:00
HEOJUNFOandClaude Opus 5.5 1c70e86a13 fix(media): poll and download Veo videos the Gemini API way (#1695)
* fix(media): poll and download Veo videos the Gemini API way

The Veo adapter targets generativelanguage.googleapis.com but polled
with Vertex AI's models/{model}:fetchPredictOperation and read inline
response.videos[].bytesBase64Encoded. The Gemini API reads operations
with GET v1beta/{name} and returns
response.generateVideoResponse.generatedSamples[].video.uri, fetched
with the same API key.

- Poll with GET v1beta/{operationName}.
- Download the sample URI with x-goog-api-key into a data URL, always
  through the configured base URL's origin and without following
  redirects, like the other adapter calls. Inline bytes are still
  accepted when present.
- Report RAI-filtered operations as a failure with their reasons.
- Catalog: Gemini API model IDs for Veo 3.1 (-preview, plus Lite) and
  only the 16:9 / 9:16 aspect ratios the API accepts.

Fixes #1694

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(media): follow the Veo download redirect through a validated transport

The Gemini API file URI can answer with a redirect to storage (Google's
own example downloads it with `curl -L`), which the download refused, so
a generation could fail after a successful poll.

- VideoGenerationConfig gets an optional downloadFetchImpl. fetchImpl is
  the pinned transport that refuses every 3xx, so the download needs its
  own one.
- lib/server/media-provider-fetch.ts adds mediaDownloadFetch and
  managedMediaDownloadFetch: providerFetch with redirects followed, each
  hop re-validated under the operator address policy, pinned to the
  vetted DNS answers, and stripped of x-goog-api-key once it leaves the
  origin (fetchWithRedirectValidation). withVideoProviderFetch installs
  both transports.
- The server video call sites (the generate route, classroom media
  generation, the agent runtime) inject it; the adapter still does not
  import server code.
- downloadVideo follows redirects only through the injected transport and
  keeps redirect: 'manual' + assertNotRedirected without it.
- Test: a 302 to another origin yields the data URL and the second hop
  carries no x-goog-api-key; without the transport the 302 is refused.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 23:00:04 +08:00
wyucandClaude Opus 5.5 cd7d6a1a59 fix(providers): pin caller-chosen provider requests and keep upstream detail out of errors (#1704)
* fix(pdf): pin verify-pdf-provider probes to the strict provider transport

The MinerU Cloud and self-hosted connectivity probes validated a
caller-supplied base URL once and then issued a plain fetch that resolved
DNS again, so a rebinding hostname could pass validation and connect to a
loopback or private address. The response also echoed the target's status,
its 401/403 body, and per-errno connection errors.

Both probes now go through providerFetch with reject-redirects and the
operator address policy (the same ALLOW_LOCAL_NETWORKS policy the route
already validates against), so the connect address is pinned to the vetted
DNS answers. A 3xx still maps to REDIRECT_NOT_ALLOWED. Authentication
failures return a fixed message, every other connection failure returns a
single generic message, and the success payload no longer includes the
target status. Details are logged server-side only.

Tests drive the real route, guard and pinned transport against loopback
servers, covering rebinding, auth body suppression, identical
refused/not-found/timeout answers, redirect refusal and the managed path.

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

* fix(pdf): pin self-hosted MinerU parsing to the strict provider transport

Self-hosted MinerU parsing validated a caller-supplied base URL once and
then posted the document with a plain fetch that re-resolved DNS and
followed redirects, so a public host could hand the upload (and API key)
to a loopback or private address.

The /file_parse request now goes through providerFetch under the operator
address policy with redirects refused. Transport failures, policy blocks
and redirects collapse into one fixed message; an unknown error status is
reported without its body (the missing-dependency classification stays),
a non-JSON body no longer surfaces the parser's input snippet, and the
empty-result error no longer lists response keys. MinerU Cloud control
plane, upload and ZIP errors likewise stop quoting raw response bodies.

Tests drive the real parse route and pinned transport against loopback
servers: rebinding, redirect refusal, body suppression, the multipart
upload shape and the managed path.

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

* fix(tts): pin the Azure voice list request to the strict provider transport

The voice list request validated the caller-supplied base URL once and then
fetched it with a plain fetch that resolved DNS again. It also mirrored the
target's HTTP status as the route's own status and returned the full error
body, plus any JSON the target answered on success.

The request now goes through providerFetch under the operator address
policy with redirects refused and a 20s deadline. Non-2xx answers return a
fixed 502 (authentication failures get their own fixed message), only a
JSON array is returned as the voice list, and transport failures share one
fixed 500 message. Details are logged server-side only.

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

* fix(providers): pin model-list probing to the strict provider transport

Model discovery validated the caller-supplied base URL and models URL once
and then issued a plain fetch per candidate, which resolved DNS again. Non-2xx
answers carried up to 512 bytes of the provider's body into the route
response, and a non-JSON success body surfaced the parser's input snippet.

fetchModels now defaults to providerFetch under the operator address policy
with redirects refused (a refused hop keeps the REDIRECT_NOT_ALLOWED
contract), and accepts an injected transport for tests. Error bodies are
never read; the route keeps its 401/404/403 contracts, reports other HTTP
failures by status class only, answers a non-JSON list and every transport
failure with fixed messages, and returns 400 for a malformed request body.

The rejected-redirect detector is shared from the transport module instead
of being copied per caller.

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

* fix(media): run image/video provider requests on the strict provider transport

The image and video routes validated a caller-supplied provider base URL
once, then every adapter issued plain fetch calls that resolved DNS again.
Adapter errors carried full non-2xx bodies into the route responses, the
auth-only probes returned the 401/403 body, and connectivity failures
echoed the transport error text.

Adapters now take a `fetchImpl` from their config and use it for every
provider request (submit, poll, download and connectivity probe). The
adapters are also imported by the settings UI, so they cannot import the
server transport; every server caller (the four routes, classroom media
generation and the agent runtime tools) injects mediaProviderFetch, which
is providerFetch under the operator address policy with redirects refused.

Connectivity results are fixed text: authentication failures, redirects,
other HTTP statuses and transport failures each have one message, and no
provider body is read. The generation routes log the adapter error and
answer a fixed message, keeping the content-safety classification. A
malformed Kling key is still reported before any request.

The rejected-redirect detector moves to a dependency-free module so the
browser-bundled probe helper can share it with the server transport.

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

* fix(llm): pin LLM calls to a client-supplied base URL

resolveModel validated a client-supplied base URL once and then handed the
AI SDK a redirect-validating fetch whose only dispatcher was the
timeout-only agent, so connect-time DNS was resolved again without the
guard. verify-model also echoed the provider's error message (which can
carry its response body) and distinguished errno classes.

A client-supplied base URL now gets providerFetch under the operator
address policy with redirects refused. The pinned dispatcher accepts
headers/body timeouts, and this path uses the same 15-minute budget as the
default LLM dispatcher; the transport's body normalization and streaming
are unchanged. Operator-configured endpoints keep the existing
redirect-validating transport.

verify-model now classifies failures by the provider's HTTP status only
(401/403, 404, 429, other status class) and answers every transport or
parse failure with one fixed message.

Tests stream a real SSE chat completion through the pinned path and check
that deltas arrive before the server finishes, plus rebinding, redirect
refusal and the managed path.

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

* fix(pdf): accept only official AliDocMind endpoints from clients

AliDocMind requests go through the vendor SDK, which builds its own HTTPS
agent and resolves the endpoint itself, so a client-supplied endpoint that
passed the URL guard could not be pinned to the validated address. The
credential check also returned the SDK's error code and message, which
told a refused port from a TLS failure or a timeout.

When the provider is not server-managed, verify-pdf-provider, parse-pdf and
extract-document (document and media paths) now accept a client endpoint
only when it is an official docmind-api.<region>.aliyuncs.com host over
https with no port, path or credentials, and answer INVALID_URL before any
SDK call otherwise. The accepted endpoint is passed on as the normalized
host. Server-managed endpoints are unchanged. Credential verification
failures now return fixed messages and log the SDK detail.

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

* fix(agent-runtime): download provider result URLs under the strict public policy

The agent-runtime image and video tools downloaded the URL a provider
returned with a plain fetch, validating each redirect hop with the operator
policy but connecting without pinning, and accepting http. A data: URL
from an adapter that inlines its result (the OpenRouter video adapter) was
rejected instead of decoded.

The classroom download helper's policy is extracted into
fetchProviderResultUrl: data: URLs are decoded locally, anything else must
be https and pass the strict public policy (allowLocalNetworks false,
regardless of ALLOW_LOCAL_NETWORKS), and the request goes through
providerFetch, which re-validates redirect hops under the same policy and
pins connect-time DNS. The agent-runtime tools and the classroom helper
share it; the existing bounded reads and content-type checks are unchanged.

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

* docs(changelog): note pinned provider transports and body-free provider errors

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

* fix(providers): keep vendor endpoint rules out of provider-neutral modules

The provider-neutrality guard keeps vendor knowledge out of the capability
routes and model resolution. The AliDocMind endpoint rule now sits behind
provider-neutral helpers (checkClientDocumentExtractorBaseUrl and
checkClientMediaExtractorBaseUrl), which apply the official-endpoint rule
to extractors whose SDK cannot be pinned and the URL guard to the rest.
The pinned LLM transport policy moves out of resolve-model into its own
module. Behavior is unchanged.

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

* fix(providers): hold IP-literal request hosts to the transport address policy

The pinned dispatcher judges hostnames in its connect-time lookup, but Node
never runs that lookup for an IP-literal host, so a request to a loopback or
private IP reached it without the local-network opt-in. The provider transport
now checks an IP-literal origin against the same policy before connecting,
so it enforces the policy on its own rather than relying on every caller to
validate the URL first.

Tests that reached loopback IP literals without the opt-in now set it, as a
self-hosted deployment would; cloud metadata stays refused under every policy.

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

* fix(providers): let operator-configured local providers connect without the opt-in

Image/video providers and self-hosted MinerU moved to the pinned transport
under the operator address policy for every base URL, so a server-managed
endpoint on a local network (for example a local Lemonade image server or a
MinerU container) stopped working unless ALLOW_LOCAL_NETWORKS was set.

A server-managed base URL is operator configuration: it now runs with local
networks allowed, still pinned and still refusing redirects, while cloud
metadata and reserved ranges stay refused. Caller-supplied base URLs keep the
operator policy. Media routes pick the transport by the provider's managed
flag; server-internal media generation uses the managed transport; document
extraction carries a `managed` flag to the MinerU parsers and the PDF
verification probe.

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

* fix(llm): pin every caller-chosen endpoint and keep transport detail out of errors

An unmanaged provider picked by the caller without a base URL fell back to its
catalog default (for example a localhost Ollama or Lemonade endpoint) on the
operator transport, which neither validated nor pinned the origin. The pinned
client transport is now chosen whenever the caller picked the model or sent a
base URL, and the effective endpoint (client URL or catalog default) is
validated under the operator policy first. A model the operator selected
through MODEL_ROUTES or DEFAULT_MODEL keeps the operator transport.

Routes relay LLM error messages, and the AI SDK builds them from the fetch
failure cause and from the provider's error body. On the caller-chosen
transport a failed request now surfaces a fixed reason ("connection failed",
"request timed out" or "redirects are not allowed") with the system error
logged server-side, and an HTTP error response reaches the SDK with an empty
body and the standard reason phrase, keeping its status and headers for retry
and status classification.

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

* fix(pdf): keep MinerU Cloud envelope text out of errors and refuse query/fragment base URLs

MinerU Cloud errors quoted the endpoint's envelope `msg`, a failed row's
`err_msg` and the batch id, and the parse routes relay error messages. They now
report the context with the HTTP status or, for a rejected request, the numeric
code only; the endpoint text is logged server-side.

Provider paths are appended to a base URL as text, so a client base URL
ending in `?` or `#` (or carrying a query) absorbs the fixed path and leaves
the request target to the caller. Client-supplied provider base URLs are now
refused when they contain a query string or fragment, across the LLM, media,
document extraction, PDF verification, model probe and Azure voice routes.

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

* fix(media): bound provider data: URL results before decoding them

`fetchProviderResultUrl` decoded a provider-returned `data:` URL in full and
left the size check to the caller's body reader, so an oversized payload was
materialized first. Callers now pass their byte limit, and the payload size is
estimated from the encoded length (base64: 3/4 less padding; percent-encoded:
at least one byte per three characters) and refused over the limit before any
buffer is built, then checked exactly after decoding.

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

* docs(changelog): describe provider transport behavior changes accurately

Note which base URLs refuse redirects (MinerU Cloud API roots still follow
validated, pinned hops), the query/fragment base URL refusal, IP-literal hosts
under the address policy, validation of an unmanaged LLM provider's built-in
default, server-configured local providers working without the opt-in, HTTPS
for agent-runtime video and poster downloads, pinned requests bypassing the
environment proxy, and the fixed LLM and MinerU Cloud error text.

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

* fix(llm): bound the logged error body and map out-of-range statuses to 502

An error response from a caller-chosen LLM endpoint was read in full only
to log 500 characters, and a status of 600-999 (passed through by the
transport) made the replacement Response constructor throw. Read at most
1 KB (or 1 s) of the body before cancelling it, and report statuses above
599 as 502.

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

* fix(pdf): hold MinerU Cloud redirect hops to the operator policy for a managed root

A server-managed MinerU Cloud API root runs with local networks allowed,
and the same policy applied to every redirect it answered with, so a hop
to a private address was followed without ALLOW_LOCAL_NETWORKS.

The provider transport now takes a separate address policy for redirect
hops (`redirectAllowLocalNetworks`): each hop is validated and pinned on
a dispatcher for that policy. A managed MinerU Cloud root keeps local
access for itself while its hops use the operator policy.

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

* fix(providers): match server-configured providers by own keys only

Provider ids come from requests and were looked up on plain config
objects, so an id such as `constructor` or `toString` read an inherited
property and counted as server-configured (and its key, base URL and
models resolved from that property). All per-provider config lookups now
go through one helper that accepts only the section's own keys.

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

* fix(audio): let server-configured TTS/ASR endpoints reach local networks

Server-configured image/video, MinerU and LLM providers on a local
network work without ALLOW_LOCAL_NETWORKS, but a server-configured
TTS/ASR or voice-registration endpoint given as a loopback or private IP
literal (for example a VoxCPM server at 127.0.0.1:8000) was refused
unless the opt-in was set.

The routes and the server-side narration, classroom TTS and voice-clone
paths now mark server-configured providers as `managed`; their endpoint
requests run with local networks allowed, still pinned, with cloud
metadata and reserved ranges refused, and redirect hops held to the
operator policy. Client-supplied endpoints keep the strict public
policy, and an unmanaged provider's catalog default keeps the operator
policy. Provider-returned result URLs are unchanged.

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

* docs(changelog): server-configured TTS/ASR local endpoints and config id lookups

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 18:01:22 +08:00
小魔andwyuc a41bbac239 feat(llm): retry once on a fallback model for retryable failures (#1614)
* feat: configurable model escalation scheduler

## Motivation

Course generation can stall when the model configured for a heavy stage (e.g. `scene-content:interactive` / `scene-content:pbl`) is repeatedly rate-limited, times out, or returns empty output. Today the operator must manually switch models in `MODEL_ROUTES` and re-trigger the page; long-running generations die on a single bad call.

This PR makes the fallback automatic and observable: an optional per-stage **escalation policy** that re-runs the failed scene once with an explicitly chosen upgrade model, and logs every decision to a durable ledger viewable from a new Settings section.

## Design

- **Disabled by default, zero behavior change.** No `data/model-schedule.json` → the engine behaves exactly as before. The config is hot-read per call (250 ms cache), so saving from the panel takes effect without a server restart.
- **Explicit escalation only.** The upgrade model is resolved through the existing `resolveModel` pipeline **outside** `MODEL_ROUTES` (unrouted model string wins), reusing the full provider/model parsing (`openai:...`, `qwen:...`).
- **Trigger semantics.** `onTimeout` requires a timeout error; `onRetryableError` accepts any retryable generation error (`isRetryableGenerationError`). Content-safety style rejections are **not** retryable and **never** escalate — the safety boundary is preserved.
- **Budget guard.** Optional `budget.dailyEscalationCap` stops escalations for the day once exhausted; per-stage `max` limits retries.
- **Ledger.** Every escalation appends one line to `data/schedule-events.jsonl` (stage, scene, base → used, error class, reason); the Settings panel shows the last 20.

## Screenshots

Settings > Model Scheduling (config present):

![Model Scheduling panel](assets/model-schedule/settings-panel.png)

Ledger after an escalation (example entry):

![Ledger with entry](assets/model-schedule/ledger-with-entry.png)

## Verification

- `pnpm lint` — 0 errors
- `pnpm check` (prettier) / `pnpm check:i18n-keys` — pass (12 locales)
- `pnpm build` — Next.js production build passes
- Engine logic script — 8/8 assertions (disabled default, policy parsing, budget guard, ledger I/O)
- API A/B flow — GET null without config; PUT template → served immediately (hot reload); DELETE → back to null

## Notes

- The config schema is minimal free-form JSON (optional `models` / `budget` / `escalation` / `strictMode`); a stricter TS schema can follow in a later PR.
- `strictMode` is reserved (not enforced yet).
- The 3-tier preset template lives in the panel's "Load 3-tier preset template" button; no example file is shipped.

* feat: configurable model escalation scheduler

## Motivation

Course generation can stall when the model configured for a heavy stage (e.g. `scene-content:interactive` / `scene-content:pbl`) is repeatedly rate-limited, times out, or returns empty output. Today the operator must manually switch models in `MODEL_ROUTES` and re-trigger the page; long-running generations die on a single bad call.

This PR makes the fallback automatic and observable: an optional per-stage **escalation policy** that re-runs the failed scene once with an explicitly chosen upgrade model, and logs every decision to a durable ledger viewable from a new Settings section.

## Design

- **Disabled by default, zero behavior change.** No `data/model-schedule.json` → the engine behaves exactly as before. The config is hot-read per call (250 ms cache), so saving from the panel takes effect without a server restart.
- **Explicit escalation only.** The upgrade model is resolved through the existing `resolveModel` pipeline **outside** `MODEL_ROUTES` (unrouted model string wins), reusing the full provider/model parsing (`openai:...`, `qwen:...`).
- **Trigger semantics.** `onTimeout` requires a timeout error; `onRetryableError` accepts any retryable generation error (`isRetryableGenerationError`). Content-safety style rejections are **not** retryable and **never** escalate — the safety boundary is preserved.
- **Budget guard.** Optional `budget.dailyEscalationCap` stops escalations for the day once exhausted; per-stage `max` limits retries.
- **Ledger.** Every escalation appends one line to `data/schedule-events.jsonl` (stage, scene, base → used, error class, reason); the Settings panel shows the last 20.

## Screenshots

Settings > Model Scheduling (config present):

![Model Scheduling panel](assets/model-schedule/settings-panel.png)

Ledger after an escalation (example entry):

![Ledger with entry](assets/model-schedule/ledger-with-entry.png)

## Verification

- `pnpm lint` — 0 errors
- `pnpm check` (prettier) / `pnpm check:i18n-keys` — pass (12 locales)
- `pnpm build` — Next.js production build passes
- Engine logic script — 8/8 assertions (disabled default, policy parsing, budget guard, ledger I/O)
- API A/B flow — GET null without config; PUT template → served immediately (hot reload); DELETE → back to null

## Notes

- The config schema is minimal free-form JSON (optional `models` / `budget` / `escalation` / `strictMode`); a stricter TS schema can follow in a later PR.
- `strictMode` is reserved (not enforced yet).
- The 3-tier preset template lives in the panel's "Load 3-tier preset template" button; no example file is shipped.

* Delete omai-upload-tree directory

* fix: ensure trailing newline in locale files

fix: ensure trailing newline in locale files

* chore: english comments for upstream review

chore: english comments for upstream review

* Delete components/settings/model-schedule-settings.tsx

* Delete lib/server/model-schedule.ts

* Delete app/api/model-schedule/route.ts

* Delete assets/model-schedule/ledger-with-entry.png

* Delete assets/model-schedule/settings-panel.png

* Add files via upload

* Add files via upload

* Add files via upload

* Add files via upload

* Add files via upload

* Add files via upload

* Add files via upload

* Add files via upload

* Add files via upload

* Add files via upload

* Add files via upload

* Update .env.example

* Enhance access control comments in .env.example

Added additional context and warnings for access control configuration.

* Add files via upload

* Add files via upload

* Add files via upload

* Add files via upload

* Add files via upload

* Add files via upload

* Add files via upload

* Add files via upload

* Add files via upload

* Add files via upload

* Add files via upload

* Add files via upload

* Add files via upload

* Add files via upload

* Add files via upload

* Add files via upload

* Add files via upload

* Add files via upload

* Add files via upload

* Add files via upload

* Add files via upload

* run ci

* Add files via upload

* Add files via upload

* Add files via upload

* Supplement. env.example

Supplement. env.example

* Add files via upload

* Update .env.example

* Update fallback notes in .env.example

Clarified fallback behavior in callLLM layer documentation.

* Update llm.ts

* Update llm-fallback.test.ts

* fix(llm): round-4 fallback hardening

fix(llm): round-4 fallback hardening — server-managed gate, content-filter refusal, APICallError unwrap stop, outlines fullStream errors

* fix(llm): rebase round-4 onto main v1.1.1

fix(llm): rebase round-4 onto main v1.1.1

* fix(llm): round4c-prettier formatting+retry test 

fix(llm): round4c-prettier formatting+retry test

* test(llm): round-4d-align outline-stream

test(llm): align outline-stream and title-generator tests with round-4 call signatures

---------

Co-authored-by: wyuc <wang-yc24@mails.tsinghua.edu.cn>
2026-09-28 17:24:55 +08:00
wyucandClaude Opus 5.5 44ad72beba fix(persistence): serve the course library without the agent runtime; exit on invalid boot config
* fix(persistence): serve the course library without the agent runtime

The course library and folder routes (/api/stages/** and /api/folders/**)
gated on isAgentRuntimeConfigured(), so a deployment with server persistence
(DATABASE_URL set, client built with NEXT_PUBLIC_PERSISTENCE=1) but the agent
runtime off answered 404 there: the home library showed an empty,
"persistence unavailable" list and folder creation failed, although imports
persisted.

These routes only need the persistence provider and the owner-bound document
store, so they now gate on isServerPersistenceConfigured(). Agent features
(/api/agent/**, /api/skills/**, /api/materials/**) keep the runtime gate, and
without a DATABASE_URL every route still answers 404.

GET /api/agent/runtime also reports `persistence`; `enabled` and
`runtimeEnabled` are unchanged and the workbench entry still keys on
`enabled`.

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

* fix(boot): exit the server when boot validation refuses the configuration

Next.js logs a throw from the instrumentation register() hook as "Failed to
prepare server" but keeps listening and answers every request with 500, so a
refused configuration (malformed asset quota, pending TTL or owner lock
waits, PERSISTENCE_SHARED_OWNER_ID without ACCESS_CODE, the removed
OWNER_AUTHENTICATOR / TRUSTED_PROXY_* variables, invalid owner auth or host
hook registrations) left a process that looked alive and served nothing.

The fatal validations now run in one validateBootConfiguration() step. When
it throws, register() calls exitOnInvalidBootConfiguration(), which prints
one "[boot] Invalid server configuration" line carrying the original message,
waits for stderr to flush, and exits with code 1. The wrapper lives in its own
module so tests stub it (or process.exit) and still assert the thrown error.
register() already returns before any validation on the Edge runtime, and
warnings (unset ACCESS_CODE, model-routing checks) never exit.

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

* docs: document library gating and exit on invalid boot configuration

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

* fix(boot): tell refused configuration apart from other startup failures

Every throw from the boot validation step was printed as "Invalid server
configuration" with its message only, so a startup failure that has nothing
to do with settings (a module missing from a standalone build, a bug in
startup code, a host registration call that throws) sent operators looking
for a bad environment variable.

Each fatal configuration check now runs through runConfigurationCheck(),
which marks what it throws as an InvalidBootConfigurationError with the same
message. exitOnBootFailure() (renamed from exitOnInvalidBootConfiguration)
keeps the one-line message for those, and prints anything else as
"[boot] Server startup failed" with its stack and cause. Both exit with
code 1.

The README (EN and zh) and CHANGELOG now list exactly the settings the boot
validation refuses, adding OWNER_CLAIM_TRIGGER, the ASSET_S3_BUCKET conflict
with a registered byte store and the sharedTeamAuthMethod() placement rule.
Also fixes a stray comment marker in feature-flags.ts.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 17:03:36 +08:00
wyucandClaude Opus 5.5 a686481e73 feat(auth): owner identity seam — composable auth methods, owner-keyed persistence, host hooks, claims
* ci: run CI for the owner identity seam integration branch

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

* feat(auth): owner identity seam, phase 1 — pluggable authenticator with today's behavior

* feat(auth): add the owner identity seam with built-in authenticators

Introduce lib/server/identity/: an OwnerAuthenticator contract
(SubjectKind, OwnerPrincipal, AuthOutcome), a single-shot, boot-validated
configureOwnerAuthenticator() registry, and the anonymousCookie and
sharedTeam built-ins that reproduce today's owner resolution exactly
(same anonymous_id cookie, owner id strings, statuses and env semantics).

Every owner-scoped route handler and the workspace Server Action now
resolve their owner through one memoized per-request resolution instead
of the three previous copies (agent-runtime/owner.ts, with-owner.ts and
the Server Action re-implementation). Publish and unpublish decide from
the principal's course:publish role instead of an anon: id prefix.

An invalid credential from an authenticator is answered with 401 on
every surface and never falls back to an anonymous owner. A source scan
keeps the anonymous owner cookie and anon: prefix checks out of code
outside the identity module.

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

* docs(auth): describe the owner identity seam and host registration

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

* fix(auth): tighten the identity boundary guard and Server Action cookies

- Scan workspace package sources too, flag imports of the concrete
  built-in authenticators outside lib/server/identity, and police the
  slice/substring/indexOf/includes spellings of an anon: id-shape check.
  Stop re-exporting the built-ins from the host-facing index.
- Refuse setCookies returned by authenticateFromContext, as the
  authenticate() fallback already did, and document that it must write
  cookies itself through next/headers.
- Let the route-test owner stub carry explicit kind/roles, and cover the
  403 forbidden branch of publish and unpublish for a signed-in owner
  without course:publish.
- README: registration conflicts fail the boot; an invalid principal is
  rejected per request with a 500.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* feat(auth): owner identity seam, phase 2 — runtime and assets keyed by the owner

* feat(auth): owner identity seam, phase 2 — runtime and assets keyed by the owner

Runtime sessions and assets on /api/persistence now take their identity
from the same memoized owner resolution as documents, instead of a
client-chosen x-learner-key behind a development bearer token that
shipped in the public bundle.

- Runtime: the storage handler's learner key is principal.ownerId. A
  path or body naming another learner answers 403 FORBIDDEN_LEARNER and
  another learner's session answers 404, whatever x-learner-key says; the
  header is no longer read. The browser learns its key from the new
  GET /api/persistence/learner-key and sends no credential of its own.
  The Pi whiteboard routes derive the same key from the owner.
  PERSISTENCE_DEV_TOKEN, NEXT_PUBLIC_PERSISTENCE_TOKEN and
  PERSISTENCE_ALLOW_INSECURE_DEV_AUTH are gone from the server path and
  the client, and server-auth.ts is deleted. Learner merge stays refused.
- Assets: allocations land in a per-owner partition (owner:<ownerId>),
  so quota is per owner and replace/delete reach only the owner's
  entries. Reads stay capability-by-id where a course viewer needs them:
  another owner's committed entry is readable while a live course
  references it. Legacy entries in the old shared partition stay
  readable by id, and may be replaced or deleted only by an owner who
  owns every course referencing them. Server-generated media is stored
  under the run owner's partition; asset-id extraction and vision image
  resolution use the same rule.
- Tombstones: runtime of a deleted course reads as absent (404, empty
  lists) and takes no new sessions or records, through a guard in the
  app's runtime composition; no package schema change.

The identity boundary scan now also fails on any source that reads
x-learner-key or the retired development token variables.

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

* docs(persistence): describe owner-keyed runtime and assets, drop the dev token

Remove the development persistence token from the server-persistence
recipe, .env.example, the Docker build arguments and the deployment
docs; describe runtime learner keys, per-owner asset partitions and
quota, the legacy shared-partition rule and the tombstone behavior; and
record the breaking change for existing server-persistence runtime data
under Unreleased.

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

* feat(storage): scope asset references to principals, typed stage refusal, uniform create collision

@openmaic/storage 0.32.0.

- PgDocumentStore takes assetReferencePrincipals: with reference
  tracking on, a write references and commits only entries held by the
  listed principals. An id naming another principal's entry records no
  row and changes no lifecycle column, like an unknown id, and the write
  is never refused. Without the option any held entry is referenced, as
  before. AssetCollector takes a matching per-document-owner function so
  the one-time backfill scopes each document the same way. This keeps a
  document naming a leaked id from committing, exposing or pinning
  another owner's allocation.
- RuntimeStageNotFoundError: a store may refuse createSession for a
  stage its host considers absent; the runtime handler answers
  404 STAGE_NOT_FOUND, recognizing the error by class or by code.
- A session create over a taken id answers 409 SESSION_ALREADY_EXISTS
  whoever holds it. It used to answer 403 for another learner's session
  and 409 for one's own, which told a caller whose session an id was.

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

* fix(persistence): close owner-seam boundary gaps in references, tombstones and legacy assets

- Document writes reference and commit only the writer's own asset
  entries and legacy shared ones (owner-bound document store and the
  collector backfill), so another owner's course naming a pending id
  can no longer commit, expose or pin it.
- The foreign-read rule additionally requires the referencing live
  course to belong to the entry's owner, as defense in depth for
  reference rows that did not come from a scoped write.
- The tombstone refusal for new runtime sessions moved from a raw path
  comparison in the route into the guarded store's createSession
  (RuntimeStageNotFoundError, 404 STAGE_NOT_FOUND), so no encoded
  spelling of the path routes around it.
- Replacing or deleting a legacy shared entry checks reference
  ownership and mutates in one transaction under the entry row lock,
  which a concurrent reference insert must wait for.
- Tests build the uncommitted-but-referenced, tombstoned-with-rows and
  foreign-reference states directly so each half of the read rule is
  observable, and a PostgreSQL suite holds a competing reference open
  across a legacy delete.

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

* docs(persistence): upgrade note for dev-token-gated deployments, owner-scoped references

Tell operators whose only access gate was the development token to put
the deployment behind an access code or gateway, register an owner
authenticator, or turn server persistence off before upgrading, and
describe that a course references only its owner's media.

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

* fix(storage): per-owner reference principals and a typed taken-id conflict

- PgDocumentStore's assetReferencePrincipals is now a function of the
  store's bound owner, evaluated per write, instead of a fixed list. A
  store re-bound with forOwner therefore scopes to the new owner rather
  than carrying the previous owner's principals onto its writes. It has
  the same shape as AssetCollector's backfill option, so one function
  serves both.
- PgRuntimeStore and BrowserRuntimeStore raise RuntimeSessionExistsError
  for a taken session id, and the runtime handler classifies it as
  409 SESSION_ALREADY_EXISTS even when its re-read cannot see the holder
  (for example a session a host-side wrapper hides). Recognized by class
  or by code.

Still the unreleased 0.32.0.

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

* fix(persistence): guard the Pi whiteboard runtime and answer hidden collisions with 409

- Every server-side runtime writer now uses the tombstone-guarded store:
  the Pi native whiteboard service gets it through the same helper as
  /api/persistence/runtime/*, so a deleted course takes no new
  whiteboard runtime either.
- Creating a session whose id is held by a session a tombstone hides
  answers the uniform 409 SESSION_ALREADY_EXISTS instead of a 500,
  covered on PGlite and on real PostgreSQL.
- The owner-bound document store passes its reference-principal
  function, so the scoping follows the bound owner.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* feat(auth): owner identity seam, phase 3 — accounts through an identity gateway (trustedProxyHeader)

* feat(auth): owner identity seam, phase 3 — trusted-proxy-header authenticator

Add the trustedProxyHeader built-in: real accounts through an identity
gateway (oauth2-proxy, Authelia, a Keycloak-based proxy, an institutional
reverse proxy) that signs the user in and forwards the verified user in a
request header. OpenMAIC never handles passwords or tokens.

- Selected with OWNER_AUTHENTICATOR=trusted-proxy and validated in
  instrumentation register(). TRUSTED_PROXY_SECRET (32-1024 printable
  ASCII characters) is required; header names default to
  x-openmaic-proxy-secret and x-forwarded-user, and an optional groups
  header with TRUSTED_PROXY_ADMIN_GROUPS grants admin. An unknown
  selector, a TRUSTED_PROXY_* variable without the selector, a shared
  owner id or a host-registered authenticator alongside it, and malformed,
  reserved or repeated header names fail the boot.
- Trust boundary: Next.js exposes no TCP peer address to route handlers,
  middleware or Server Actions, and fills x-forwarded-for from the socket
  only when the client sent none, so no address allowlist is offered. The
  gateway secret is compared in constant time (hashed, timingSafeEqual).
- Per request: a missing or wrong secret, a missing or blank user, a user
  header with a comma (duplicate lines arrive comma-joined), or a user
  whose proxy:<user> id fails the owner id guard is 401
  INVALID_CREDENTIAL, never an anonymous owner. The guard failure is a
  401 rather than a 500 because the value comes with the request. The
  user is trimmed and keeps its case. Every gateway user holds
  course:publish; kind user, assurance verified, channel proxy; no
  cookie. Route handlers and Server Actions share one code path.
- The unset-ACCESS_CODE boot warning is skipped in this mode; the gateway
  is the access gate and ACCESS_CODE stays independent.
- The identity boundary scan now also fails on gateway identity headers
  (x-forwarded-user/-groups/-email, x-auth-request-*, remote-user, the
  secret header) and TRUSTED_PROXY_* read outside lib/server/identity.

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

* docs(auth): document accounts through an identity gateway

Describe the trustedProxyHeader built-in in the README owner identity
section (and its Chinese counterpart): configuration, request semantics,
the trust boundary and why it is a shared secret, the interaction with
ACCESS_CODE, boot-time validation, and an oauth2-proxy example that
injects the user, groups and secret headers. Add the .env.example block,
a SECURITY.md note and an Unreleased changelog entry.

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

* fix(auth): harden the trusted-proxy authenticator after review

- Reserve the header names HTTP, Next.js and forwarding proxies set
  themselves: configuring the user, groups or secret header to rsc,
  next-action, forwarded, x-forwarded-for/-host/-proto/-port, x-real-ip,
  or anything starting with x-middleware-, x-invoke-, x-nextjs- or
  next-router- now fails the boot.
- Log a one-time boot warning when TRUSTED_PROXY_ADMIN_GROUPS is set: the
  admin role is granted from the groups header, which is trusted on the
  strength of the shared secret alone, so the gateway must overwrite or
  strip it.
- POST /api/chat/pi resolves the owner up front and answers 401 when the
  authenticator rejects the credential, like every other owner-resolving
  route, instead of continuing without an owner. The anonymous default
  always resolves, so it is unaffected.
- The identity boundary scan also covers the Authentik, Azure App Service
  authentication, WebAuth, Cloudflare Access, Google IAP and AWS ALB OIDC
  header families.

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

* docs(auth): admin-groups warning and a corrected oauth2-proxy example

- Warn next to TRUSTED_PROXY_ADMIN_GROUPS (README, README-zh,
  .env.example) that the groups header is trusted on the strength of the
  shared secret alone, and list the reserved header names.
- The oauth2-proxy example now states it targets v7.14+ (nested
  claimSource/secretSource), uses the canonical bindAddress, sets
  preserveRequestValue: false on all three headers and
  insecureSkipNonce: false, forwards the subject with `claim: user`, and
  notes that the secret variable holds the literal secret. It adds the
  legacy-option migration caveat, a warning not to exempt app routes via
  skip-auth or trusted-ip options, and notes that the IdP must release the
  groups claim and group names must not contain commas.
- Changelog: the new boot checks and the /api/chat/pi 401.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* feat(persistence): owner identity seam, phase 4 — host extension hooks

* feat(persistence): owner identity seam, phase 4 — host extension hooks

Let a host add product behavior at four points without forking a route,
registered once from instrumentation.ts register() like the owner
authenticator and sealed on first use. Nothing changes when no hook is
registered.

- configurePersistenceHooks({ name, authorizeCreate, onCreate, library,
  beforeAssetAllocate }):
  - authorizeCreate / onCreate run once per created course, inside the
    create transaction, in the transaction that inserts the course's
    ownership row. Saves of a course the owner already holds, and a create
    that loses a race to a concurrent create of the same id, are updates and
    run no create hook. A refusal answers 403 CREATE_REFUSED; a throw rolls
    the course, its ownership row and the host's writes back together. The
    hooks receive the request principal, or only the owner id for a
    background agent run.
  - library chooses which stage ids GET /api/stages lists; the route builds
    the usual summaries in the provider's order and drops every id the read
    path would refuse (deleted or unclaimed). folderId is shown only on the
    principal's own courses.
  - beforeAssetAllocate runs inside the storage handler's asset
    authorization step for POST /assets, after the owner is resolved and
    before the body is read; a returned Response is sent instead, with
    nothing stored and no quota counted.
- configureAssetByteStore({ name, create, signsReadUrls }) replaces the
  ASSET_S3_BUCKET switch for both the persistence provider and the asset
  collector. A host store must keep bytes outside the registry database.
  Under ASSET_BYTE_EGRESS=redirect a store that does not declare
  signsReadUrls stops the server at boot; the built-in layers keep their
  behavior.
- @openmaic/storage 0.33.0: DocumentWriteRefusedError lets a store refuse a
  write as policy; the document HTTP handler answers 403 with its code.

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

* fix(persistence): harden the host extension hooks after review

Correctness:
- Registration reads every hook once and stores it bound to the object the
  host passed, so hooks defined on a class prototype or as non-enumerable
  properties are kept. A spread copy dropped them silently, including the
  authorizeCreate and beforeAssetAllocate gates. Same for
  configureAssetByteStore. The unknown-key check now applies to plain
  objects only, since class instances carry their own state fields.
- The owner-bound document store carries each call's operation in its own
  async context (AsyncLocalStorage) instead of a field on the instance. One
  store is shared by every tool of an agent run; a concurrent call could
  overwrite the field before the transaction read it, so a create was gated
  as a read (no ownership row, no create hooks) or claimed another stage.
  create_stage also runs sequentially within a tool batch.

Hardening:
- isDocumentWriteRefusedError recognizes a refusal from another copy of the
  storage package by name and shape (not by code alone). The document
  handler and POST /api/stages use it.
- beforeAssetAllocate also gates PUT /assets/{id}/content. The request
  carries operation ('create' | 'replace') and the decoded assetId from the
  handler's own routing.
- Agent runs: a refusal on a background write carries a fixed message, and
  create_stage answers it with a stable tool result. The host's message never
  reaches the model.
- A byte store declaring signsReadUrls without a signer degrades to direct
  bytes with a warning instead of failing reads, and the collector no longer
  requires signing.
- DocumentActor is discriminated by source ('request' with the principal,
  'background' without one).
- Library ids the read path cannot address are dropped, and a provider
  answer is capped at 5000 ids.
- The boot-time ownership backfill logs how many courses it adopted without
  hooks.
- The server persistence provider no longer exposes an unscoped document
  store.
- The writesOutsideRegistryDatabase trust boundary and the scope of upload
  admission (HTTP uploads, not server-generated media) are documented.

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

* fix(persistence): refuse misspelled hooks on host classes too

A class instance skipped the unknown-key check, so a misspelled hook such
as authorizeCreat registered cleanly and never ran, leaving creation
ungated. Both configurePersistenceHooks and configureAssetByteStore now
reject any function-valued property of a class instance (own or on its
prototype chain up to Object.prototype, constructor excluded) that is not a
known key, and any unknown key of a plain object. The error names the key
and suggests the known key within edit distance 2. Host classes keep their
helpers private (#helper) or register a plain object; the README says so.

Also:
- isDocumentWriteRefusedError requires a string message from a cross-copy
  candidate, and documents that the name is the effective discriminator.
- CHANGELOG: ServerPersistenceProvider no longer exposes an unscoped
  documentStore; use the owner-bound store.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* feat(persistence): owner identity seam, phase 5 — tenancy lives in stage_meta only

* feat(persistence): owner identity seam, phase 5 — tenancy lives in stage_meta only

Course ownership was recorded twice: in stage_meta, which already answers
every access decision, and in document_stages.owner_id, which the storage
package scoped by. Two tenancy records double what a later claim has to
re-key, and a host that keeps ownership in its own table had to rewrite the
package's SQL to strip the column.

@openmaic/storage 0.34.0:
- PgDocumentStore never reads or writes document_stages.owner_id. An
  owner-bound store scopes listings, writes, deletes, the freshness manifest
  and folder membership through the host's ownership relation, named with
  the new documentOwnership option ({ table, stageIdColumn, ownerIdColumn,
  tombstoneColumn, claimOnCreate }, or false for no document scoping).
  Identifiers are validated, never quoted into SQL from arbitrary input.
- forOwner / ownerId without documentOwnership throws at construction,
  so a direct consumer cannot upgrade into a store that silently scopes
  nothing. An unbound store is tenant-agnostic.
- claimOnCreate inserts the ownership row in the create transaction; a
  concurrent create of the same id by another owner rolls back.
- folders: false lets a host whose document_stages has no folder_id use
  the store; folder ids are unique per owner, so membership goes through
  the ownership relation too.
- AssetCollector reads each document's owner from documentOwnership for
  assetReferencePrincipals, and refuses the function without it.
- Schema: fresh installs get no owner_id column and none of its indexes;
  an existing column is kept for one release, made nullable with its
  default dropped (each ALTER guarded by a catalog check, so a replay takes
  no lock), and its two indexes are dropped. document_stages_folder_idx
  replaces the owner/folder index.

Application:
- The owner-bound store binds the package to stage_meta
  (STAGE_META_OWNERSHIP) and lists through it in one query; the asset
  collector schedule passes the same relation.
- The boot backfill copies column-only owners into stage_meta before any
  store is built, only when the legacy column exists, idempotently, and
  logs how many it adopted and how many disagree (stage_meta stands).

Tests cover a fresh install and an upgrade from the previous schema on
PGlite and PostgreSQL 16, a host table with neither ownership nor folder
columns, folder isolation across owners sharing a folder id, and the
concurrent-create race.

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

* fix(persistence): serialize schema bootstrap; guard unscoped owner-bound stores

Review follow-ups for the tenancy change.

- Concurrent starts: two instances starting against one database raced on
  the schema DDL (a duplicate pg_class / pg_type entry, or "tuple
  concurrently updated"), failing one instance's first request. Every
  schema bootstrap the application runs -- the persistence provider's
  sequence (runtime, documents, stage_meta and its ownership backfill,
  owner materials, assets), the agent-session, session-material and
  user-skill stores, and the asset collector's -- now runs under one
  PostgreSQL advisory lock held on a dedicated connection and released in
  finally. It lives in the application because what needs serializing is
  the whole sequence, including tables the package does not own. A
  PostgreSQL test boots eight instances at once, four rounds each, on a
  fresh and on an upgraded database; without the lock it fails every run.
- documentOwnership: false on an owner-bound PgDocumentStore now also
  requires allowCrossOwnerDocumentAccess: true, so binding an owner for
  folders or asset principals cannot silently expose every owner's
  documents.
- Documented that an ownership relation must cascade with the document
  rows (a leftover row keeps the id reserved for its owner, which is also
  how a host keeps retired ids from being reused), with tests for both.
  Leftover rows are deliberately not made claimable: a host cannot be
  told apart from one that keeps them as retirement markers.
- Upgrade notes: the one-time table locks on the first start, and the
  out-of-band CREATE INDEX CONCURRENTLY / DROP INDEX CONCURRENTLY path
  that leaves the start with nothing to build or drop.

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

* test(persistence): leave the schema bootstrap lock out of the held-lock check

The create-hooks suite asserts that its host lock is released with the
create transaction by counting granted advisory locks database-wide. A suite
booting a provider against the same database at that moment now holds the
schema bootstrap lock, which is not the lock under test.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* feat(auth): owner identity seam, phase 6 — claiming anonymous work on sign-in

* feat(storage): owner merge primitives for claiming anonymous work (0.35.0)

Add the per-store pieces a host needs to move one owner's data to another
inside a single transaction of its own:

- reassignDocumentFolders(tx, { fromOwnerId, toOwnerId, documentOwnership })
  moves folders and filing: a same-named folder merges into the target's, a
  colliding id is renumbered, and filing is rewritten in one statement from
  the old ids.
- PgAssetStore.reassignPrincipal(fromKey, toKey) moves a principal's entries
  under both principals' write locks, in key order; quota is not re-checked.
- PgUserSkillStore.mergeOwner(from, to) moves skills, renaming a live handle
  the target already uses with the first free numeric suffix.
- PgUserSkillStore takes a resolveFinalOwner hook, run as the create
  transaction's first statement, like the agent-session store's.

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

* feat(auth): owner identity seam, phase 6 — claiming anonymous work on sign-in

A visitor who works anonymously and then signs in can move that work to their
account in one transaction, and the anonymous id is retired afterwards.

- Principal: pendingClaim { fromOwnerId, assurance }. The trusted-proxy
  built-in sets it when a gateway request also carries a valid anonymous
  cookie; the anonymous and shared-team built-ins never do. Authenticators may
  implement describeStoredOwner, canonicalize and clearPendingClaim;
  principalFromStoredOwner describes a stored id for work without a request.
- Claims: claimOwner / claimPendingOwner and registerClaimParticipant (sealed
  on first claim). One transaction, both owners' identity locks taken
  exclusively in key order, participants in a fixed order (folders, courses,
  materials, agent sessions, skills, runtime, assets), recorded in the new
  owner_merges table. Idempotent per pair; refuses a non-anonymous source, an
  anonymous target, a source already claimed elsewhere, and chains. Same-named
  folders merge, colliding folder ids are renumbered, colliding skill handles
  get a suffix, and quotas are not applied to what moves.
- Identity lock: every owner write (documents, folders, asset allocations,
  material registration, agent sessions, skills) takes the owner's advisory
  lock in shared mode first, so a write racing a claim is moved or refused,
  never orphaned or deadlocked.
- Retired ids: request writes are refused with 403 OWNER_RETIRED; agent runs
  and media generation that started before the claim follow the id to the
  account (forwarding document store, canonicalized stage probes, forwarded
  generated assets and skills).
- Trigger: POST /api/identity/claim (same-origin JSON only; clears the
  anonymous cookie), or OWNER_CLAIM_TRIGGER=auto on the first request carrying
  a pending claim. The runtime contract's learner merge now performs the same
  claim, allowed only for the request's own pending claim.

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

* test(persistence): count only the suite's own advisory locks after concurrent creates

The check that a host's transaction-scoped lock is released counted every
granted advisory lock in the database. Suites that run against the same
database at the same time hold advisory locks of their own (a schema
bootstrap, the owner identity locks a claim takes), which made it fail
depending on timing. Count only locks held by this suite's connections.

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

* docs(auth): use a neutral example table for host claim participants

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

* fix(storage): owner merge fixes and store policy errors over HTTP (review round 1)

- PgUserSkillStore.mergeOwner reserves every handle either owner holds before
  renaming: a claimed skill whose handle the target uses could be renamed into
  a handle another claimed skill still held, which violated the per-owner
  unique index and made that merge fail every time.
- PgUserSkillStore.list orders ties by id.
- PgRuntimeStore takes resolveFinalLearner(tx, learnerKey), run as the
  create transaction's first statement, so a host can fence session creates
  against its owner merges; reassignLearner(from, to) re-keys sessions without
  re-validating them, so one session a newer version wrote cannot block a
  merge.
- Every HTTP handler (runtime, documents, assets) answers a
  DocumentWriteRefusedError as 403 with its code, and the new StorageBusyError
  (code, message, retryAfterSeconds) as 503 with Retry-After.

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

* fix(auth): claims review round 1 — fenced runtime creates, bounded locks, retired-cookie recovery

- Runtime session creates take the owner's identity lock as the create
  transaction's first statement and refuse a retired owner, closing the window
  in which a create racing a claim was left under the retired id.
- Lock waits are bounded: a claim waits OWNER_CLAIM_LOCK_WAIT_MS (default 5 s)
  for its identity locks, a write OWNER_WRITE_LOCK_WAIT_MS (default 30 s);
  running out, or losing a deadlock or serialization race, answers 503
  OWNER_BUSY with Retry-After on the claim routes and on every fenced write.
- Every 403 OWNER_RETIRED carries the Set-Cookie values that drop the retired
  anonymous credential (the anonymous built-in now implements
  clearPendingClaim), so a browser whose claim response was lost recovers.
  Writes by id to rows that moved (skill delete, session messages) answer
  OWNER_RETIRED instead of not-found or a bare 403.
- OWNER_CLAIM_TRIGGER=auto no longer claims ahead of the explicit claim
  routes, which now report their own claim instead of a false refusal.
- The owner-events stream tells a retired owner only that it moved, never the
  claiming account's id.
- Creates of one course id take turns (a per-id advisory lock before the
  ownership probe), so a concurrent second create saves as an update instead
  of failing as reserved-document.
- A claim's source must be described as anonymous by the authenticator; the
  write fences skip the retirement lookup for every other owner. The host
  canonicalize hook is removed: forwarding is core's owner_merges alone.
- The folders participant locks the source's stage_meta rows first; runtime
  sessions are re-keyed without re-validation; the owner asset store fences
  every allocation and refuses to allocate without transactions; background
  skill reads and patches follow a mid-run claim; the forwarding document
  store forwards methods only.
- Docs: when a pending claim can arise with the shipped authenticators, the
  anonymous cookie as a bearer credential, bounded waits, the exact scope of
  OWNER_RETIRED, and claims narrowed to what the tests show.

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

* fix(auth): claims review round 2 — merge-record guard, boot-checked lock waits, stream credential drop

- owner_merges records claims of anonymous owners only, since the write
  fences enforce retirement only for ids the authenticator describes as
  anonymous. Reading a row that retires any other owner now fails loudly
  instead of being followed but not enforced. The comment that pointed hosts
  at claimOwner for their own account merges is corrected: a host merging two
  signed-in accounts moves the rows itself and refuses the merged-away account
  in its authenticator. describeStoredOwner must classify ids stably.
- OWNER_WRITE_LOCK_WAIT_MS and OWNER_CLAIM_LOCK_WAIT_MS are validated at boot
  with the same parser the lock paths use, so a malformed value stops the
  server instead of failing every write.
- The owner-events stream answers a connect by an already retired identity
  with one owner_moved event and the Set-Cookie values that drop the retired
  credential, so a stale tab's reconnect loop ends after one round trip.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* chore(auth): final polish before landing the owner identity seam

* chore(auth): final polish before landing the owner identity seam

- Pi routes refuse a rejected credential with the seam's INVALID_CREDENTIAL body.
- Drop the unused LegacyAssetMutations alias and resolveLockWaitMs re-export.
- Scope the document_stages.owner_id deprecation wording: the store no longer
  reads or writes it; the boot backfill reads it and a claim mirrors ownership
  into it for rollback.
- Document OWNER_CLAIM_TRIGGER and the two lock-wait variables in .env.example.

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

* chore(storage): 0.35.1 for the README wording fix

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* feat(identity): one identity entry point with composable auth methods

* feat(identity): compose owner auth methods behind one entry point

Owner resolution now asks an ordered list of owner auth methods instead
of one configured authenticator. Each method answers `authenticated`,
`not-applicable` (no credential of its kind) or `invalid` (its credential
is present but bad). Core keeps the first `authenticated` answer, refuses
any `invalid` answer with 401 INVALID_CREDENTIAL without asking later
methods, and when no method applies falls back to the anonymous cookie
owner, or answers 401 when the host turned the fallback off. Route
handlers and Server Actions share the same order and per-request
memoization.

- configureOwnerAuthentication({ methods, anonymousFallback? }) replaces
  configureOwnerAuthenticator: single-shot, sealed on first use, and
  validated at registration and at boot.
- anonymousCookie becomes the built-in fallback method; sharedTeam
  becomes a method, selected by PERSISTENCE_SHARED_OWNER_ID as before
  when nothing is registered, and kept beside host methods only when the
  host lists sharedTeamAuthMethod() last. The variable set beside a
  registration that leaves it out fails the boot.
- Core attaches pendingClaim itself: a host method's non-anonymous
  principal on a request that also carries a valid anonymous cookie. A
  method may not set one. Claim cookie clearing and OWNER_RETIRED
  recovery go through the anonymous method's clearCredential.
- principalFromStoredOwner consults the anonymous method first, then the
  configured methods' describeStoredOwner in order.
- Remove the built-in trusted-proxy header authenticator with
  OWNER_AUTHENTICATOR and every TRUSTED_PROXY_* variable. The boundary
  test now also keeps the resolution internals inside
  lib/server/identity/ and asserts no source reads the removed variables.

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

* docs(identity): document owner auth methods and a gateway JWT recipe

Rewrite the owner identity section around the ordered auth methods: the
three answers and what resolution does with each, the anonymous
fallback, registration and its boot checks, and the sharedTeam rules.
Replace the removed built-in gateway authenticator's documentation with
a short host-owned recipe that verifies a gateway-forwarded signed JWT
(oauth2-proxy, Cloudflare Access, IAP) against the identity provider's
keys with the jose library. Update the claim section for the core-owned
claim candidate, and the Chinese README, SECURITY.md, CHANGELOG and the
deployment docs in every locale to match.

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

* fix(identity): tighten retired-owner cookies, retired config and the boundary

- On 403 OWNER_RETIRED, core now calls clearCredential only on the
  anonymous fallback and on methods that declare
  `issuesAnonymousOwners: true`, so an account method's session cookie is
  never cleared there. A clearCredential that throws is logged and
  skipped instead of turning the refusal into a 500.
- Setting any retired OWNER_AUTHENTICATOR / TRUSTED_PROXY_* variable fails
  the boot with a pointer to the auth-methods docs, instead of silently
  serving every request anonymously. Only the names are matched, in one
  module the boundary test checks never reads a value.
- Add lib/server/identity/host/ for host auth methods. The boundary test
  now scans core identity files for gateway identity headers too, polices
  reads of an incoming Authorization header everywhere but the host
  directory, and keeps resolution internals out of host code.
- Document on OwnerAuthMethodResult that a present but unusable
  credential must answer `invalid`, never `not-applicable`, and that a
  method that cannot decide throws.

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

* docs(identity): fix the gateway JWT recipe's error split and add notes

- The recipe now answers `invalid` only for token failures (expired,
  claim validation, malformed JWS/JWT, bad signature, no or several
  matching keys, disallowed or unsupported algorithm) and rethrows
  everything else: jose reports a key endpoint that is down, answers
  non-200 or returns unparsable data as a generic JOSEError, which the
  previous version turned into a 401 for every user.
- The recipe file goes in lib/server/identity/host/, with an accurate
  description of what the boundary test enforces.
- Document invalid versus not-applicable for malformed credentials,
  issuesAnonymousOwners, the boot failure on retired variables, and that
  the claim candidate is an unsigned bearer cookie a sibling subdomain can
  shadow (serve on a registrable domain of its own; cookie hardening is
  deferred). Mirror the notes in the Chinese README, SECURITY.md and the
  changelog.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 11:54:26 +08:00
LING DUANandwyuc 04baad7c45 fix(pi): distinguish state errors from stale whiteboard references (#1681)
Co-authored-by: wyuc <wang-yc24@mails.tsinghua.edu.cn>
2026-09-27 01:01:20 +08:00
LING DUANandClaude Opus 5.5 d23bc104dc feat(pi): reference a whiteboard element in classroom chat (#1656)
Let a learner pick one element on the classroom whiteboard and ask about
it, alongside the existing PPT element and interactive component
references behind the courseware-reference flag.

- Add a `whiteboard_element` reference: { kind, whiteboardId, elementId }.
  The Host resolves it only from this request's Stage snapshot
  (`stage.whiteboard[0]`), projects the element with the existing slide
  element projection, and passes it through the existing Director ->
  call_agent evidence path. It grants no Spotlight or other tool access.
- Reuse the playback pick overlay inside the whiteboard, scoped to the
  whiteboard DOM so pan/zoom and overlapping elements resolve correctly,
  and keep a highlight on the selected element.
- Share one displayed-whiteboard selector for display, picking, and the
  pre-send check. A runtime (RuntimeStore) whiteboard disables picking;
  runtime-board references are out of scope.
- Before sending, re-check the reference against the snapshot actually
  POSTed; if it is stale, keep the learner's text and ask them to pick
  again. Server rejections carry a `whiteboard_reference_changed` reason.
- Describe whiteboard references correctly next to page-reported state,
  and rename the entry to "Reference content" in all locales.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 11:13:03 +08:00
a87c94c89d feat(settings): restructure settings IA with course model config and token plan controls [AI-assisted] (#1644)
* feat(generation): propagate per-stage model routes via x-model-routes header

Course model config lets each pipeline stage pin its own model; send the
user-level stage routes alongside the main model credentials so servers
resolve overrides consistently. Server-side model-routes/resolve-model
learn to merge them; scene renderers, the scene generator and the
generation preview pass the header through.

* feat(settings): token plan enrollment markers, plan toggle and store rework

- Plans connect through an explicit tokenPlanEnrollments marker: a personal
  key on a shared provider (minimax/tokendance/doubao) is never mistaken for
  a connected plan, so startup reconciliation no longer hijacks its catalogue
- New per-plan authorization toggle (tokenPlanDisabled): toggling cascades
  to the plan's per-modality provider enabled flags while skipping providers
  another usable plan still owns; re-enabling clears the seed fingerprint and
  re-seeds through reconciliation
- Multi-plan priority: exclusive slots (main model, stage routes, per-modality
  selections) yield to plans earlier in TOKEN_PLAN_PRESETS order regardless
  of write order; picker ordering follows the same order
- Reconciliation skips plans disabled by the toggle; disconnect restores the
  built-in provider's enabled flag and clears stale authorization markers
- Stage routes are pruned when a provider becomes unusable; the homepage
  web-search toggle migrates into the settings store; server-provider init
  waits for persist hydration

* feat(settings): five-section IA with course model config and provider panels

- Settings dialog reorganized into Token Plan / Course Model Config /
  Model Services / Skills / System
- New Course Model Config section: station cards along the generation
  pipeline with a per-station inspector for model overrides, mainline
  model plus fine-grained sub-stage routes, invalid selections kept as
  disabled rows instead of blank triggers
- Model Services becomes a provider list + detail panel with a per-provider
  authorization toggle (disabled providers leave course model config);
  Qwen TTS voice-clone area redesigned (45:55 split, add-clone CTA)
- Token Plan panel gains the Enable-this-plan switch mirroring the provider
  toggle, connected/off status copy, and priority-ordered model groups

* refactor(generation): drop the media popover now that media providers live in settings

Media provider/model selection moved into Model Services and Course Model
Config; the generation-bar media popover (520 lines) goes away and the
toolbar slims to the remaining toggles.

* fix(ui): keep provider popovers inside the viewport

Voice/config popovers now cap their height against
--radix-popover-content-available-height and their width against the
viewport, so search boxes no longer clip near screen edges.

* feat(i18n): complete course-model stations and settings copy across 12 locales

- Translate stations.* labels and descriptions for the 9 locales that fell
  back to English; convert zh-TW descriptions to Traditional Chinese
- Add subStages.*, mainlineUnset, pickModel/pickProvider, optionInvalid and
  the token-plan toggle copy across all locales

* test(audio): mock getStageRoutesHeaderValue in refused-narration test

Companion mock to the x-model-routes plumbing commit.

* style: apply prettier to files touched by the settings IA refactor

* fix(settings): restore the homepage model pill and set-up CTA lost in the IA refactor

The refactor dropped the toolbar's model picker and "Set up model" CTA,
leaving the homepage without any model affordance (#580 dead-end) and
breaking the picker stress tests (#1447), the managed-provider specs
(#620) and the KV-persistence checks that anchor on the pill.

- Re-add the pill on the shared ModelPicker component (aria-label
  "Provider / Model"), backed by the extracted useLLMPickerGroups hook so
  the toolbar and course model config share one filtered, priority-ordered
  provider list
- Re-add the amber "Set up model" CTA when no usable provider exists; it
  opens settings at Model Services
- Specs: navigate to Model Services in #620 and scope its catalog
  assertion to the dialog; drop the provider-tab step in #1447 (providers
  are group headings now)

* fix(settings): write the full runtime stage set for classroom interaction

The interaction station wrote only chat-adapter, while quiz grading and
the PBL v2 runtime resolve quiz-grade / pbl-v2-runtime:*, so the override
never reached them and calls fell back to the main model (review P0).

- Add lib/config/station-stage-keys.ts as the single station -> runtime
  stage-keys contract; interaction now writes chat-adapter, quiz-grade,
  pbl-chat and pbl-v2-runtime together (PBL composite sub-stages inherit
  the base key via the existing colon fallback)
- Override and restore-follow now write/clear the whole key group,
  thinking-config changes included
- Regression tests: contract keys are LLM_STAGES members, no orphan
  runtime stages, the interaction override resolves for every runtime
  endpoint, and explicit sub-stage overrides keep precedence over
  inherited parents

* fix(settings): enforce provider authorization across sync fallback and gates

A disabled plan's provider could be resurrected on refresh: validateProvider
correctly rejected it, but the auto-recover branch adopted fallback[0],
whose order still included authorization-disabled entries; the homepage
generate gate (isLLMProviderConfigured) also ignored the toggle (review
P0-02). Excluded disabled entries from the fallback order (extracted as
buildUsableFallbackOrder; image/video keep adopting server-configured
entries whose enabled:false means not adopted yet) and taught
isLLMProviderConfigured to reject disabled providers, so an empty
selection is retained and submission stays blocked.

* fix(token-plan): assign shared provider credentials to the top enabled plan

Applying a plan overwrote shared provider slots (seedream) last-writer-
wins, and disabling the owning plan left the slot enabled with the
disabled plan's endpoint and key (review P0-03). Shared slots now always
belong to the highest-priority enabled plan targeting them: apply yields
credentials and seed catalogues to a higher-priority owner regardless of
connect order, and disable/enable/remove transitions hand the slot back
(restoreSharedProviderCredentials rewrites endpoint, key — taken from the
owner's LLM slot — and catalogue of the new owner).

* fix(token-plan): include the re-enabled plan in shared ownership and clean up on removal

Two transitions still mis-owned shared slots after the previous fix
(re-review of P0-03): re-enabling a higher-priority plan excluded itself
from ownership resolution, so the slot stayed with the lower-priority
plan; and removal skipped cleanup when another plan was merely enrolled
but disabled, leaving the removed plan's endpoint, key and model
catalogue active on the shared provider.

- restoreSharedProviderCredentials takes excludeConcerned: disabling and
  removal exclude the concerned plan, re-enabling includes it, so the
  newly enabled plan reclaims its slot and a lower-priority enable does
  not steal one it does not own
- removeTokenPlan skips shared-slot cleanup only for another USABLE plan;
  otherwise the slot is cleared and disabled instead of surviving behind
  an enrolled-but-disabled plan

* fix(chat): honor per-stage user routes for classroom chat

The classroom-interaction model override is sent by the client as the
`x-model-routes` header, but the two multi-agent classroom chat routes
resolved their `chat-adapter` stage from the request body without parsing
it, so a per-stage override was silently ignored and the main model was
used instead.

Forward the parsed user routes into `resolveModel` for both /api/chat and
/api/chat/pi (credentials still come from the body, not x-* headers),
and attach the header from the client fetches. Operator MODEL_ROUTES still
wins server-side.

Regression tests cover the user-route precedence, the operator override,
the x-model-routes parser, and both client fetches.

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

* fix(settings): drop the pro-mode override the server never honors

The Course Model Config UI offered a "Pro Mode" station that wrote a
user-level route for `maic-agent-driver`, but that stage is resolved only
from the operator's MODEL_ROUTES (it also needs an explicit api dialect
and contextWindow), so the override could never take effect.

Remove the station from the map and its pipeline card, stop seeding the
route from the TokenDance preset, and drop the now-unused label/i18n keys
in every locale. The settings store's rehydrate sanitizer also drops any
previously persisted `maic-agent-driver` user route, so upgraded installs
do not keep a dead entry. The "no orphan runtime stage" contract test now
expresses `maic-agent-driver` as an explicit operator-only exception.

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

* chore(settings): neutralize token-plan comments

Rewrite comments that referenced an external deployment or implementation
into neutral descriptions of this file's own behavior and design intent.
No behavior change.

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

---------

Co-authored-by: Percy <percy@PercydeMacBook-Pro.local>
Co-authored-by: wyuc <wang-yc24@mails.tsinghua.edu.cn>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-23 18:11:21 +08:00
Yizuki_Ame 7de474f4b2 fix(workbench): show effective upload limits in friendly errors (#1418)
* fix(workbench): show effective upload limit in friendly errors

* fix(workbench): format the upload limit shown in composer toasts

A non-MiB cap was stringified as a long fraction, and nothing checked that the toast used the localized message.
2026-09-23 11:43:08 +08:00
LING DUAN 2a77a8a476 feat(chat): make Pi classroom runtime the default (#1628)
* feat(chat): make Pi classroom runtime the default

* chore(chat): align docs and E2E with Pi default
2026-09-22 11:41:13 +08:00
Yizuki_Ameandwyuc 54d626bc61 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>
2026-09-21 18:49:25 +08:00
Huang Geyangandwyuc 67f568848a fix(server): warn when access-code protection is disabled (#1599)
Co-authored-by: wyuc <wang-yc24@mails.tsinghua.edu.cn>
2026-09-20 19:14:38 +08:00
Easternandwyuc 33ff189a11 fix(persistence): correlate 5xx responses with request ids (#1601)
Co-authored-by: wyuc <wang-yc24@mails.tsinghua.edu.cn>
2026-09-20 16:30:53 +08:00
d7b31aa5e7 fix(generation): add browser-safe package entry (#1609)
Co-authored-by: SY <sy@SYdeMacBook-Pro.local>
Co-authored-by: wyuc <wang-yc24@mails.tsinghua.edu.cn>
2026-09-20 16:10:52 +08:00
LING DUANandClaude Opus 5 f29bbc4daa 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>
2026-09-19 18:17:30 +08:00
XHaoandwyuc e14c3a1d12 fix(api): validate pdfContent input (#1578)
Co-authored-by: wyuc <wang-yc24@mails.tsinghua.edu.cn>
2026-09-18 12:36:29 +08:00
wyucandClaude Opus 4.8 87c4524b4d fix(audio): validate redirects and pin connections on provider requests (#1514)
Audio provider requests (TTS, ASR, voice registration and voice cloning)
validated a client-supplied base URL once and then issued a plain fetch with
default redirect-follow and no pinned dispatcher. A base URL that resolved to a
public address but answered with a redirect to an internal one was followed, and
a DNS answer that changed between the guard's lookup and the connect reached an
internal host — both readable in-band.

- Route every lib/audio provider request through a new
  lib/server/audio-provider-fetch.ts that combines per-hop redirect
  re-validation with a pinned undici dispatcher, so the socket can only reach an
  address the guard validated, on every hop.
- Select the public-vs-local policy server-side from isServerConfiguredProvider;
  a client-supplied base URL is always strict public and can never reach a
  private, loopback or cloud-metadata address, even with ALLOW_LOCAL_NETWORKS
  set.
- Pin the result-audio download hop as well, keeping its host allowlist and
  redirect:'error'.
- Add a coverage-matrix test that fails if any lib/audio module regains a raw
  provider fetch.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-15 17:47:30 +08:00
wyucandClaude Opus 4.8 ee79762ef9 fix(access-code): expire tokens server-side and throttle verification behind a trusted proxy (#1513)
When ACCESS_CODE is set, verification tokens (timestamp.HMAC) never expired
because neither verifier checked the timestamp, and POST /api/access-code/verify
had no attempt throttling.

- Enforce a 7-day token lifetime in a shared, Edge-safe module used by both the
  Node verifier and the middleware Web-Crypto verifier, and reject non-canonical
  signatures in both.
- Rate-limit verification only when the client identity is trusted
  (TRUST_PROXY_HEADERS=true): a per-client sliding window (10 failures / 60s)
  whose attempt is reserved atomically at check time, returning 429 with
  Retry-After. Without a trusted proxy the app cannot attribute requests to a
  client, so no shared throttle is applied — a long random ACCESS_CODE is the
  protection, and a warning is logged when it is short.
- Store bounded, copied identity keys so a large forwarding header cannot retain
  memory.
- Document the behavior in .env.example, README, and configuration docs.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-15 17:47:14 +08:00
DDLandwyuc 0b2e502811 fix(classroom): keep transient load failures off the not-found card (#1485)
* fix(classroom): keep transient load failures off the not-found card

/api/classroom 5xx and network errors used to become null, so ClassroomSurface treated them like a missing course and rendered the terminal not-found claim with no retry. Classify fetch answers as found/absent/unavailable and only show not-found on a positive 404/410 miss.

Fixes #1450

* fix(classroom): handle terminal load states

---------

Co-authored-by: wyuc <wang-yc24@mails.tsinghua.edu.cn>
2026-09-15 16:25:21 +08:00
wyuc c605e9e6ef feat(persistence): turn on the server-owned asset lifecycle and release assets on course deletion (#1007 amendment, part 2) (#1473)
App wiring for the @openmaic/storage 0.31.0 lifecycle: reference tracking and document references are paired unconditionally and declared at startup, course deletion withdraws references inside the tombstone transaction, ASSET_PENDING_TTL_MS configures the pending window, and the dead client-side reclamation code is removed.
2026-09-15 09:55:26 +02:00
90cf53f142 fix(upload): resolve the generic Office MIME (application/vnd.ms-office) via filename extension (#1498)
* fix(upload): resolve the generic Office MIME via filename extension (#1497)

On Linux, Chrome derives File.type from the XDG shared-mime-info database.
Older or trimmed databases (e.g. Kylin OS V10) map every OOXML extension to
the generic container `application/vnd.ms-office` instead of the concrete
format MIME, so .pptx/.docx/.xlsx uploads were rejected with "当前解析器不支持该格式"
on the generation toolbar and with 415 unsupported_mime on POST /api/materials
— while the same files upload fine on macOS/Windows.

Treat `application/vnd.ms-office` as a generic upload fallback alongside the
zip family, so the filename extension decides the concrete format:

- lib/document/mime.ts: add it to GENERIC_DOCUMENT_MIME_TYPES. This covers
  the toolbar's provider-whitelist check and the extract-document API gate
  (both call normalizeDocumentMimeType). It must NOT go into per-format
  aliasMimes — canonicalFromAlias iterates DOCUMENT_FORMATS in declaration
  order, so a shared alias would misroute x.pptx to application/msword.
- lib/workbench/material-upload-policy.ts: new resolveWorkbenchMaterialMime()
  grants missing/octet-stream/zip/vnd.ms-office types the same extension
  fallback the document path already had (the workbench gate previously
  rejected empty and zip-family types outright on every OS).
- app/api/materials/route.ts + session-store.ts: gate, store, and send the
  resolved MIME instead of the raw browser value.

The spoofing surface is unchanged: a specific but unsupported MIME still
falls through verbatim for the whitelist to reject, and an unknown extension
keeps the generic MIME. Full suite: 7992 passed.

Closes #1497

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

* fix(review): log the declared mime, harden the fallback chain, pin adversarial cases

Review follow-ups on #1498 (no correctness or security findings — hygiene only):

- materials route: the reject/context log now carries declaredMime alongside
  the resolved mime whenever they differ, so a generic-header spoof stays
  visible in upload failure logs instead of collapsing to the resolved type.
- uploadWorkbenchMaterial: the returned record's mimeType falls back to the
  locally resolved mime before the raw browser value, which may still be the
  generic Office container if the server echo is ever absent.
- material-upload-policy: export WORKBENCH_MATERIAL_EXTENSIONS and add a
  drift guard asserting every accepted extension resolves (via a generic
  MIME) to a whitelisted type — a hand-maintained map entry going missing
  now fails tests instead of silently 415ing.
- tests: pin the legacy-.ppt provider split (mineru rejects, mineru-cloud
  accepts), uppercase-extension resolution, and 415-before-400 precedence
  for a generic mime with no filename.

Full suite: 7996 passed.

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

* fix(upload): support WPS Office MIME aliases

---------

Co-authored-by: Claude Code <noreply@anthropic.com>
Co-authored-by: wyuc <wang-yc24@mails.tsinghua.edu.cn>
2026-09-14 17:53:08 +02:00
wyuc 68aed2932a fix(proxy-media): connect only to addresses the SSRF guard validated (#1488)
* fix(proxy-media): connect only to addresses the SSRF guard validated

The route validated a URL with validateUrlForSSRF and then handed the
hostname to fetch(), which resolved it again at connect time. An
attacker-controlled name could answer a public address to the guard and an
internal address (loopback, RFC1918, link-local, cloud metadata) to the
socket, and every redirect hop repeated the same validate-then-refetch gap.

Add a shared policy-aware pinned dispatcher
(lib/server/pinned-dispatcher.ts) whose connect.lookup resolves all
addresses, runs the same local-network policy as validateUrlForSSRF over
every answer (cloud metadata is always blocked, even with
ALLOW_LOCAL_NETWORKS=true), and passes only the vetted list to the
connection. proxy-media creates one dispatcher per request and uses it on
every hop, mapping a connect-time refusal to 403 INVALID_URL instead of a
generic 500. The agent-runtime pinned agent now reuses the same builder
with its strict assertSafeIp policy unchanged.

The other routes that validate with validateUrlForSSRF and then hand the
URL to provider SDKs (parse-pdf, transcription, generate/*, verify-*,
probe-models, azure-voices) are not covered; that is a follow-up.

Tests: new tests/server/proxy-media-rebinding.test.ts covers the two-answer
rebind, the ALLOW_LOCAL_NETWORKS opt-in through the pinned path, metadata
refusal under the opt-in, and a redirect-hop rebind.

* fix(proxy-media): destroy the per-request dispatcher and align non-unicast policy across layers

Follow-up hardening for the proxy-media SSRF pin.

1. Handler hang: the route awaited `dispatcher.close()` in `finally`.
   undici's `close()` drains in-flight requests, and the response body is
   intentionally left unread on the 30x (`redirect: 'manual'`), non-2xx,
   oversize and connect-refusal paths, so an upstream that keeps its body open
   never completes and the POST never settles. Tear the pool down with a
   non-awaited `destroy()` instead; the response blob is already in memory by
   the time `finally` runs. Regression tests drive a loopback server that
   answers 404, a 30x, and an oversize 200 while trickling its body forever and
   assert the route settles in well under a second.

2. Headline rebinding test sensitivity: install an attacker-controlled global
   dispatcher (`setGlobalDispatcher` plus a lookup that always answers
   loopback) in `beforeEach` so the unpinned fetch path really reaches the
   loopback server. Removing the route's dispatcher now fails on
   `internal.requests() === 0` rather than on an unrelated DNS resolution
   error. The positive control also asserts the pinned connect lookup was
   invoked, so a runtime that silently ignores `init.dispatcher` goes red.

3. Non-unicast policy parity: `validateUrlForSSRF` only classified IP-literal
   URLs through `isPrivateIP`, so a literal in CGNAT 100.64/10 or an IANA
   reserved/special-use range (240/4, 198.18/15, the TEST-NET blocks,
   192.0.0.0/24, multicast, broadcast) passed the URL layer while the same
   address reached through a hostname was refused at connect time. Both layers
   now share `isNeverAllowedRange`, applied to IP-literal URLs and to every
   resolved answer, with or without `ALLOW_LOCAL_NETWORKS`.
   `connectionAddressBlockReason` drops its blunt `range() !== 'unicast'` test
   so the two layers agree exactly, while private, loopback and link-local
   answers keep following the opt-in.

   Behaviour change: a hostname resolving into CGNAT 100.64/10 or an IANA
   reserved/special-use range is now refused (403 INVALID_URL) where it was
   previously proxied, including under `ALLOW_LOCAL_NETWORKS=true`, since these
   ranges are never legitimate proxy targets.

4. Do not echo an unparseable connect-time answer in the refusal. Return the
   generic block message instead of `Unable to classify network address:
   <value>`; the route relays that message to the client in a 403 body.

Tests: new trickle-body regression tests and CGNAT/reserved cases for both
literals and resolved answers, plus a `connectionAddressBlockReason` parity
suite. One existing fixture is updated from the IANA documentation prefix
2001:db8::/32 (reserved) to a real public prefix 2001:4860::/32.

* fix(ssrf-guard): let the local-network opt-in cover CGNAT and decode local-use NAT64 prefixes
2026-09-13 18:00:56 +02:00
wyuc 1fd4348f21 fix(classroom): generate classroom ids server-side and create files exclusively (#1489)
* fix(classroom): generate classroom ids server-side and create files exclusively

The legacy file-backed classroom store accepted a caller-chosen stage.id and
renamed a temp file over the target, so anyone who knew a public share URL
could POST the same id and replace that classroom's content. POST
/api/classroom now mints the id itself (nanoid, 10 characters, matching the
generation pipeline), ignores any client-supplied stage.id, and rebinds the
stage and every scene to the generated id. Both the route and
classroom-generation persist through a new exclusive create that hard-links a
temp file into place, so an existing classroom is never replaced; EEXIST is
retried with a fresh id a bounded number of times and then reported as 409.

Unchanged: the server-backed persistence path (app/api/persistence,
lib/persistence), the read/GET path, middleware, and the access-code gate;
writeJsonFileAtomic keeps its overwrite semantics for the other callers.
Adds regression tests pinning non-overwrite, the typed EEXIST error, collision
retry/exhaustion, and generation-path exclusivity.

* fix(classroom): fall back to exclusive open without hard links and reserve ids before media generation

writeJsonFileExclusive kept `fs.link` as its only route to the destination, so
classroom creation failed hard on mounts without hard links (gcsfuse, s3fs and
some FUSE gateways return ENOSYS/ENOTSUP/EOPNOTSUPP/EPERM/EXDEV). Keep `link`
as the fast path and, for exactly those codes, fall back to an exclusive `wx`
open: still never replaces an existing file and still maps EEXIST to
ClassroomAlreadyExistsError. Any other code keeps propagating, and the temp
file is always removed.

The generation pipeline wrote media and TTS into
<CLASSROOMS_DIR>/<id>/{media,audio} before the exclusive create, so a collision
would have landed the new classroom's files in an existing classroom's
directory and the retried document's URLs would still point at that other id.
Reserve the id first by exclusively creating the classroom file with a
placeholder document (`reserved: true`, empty scenes). The file is the only
token that atomically covers the whole collision namespace: a classroom created
through POST /api/classroom has a JSON file but no directory, so reserving a
directory would miss it. readClassroom treats a reserved document as absent, so
an in-flight or crashed reservation is never served as an empty classroom. The
retry on EEXIST now happens at reservation time (bounded, before any media),
and the final persist is an ordinary overwrite of the id the process owns, so
the post-media id retry is gone.

Document OPENMAIC_CLASSROOMS_DIR in .env.example, including that
CLASSROOM_JOBS_DIR does not move with it.

* fix(classroom): release an unused reservation when generation fails
2026-09-13 17:49:57 +02:00
LING DUANandwyuc ebf665f316 feat(playback): reference static GenUI components (#1281)
* feat(playback): reference static GenUI components

* feat(playback): gate courseware references

* fix(pi): bound Interactive source HTML before parsing

* fix(pi): bound interactive reference HTML processing

* fix(playback): close interactive reference review gaps

---------

Co-authored-by: wyuc <wang-yc24@mails.tsinghua.edu.cn>
2026-09-11 19:16:10 +02:00
Yizuki_Ameandwyuc c460bceb60 feat(export): preflight render queue availability (#1455)
Co-authored-by: wyuc <wang-yc24@mails.tsinghua.edu.cn>
2026-09-11 14:33:51 +02:00
85ef52d599 feat(media): write generated media through the asset pool under server-backed persistence (#1392)
* feat(media): store generated media in the asset pool when persistence is server-backed

With server-backed persistence the document is durable and shared, but
generated media stayed in the producing browser: the document kept its
gen_img_* / gen_vid_* placeholder and narration kept a browser-derived audio
id. Every new browser that opened such a course re-ran generation for every
slide, and it never converged, because the address of the generated bytes was
never written back into the document.

Under server-backed persistence only, the classic generation chain now stores
bytes in the asset pool first and writes the id the pool allocated into the
document.

- The client bootstrap configures the asset seam alongside the document and
  runtime seams: an HttpAssetStore over the persistence endpoint carrying the
  same credentials the document store carries, marked server-backed. The seam
  preflight now covers all three, so a failure still cannot half-configure
  persistence.
- Image, video and TTS generation commit in one fixed order: provider, pool,
  document, local cache, task. A reference reaches the document only after put
  returned an id, so a document can never name bytes that were not stored. A
  failure before the write-back leaves the placeholder with the provider called
  exactly once; the retry happens on the next owner load.
- The write-back is a per-slot rewrite through mutateDocument, which re-reads
  the current document under the per-stage lock, so it cannot clobber a newer
  scene. The open course is refreshed with the same rewrite without being
  marked dirty.
- "Has this already been generated?" is answered by the document (the slide
  exists and no longer holds the placeholder) instead of by this browser's task
  table.
- The classroom's resume effect fails closed on ownership: only a resolved
  owner starts generation, so a viewer opening a shared course spends nothing.
- The local media and audio tables become a per-tab cache. A failed cache write
  costs a re-download, never the media.

Browser-only mode is unchanged: every new call site sits behind the
server-backed gate, the local tables stay authoritative there, and placeholders
stay in the document.

Rendering and export needed no changes. HttpAssetStore.resolve mints an object
URL exactly as the browser store does, and the export byte resolver was already
pool-first.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XypYpLBtk8DB5nT5jyqZ5D

* fix(classroom): make the generation owner gate a three-outcome rule and apply it everywhere

The gate refused everything but a resolved owner, which read a sidecar that
answered "no ownership fact exists for this course" as a reason to block. That
is the answer a deployment without the sidecar's server-side prerequisites
gives for every course, and the answer a course with no ownership record gives:
in both, there is nobody the operator's budget needs protecting from, and
refusing strands the course's own author behind a question that can never be
answered.

Ownership is now four states over the sidecar's three outcomes. A definite
answer splits into owner and not-owner. An absent record is its own answer,
ownerless, and generation proceeds — the behaviour such a deployment had before
the gate existed. Only the absence of an answer, a transport failure or a load
that has not asked yet, stays unresolved and fails closed: "we could not ask"
must never be read as "nobody owns this". One mapper turns a sidecar result
into that state, and one predicate decides on it.

The workbench classroom pane runs the same resume effect and had no ownership
input at all, so a viewer opening a shared course there could still spend the
budget. It now asks the sidecar once per course, in parallel with its load and
feeding only the generation gate, so its read-only and edit behaviour is
unchanged. The shared progressive-load policy carries the gate for it, with
both new inputs required rather than defaulted so a future caller cannot omit
them into an open budget. Its stale comment claiming ownership could not be
expressed here is corrected.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XypYpLBtk8DB5nT5jyqZ5D

* fix(media): make the write-back survive autosave, arrive before the scene does, and never leak

Independent reviews of the write-back found three ways a durable document could
still end up naming a placeholder, and two ways the gate that protects the
operator's budget could be walked around.

An autosave round captures the store synchronously and writes that capture, so
a round already in flight when a rewrite landed wrote the placeholder straight
back over the allocated id, and nothing marked the store dirty again to correct
it. The rewrite now marks the units it changed, which leaves a corrective flush
queued behind the stale one; re-saving a scene that already holds the id is
idempotent, losing the id is not.

Media is generated from outlines in parallel with scene content and usually
finishes first, so the slide that will carry the placeholder does not exist yet
and the write-back has nothing to rewrite. That was the ordinary path, not a
tail case, and its result was discarded: the task was marked done, the scene was
added afterwards with its placeholder intact, and a second pass in the same run
could call the provider again. The allocation is now held under the placeholder
— which also answers the skip test, so nothing pays twice — and applied when
that scene is committed, before its first save. One complete pass now leaves no
placeholder behind.

A failed commit used to abandon what it had already allocated. A poster upload
that failed threw away a stored video and sent the retry to submit the most
expensive job in the system again; a rejected write-back left registry rows that
name bytes nothing references, which the byte collector cannot reclaim because
it only collects blobs no row names. A poster failure now costs the poster, and
a write-back that reached nothing reclaims what it allocated. A partial write is
left alone, because the document already names it.

The ownership gate is fail-closed again. Treating the sidecar's 404 as
permission was wrong: the client cannot tell "this course has no owner" from
"this deployment told me nothing", so a visitor who opened a shared course could
bill the operator. The root cause was the sidecar itself, which gated on the
agent runtime although every persisted course has an owner regardless — the
persistence route resolves one for every request. It now gates on server
persistence, so the configuration that made 404 the universal answer has real
ownership facts to report, and the gate can refuse everything but a named owner.

Retry affordances answered to no gate at all. A viewer of a shared course with
one failed image was shown a Retry button that called the provider. Both retry
entry points and every surface that draws them now read one shared permission,
so what is offered and what is allowed are the same value.

Also: narration regeneration no longer pretends it can replace bytes behind a
live id — the exclusivity proof that would allow it is refused by construction
once references leave the browser, so it forks to a fresh id and says so; the
"already generated" test lets a finished deck answer from the document alone,
since scene order stops identifying an outline once slides are inserted or
deleted; stored assets record a specific media type rather than a generic
transfer type; the pane no longer asks the sidecar in browser-only mode; and the
funnel's docstring now states what the per-stage lock actually guarantees, which
is same-browser serialization and not a cross-browser compare-and-swap.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XypYpLBtk8DB5nT5jyqZ5D

* fix(media): park allocations in the deciding turn, never reclaim on an ambiguous write

A delta review of the write-back found the first-pass fix still had a window,
and the reclamation it added could delete media the document already names.

The allocation was parked after an awaited local cache write. A scene committed
in that window reconciled against a registry that did not hold it yet, so the
document kept the placeholder — and the entry recorded a moment later then
answered the skip test as "already handled", so nothing could correct it. Parking
now happens inside the write-back, in the same synchronous turn as the decision
that nothing could take the reference; no await separates the live check from the
park. Allocations parked by an earlier pass are handed to their slides at the
start of the next one, before anything decides what still needs generating, so a
held allocation whose scene has since arrived becomes a rewrite rather than an
answer.

Reclaiming on a rejected write was unsound: a rejection does not prove the server
did not apply the write, so deleting the asset could break the scene that now
names it. The funnel decides instead, and says so: it reclaims only when no store
write was ever issued and nothing took the reference. Anything else is placed if
its slide exists and parked if it does not, so the next pass reuses the bytes
instead of paying for them again. When a write fails after part of it landed, the
live store is brought up to the document before the error is rethrown — otherwise
the next ordinary flush would overwrite the half that did land, with the ids
deliberately not reclaimed.

Parked allocations are now cleared with the course. Classic placeholders are
reused across runs, so one surviving an interrupted run would be handed to a
different slide of the next deck: the previous picture, on a slide whose provider
was never asked. Both classroom surfaces clear the arriving course, the deletion
cascade clears the deleted one, and clearing the database clears them all.

Two more ways generation could start without asking the gate are closed. An
overlapping pass — an outline retry re-enters generation with every outline while
the first is still working — re-requested elements whose provider call was
already in flight; a task that is not done is an answered request, not an
unanswered one. And narration regeneration in the timeline editor called the TTS
provider and allocated a pool asset with no ownership check at all; it now reads
the same permission, which withholds both the per-line and whole-timeline
controls and refuses the call.

Finally, a pane opened during the stage-link availability gap recorded the
sidecar's 404 for a course that was moments from existing and never asked again,
leaving the real owner locked out of generation until it remounted. Ownership is
re-fetched once the document becomes available; the gate stays closed until an
answer arrives, so asking again can only open it for someone entitled to it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XypYpLBtk8DB5nT5jyqZ5D

* fix(media): make the asset routes reachable, and stale snapshots harmless

A full-branch audit found that the deployment this project documents could not
store a single generated asset, and that several routes into durable storage
could still write a placeholder over a reference that had already landed.

The persistence route sent asset requests through the development
authenticator, which refuses outright in a production build that has not
explicitly opted into it — and the documented server-persistence recipe
produces exactly that build. Every store and every read answered 401, so images
and video failed on every slide while re-billing the provider on each retry, and
a narration failure stopped the deck at its first slide. Assets live in one
shared partition by design, so there was never anything per-caller for that
authenticator to decide: the route now resolves the asset principal itself,
alongside the owner it already resolves for documents. Runtime sessions are
genuinely per-learner and keep the development authenticator until real session
verification replaces it. And narration that cannot be stored no longer fails
its scene: the line stays unvoiced and retryable, which is what an image that
cannot be stored does to its slide.

Placeholders could also come back from behind. A queued autosave's snapshot, an
editor-history entry replayed by an undo, the departing save a course switch
flushes — each captures content at its own moment, and any of those moments can
predate a write-back. Point fixes at each producer would leave the next producer
to rediscover the bug, so the check lives at the write boundary every producer
passes through, and the allocation record it consults now outlives the parked
queue: a placeholder whose rewrite landed long ago is exactly the case it
catches.

Two ways generation could be lost or repeated are closed. A pass now claims the
elements it will reach and releases them however it ends, so an overlapping pass
stands down while an aborted one strands nothing — previously its tasks stayed
`pending` and every later pass skipped them with no retry control to recover
them. And the media abort controller is aborted before being replaced, so a
superseded pass stops calling providers instead of running on for a course the
user has left.

The remaining two are narrower. The workbench pane asks for ownership only after
a document load succeeds, and after every later one, mirroring the page route:
the load is what creates the ownership row the first time a course is opened, so
asking beforehand asked about a course that did not exist yet and locked its
author out for the mount. And the ownership gate on the timeline editor now
withholds narration regeneration alone; listening back to existing narration and
seeing whether a line has any spend nothing and stay available.

Known limitation, unchanged and now stated plainly in the comments that used to
point at it as a solution: nothing reclaims an unreferenced pool asset. The
registry sweep is written but not wired up, and the byte collector only reclaims
blobs no registry row names, so every narration regeneration and every abandoned
allocation leaves storage behind.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XypYpLBtk8DB5nT5jyqZ5D

* fix(media): gate asset mutations, and make claims and allocation records survive a handoff

Opening the asset routes opened all of them. Reads and allocations are meant to
be as open as document reads and creates already are, but no authorization hook
was supplied, so the handler's default admitted PUT and DELETE too — and those
scope by principal key alone, which is one shared constant. Any caller who
learned an id, and a document read hands out every id its slides name, could
overwrite or destroy another author's media. Mutations now require the
deployment's credential, which in a production build without the development-auth
opt-in means they are refused outright; reads and allocations stay open. The
route comment says what the posture is and what it is not: the deployment-level
fence is the access code, and no per-principal quota is configured. The client's
own reclaim is best effort to match — losing an argument about deleting an asset
must not cost a task its retry, and the bytes are left for server-side
reclamation.

The pass claim could not survive the handoff it was written for. A retry aborts
the live media pass and starts its replacement in the same synchronous block,
long before the aborted pass's cleanup runs, so the replacement saw every element
still claimed, collected nothing, and returned — leaving each unreached element
at pending with nobody coming back for it and no retry control to recover it,
which is the exact failure the claim was introduced to prevent. A claim now
carries its pass's signal and is retired the moment that signal aborts, and a
pass releases only claims it still owns, so a late unwind cannot take its
replacement's work. Claims are also acquired at the single point every request
passes through, so a single-task retry participates too — previously a retry
awaiting its provider was invisible to a pass starting alongside it and both
called it.

The allocation record could outlive the bytes it named. It was written before the
write-back attempted anything and survived the reclaim that followed a failure,
so when the slide finally arrived the write boundary stamped a deleted id into
the document — and the placeholder it replaced was gone, which reads as already
generated and stops anything from retrying. The record is now written only where
the allocation is retained, and forgotten wherever a reclaim removes the bytes,
including the narration rollback path.

The tests follow. The route test drives the real storage handler against an
in-memory registry instead of a stub, so it can see what the resolved principal
is then allowed to do; the handoff test performs a real abort mid-pass rather
than starting from an already-aborted signal; and the guards that could only
assert file layout now assert the property they care about, or have been replaced
by behaviour.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XypYpLBtk8DB5nT5jyqZ5D

* fix(media): make media passes serial per course instead of tracking element ownership

Three rounds of per-element claims each produced a new way to lose an element.
Whole-pass reservations swallowed a Retry for an element the same pass had
already failed, leaving it pending with the affordance gone. Retiring a claim by
its signal freed an element whose commit was still uploading, so the replacement
pass paid for it twice. A claim held for a failed element stranded its retry. The
bookkeeping is the defect: every refinement of "who owns this element right now"
answered the question at a moment when the answer was already stale.

Passes for one course are now serial. A replacement aborts its predecessor, as
before, and then waits for it to settle before collecting. That removes the
question entirely: a commit already under way finishes — its bytes stored and its
reference written, so the new pass sees a resolved slide and skips it — and an
element the aborted pass never reached is still a placeholder and gets collected
like any other. The claim set, the reservations, the signal retirement and the
identity-checked release are all gone.

The task table is consulted for one thing only: an element that is generating
right now is a single-element retry running alongside the pass, and taking it too
would pay twice. Pending is deliberately not a skip reason — it means a pass once
intended to reach an element, which an abandoned pass leaves behind with nobody
acting on it, and reading that as answered is what stranded elements before. A
retry runs concurrently with a pass, because a pass never revisits an element it
has processed, and it re-reads the task after its own await and refuses before
touching it: marking first and refusing afterwards destroyed the failed state
that draws the affordance.

Browser-only mode is back to exactly what it was. The abort is now conditional,
the waiting does not apply, and the original status-based skip is restored
verbatim. Two baseline lines remain changed in each of the two files, and both
are behind a server-backed fork whose else-branch is the original.

Two smaller things. The allocation record becomes visible when a write goes on
the wire rather than when the round trip ends, and the write boundary reconciles
under the document lock rather than before it — a save queued during a write-back
was otherwise captured with the placeholder and, for a course the user had left,
had no corrective flush to follow. And the comments that said a refused reclaim
leaves its bytes for server-side reclamation were wrong: nothing collects them,
because the registry entry still names its blob and the sweep that would remove
it is not wired up. They now say the bytes leak.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XypYpLBtk8DB5nT5jyqZ5D

* fix(media): make a deferred pass re-earn its right to run, and bound the commit it waits on

Serializing passes moved their body out of the block that launched them, and
three things followed from that.

A pass now wakes when its predecessor settles, which can be after the user has
left the course. It enqueued before it looked at its signal, into a task table
keyed by element id alone — and placeholder ids are not unique across courses,
which is why the classroom clears that table on arrival. So a departing course's
pass seeded the arriving course's table with tasks carrying the wrong stage id,
and a Retry routes by that id: the reference went into the wrong document. A pass
now re-validates after the wait, before touching anything shared.

The same lateness broke the skip test. `documentSkipIndex` answers only while the
live store is on the pass's stage, and returning nothing put the collection loop
on the browser-only rule — a silent demotion from "the document is the authority"
to "this browser's task table is", on exactly the path where that table has just
been cleared. Every element the predecessor had committed was collected again,
paid for again, and its second write-back found no placeholder to rewrite, so its
bytes were parked where nothing will ever reference them. In server-backed mode
an unreadable document now means the pass stands down.

And waiting was unbounded. A commit is uncancellable: the asset client takes no
signal, and a document write cannot be half-undone. One stalled upload therefore
froze the course's media generation for the session — the replacement never
collected, the element sat on a skeleton that draws no Retry, and only a reload
recovered. The pass's signal is now threaded into the media proxy fetch, and the
commit is bounded by a deadline. The deadline is on the wait, not the work: the
commit carries on, and if it lands late the document simply ends up correct,
while the element becomes retryable and the queue moves on.

The tests that were meant to pin the previous round were not sensitive to it.
Two asserted end states where the mechanism only changes ordering, and one of
them rigged the document read so the assertion held whether or not the pass had
waited; a third covered half of what it claimed. They now observe the ordering
directly — nothing is issued while another pass for the course is working; in
browser-only mode a second pass reaches its provider immediately — and the
reconciliation under the document lock has a test that fails when it moves back
outside it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XypYpLBtk8DB5nT5jyqZ5D

* revert(media): drop the commit deadline and the abortable download

The deadline bought less than it cost. Abandoning a commit after two minutes
makes the element retryable while the real commit is still running, so a Retry
starts a second commit for the same placeholder against the first: two provider
calls, two allocations, and whichever lands second stamps its result over the
other's task by element id. The allocation record is keyed by placeholder, so the
loser's cleanup erases the winner's record, and the write boundary then puts the
raw placeholder back into the document. That is the overlap serial passes were
built to remove, reopened through the one door serialization never covered.

So a stalled commit holds the course's media queue until it settles or the page
is reloaded, and that is written down rather than papered over. The wait is
unbounded on purpose: every ceiling on it turns out to be a way of running two
commits for one element.

Threading the pass signal into the download was also a mistake, in the other
direction. The provider call that produced the URL has already been billed, so
cancelling the download throws away work that is paid for — and the shared proxy
cache records a cancelled request as a transient failure against that URL, which
after three of them blocks it for every consumer in the session. Browser-only
mode never asked for this: it had no way to observe an abort there, which is
exactly why the bytes were kept. The signal is gone from the download again, and
`fetchAsBlob` is byte-for-byte what it was before this branch.

The regression guard for the stranded-element rule is restored alongside the
timing test that was meant to supersede it. It catches a different rule — a task
left pending being read as answered — and nothing else does: making the pass skip
pending leaves every other suite green.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XypYpLBtk8DB5nT5jyqZ5D

* fix(media): stop asking the pool for refs it never issued, bound it, and adopt cached bytes

Four things a deployment found once this was running for real.

A reference this application mints itself — a generation placeholder, a derived
narration key — was never in the pool, because the pool allocates every id it
holds. Asking anyway used to be an IndexedDB miss; once the pool is server-backed
it is a request that answers 404, one per element per load, forever on a course
that still holds placeholders. Every lease and probe now checks first. The check
is a negative test on shapes this application owns, not an id validator: the
pool's id domain stays unconstrained, and anything that is not one of ours is
still asked about.

The asset store can bound how much one principal holds, and enforces it inside
the write transaction, but nothing ever passed the number. It does now, with a
default rather than an opt-in: allocation is reachable by any caller a
deployment admits, and with one shared principal an unbounded store is
unbounded database growth with no operator-visible brake.

Refusing asset mutations to unauthenticated callers was not enough, because
every authenticated caller resolves to that same shared principal — so
authentication decided nothing, and any signed-in visitor could delete any id
they learned. Since this branch began storing media the registry is the only
copy a course has. Replacing and deleting are now refused to everyone, and the
browser no longer tries: an entry nothing references waits for server-side
reclamation instead. What a browser must still do is forget its own record of an
allocation that reached nothing, or a later save would stamp an id the document
has no reason to trust.

And a course generated before any of this holds placeholders in its document
with its bytes only in the author's browser. Those bytes are paid for, so the
author's next load converts them — stored to the pool and written back through
the ordinary commit path, with no provider call — instead of buying them again.
A row that records only a hosted URL is treated as absent: that URL is the
provider's address, not something a document may hold.

One renderer expectation moved with this. An untracked placeholder used to paint
as pending on first render because asking the pool left a lease in flight; it
settled to disabled a moment later either way, and now says so from the start.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XypYpLBtk8DB5nT5jyqZ5D

* fix(media): surface a full store as a refusal and convert legacy narration

A quota refusal reached the browser as HTTP 500 with a generic message, which
reads as a transient failure: the element kept a Retry that would pay a
provider again and be refused again. The store raises the contract's own error
and the handler maps it to 507, but the store answering a request is not always
built by the same bundle as the handler -- the persistence provider is reached
from the route bundle and from instrumentation, which is why its state lives on
a Symbol.for global -- and `instanceof` is false across that boundary while the
declared code is still right. Classify on the code as well as the class, and
make the code a permanent, persisted refusal in the browser: recorded locally so
it survives a reload, shown as "storage is full", and refused by the retry entry
point so a stale button cannot buy a second generation. Every other storage
failure stays retryable.

Convert what a pre-server-backed course still holds. Generated media is adopted
under either key this application has used for it -- the placeholder, and the
allocated id of a course converted once and later rolled back -- instead of only
the first. Narration is converted by a load-time pass over the open course's
speech actions, since nothing re-enters generation for an action that already
has an id: bytes to the pool, id written back through a funnel that mirrors the
media one, owner-only and server-backed-only. A line whose bytes are in no
browser is left alone rather than re-synthesized.

Also: the pool guard is now a positive `ast_` test rather than an enumeration of
the shapes we mint (imports never reach the pool, so this is safe in both
modes); the slide ref collection is an exported pure function so its four lease
sites are covered behaviourally; ASSET_QUOTA_BYTES treats every spelling of zero
as opting out and refuses a malformed value at startup instead of falling back;
the abort signal is re-checked after the cache read, before an uncancellable
commit; and the unused `removeAsset` and pool `replace` surfaces are gone.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XypYpLBtk8DB5nT5jyqZ5D

* chore(storage): release 0.29.1

The asset HTTP handler now recognises a store refusal by the contract code it
declares as well as by its class, so a quota refusal raised in another module
realm answers 507 instead of 500. Same contract, stricter recognition, no API
change: a patch.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XypYpLBtk8DB5nT5jyqZ5D

* fix(media): make a full store recoverable and adoption course-safe

Narration adoption read the local audio row by its derived key alone. That key
carries no stage id and the table is keyed by id alone, so two courses can mint
the same one -- a PPTX import numbers its scenes and actions deterministically,
which gives every imported deck's first slide `tts_s1_speech-scene-p1`. Locally
a collision only means one course plays another's clip in one browser; adopting
it wrote that clip into the shared document permanently, for every device and
every visitor. A row that names a course is now adopted only into that course,
and a row from before that column existed only when the text it recorded is the
text of the action being converted.

A full asset store was made permanent last round, which was wrong three times
over: it overwrote the refused bytes with an empty blob -- on the conversion
path that row is a course's only copy of its own media -- it kept sending the
rest of the deck to a provider against a ceiling it already knew was reached,
and it left no way back once an operator raised that ceiling. A full store is
neither the content's fault nor the configuration's, so it is now its own case:
the bytes are kept, the pass stops at the first refusal, and the element shows
the reason together with a Retry that re-attempts the upload from those bytes.
Nothing retries automatically, so no one is re-billed.

The narration write-back now reaches the write boundary every producer of a
durable write passes through, not only the dirty mark: adoption never deletes
the derived row, so a snapshot that reverts the rewrite is adopted again on the
next load and allocates a fresh asset every time. Adoption is also mounted by
both classroom surfaces rather than one, takes the course's abort signal, and
re-validates that this browser still has the course open before each write.

ASSET_QUOTA_BYTES is validated from instrumentation, where the README and the
docstring already claimed it was: its only other consumer is lazy and memoised,
so a malformed ceiling let the process boot and then failed every persistence
request, documents and runtime included.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XypYpLBtk8DB5nT5jyqZ5D

* fix(media): remember a full store per course, and never lose retained bytes

A stopped pass left the elements it never reached as placeholders with no
persisted record -- deliberately, since nothing was attempted for them. But
that left the next load with no reason not to try: it called a provider for the
next placeholder and was refused at exactly the same point, once per reload,
indefinitely. A full store is not a property of any slide. It belongs to the
deployment and changes for reasons the document knows nothing about, so it is
now remembered once per course in the browser's device KV. A pass that finds
the marker stands down before spending anything and leaves every placeholder
its "storage is full" state and its Retry; the first upload that succeeds
clears it and the next pass runs normally.

Narration adoption latched per course so it runs once per load, and the latch
outlived the abort that leaving a course performs. On a surface that stays
mounted across switches -- the workbench pane is one component for every course
it shows -- owner course A, visitor course B, then back to A skipped exactly
the clips the abort had cut off, and nothing else converts them. The latch is
released with the abort now, and a course adopts one run at a time so a
re-entry cannot hand a clip a second allocation while the previous run's
uncancellable tail is still settling.

A quota-blocked element retried into a network error or a 500 lost the bytes
that were kept for it: the retry deleted the row before attempting the upload
and wrote no replacement for an error carrying no structured code, so the next
retry went back to a provider for media this browser had a moment earlier. The
row now survives until an upload succeeds, the failure handler keeps whatever
bytes the attempt was given, and the retry asks the question a pass asks --
does this browser already hold bytes for this element -- rather than reading an
error code that a second failure has already overwritten.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XypYpLBtk8DB5nT5jyqZ5D

* fix(media): adopt real legacy narration, queue re-entries, report attempt outcomes

Narration adoption admitted a stage-less row only when the text it recorded
matched the action being converted. Both of those columns were added to the
local audio table by the very change that moved narration onto allocated ids,
so a row still carrying a derived key has neither: the rule refused every real
pre-allocation course and passed only on fixtures built from post-allocation
rows. What the row cannot say, the key can. A derived key names two clips only
when two courses share a scene order and an action id, and an action id repeats
only when something other than the generator minted it -- an import numbers
them by slide position. So a key built from a generated action id is adopted on
that basis, a key an import could have reproduced still needs matching text,
and a row that names another course is refused however unique its key looks.

Handing a re-entering caller the adoption run already in flight undid the latch
release it was paired with: that run is bound to the signal the departure just
aborted, so it stops at its next clip while the caller -- which has the course
open and a live signal -- is told the work is done, and an effect replayed as
mount, cleanup, mount adopts nothing at all. A later caller now waits for the
uncancellable tail and scans again, which costs a lookup on a course that has
nothing left and finishes the clips the abort cut off on one that does.

One attempt at an element now reports both facts its callers need instead of a
bare boolean: whether the store refused it for room, and whether bytes actually
reached the store. Leaving a course clears the task table, so a retry that
landed afterwards read "no failed task" as success and deleted the row holding
the only copy of the media. Nothing is inferred from that table any more.

Reading the localStorage property can throw where storage is denied by policy,
typeof included, so the availability check moved inside the guard: this metadata
is best-effort, and a rejection here strands a generation pass that has already
enqueued its tasks.

A retry is never blocked by the per-course "store is full" marker, but a retry
that is refused again re-sets it, and adoption now reads and writes the same
marker rather than issuing one refused upload per clip on every load. The two
canvas element renderers and both thumbnail renderers show the reason beside
the Retry, so a full store does not look like an ordinary failure.

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

* fix(media): probe a full store instead of standing down, and pair notices with a Retry

Narration adoption was given both halves of the per-course "the store is full"
marker last round: it stood down when the marker was set, and it set the marker
when its own upload was refused for room. Those halves are only safe together
if something can lift the marker, and for adoption nothing could. It has no
affordance of its own, it stood down before reaching its own clear, the media
pass returns before its marker gate when there is nothing to generate -- so a
narration-only deck, or one whose slides are already satisfied, painted no
storage-full element and offered no Retry -- and narration generated rather
than adopted allocates directly rather than through the media commit. The
course's cached narration was then lost for good, where before it converted on
the first load after the ceiling was raised.

The gate is a probe now. A marked course attempts exactly one clip per load:
refused, it stops and the marker stands, which costs what standing down cost;
stored, it lifts the marker and finishes the course. Adoption spends no
provider money, so the whole cost of probing a store that is still full is one
refused upload. Generated narration lifts the marker too.

The three surfaces that gained a failure notice last round drew it for any
failure with a reason, including the one refusal that is reachable without
server-backed persistence, so a browser-only deck painted something it had not
painted before. The notice is drawn beside a Retry and nowhere else, which is
what it was added for and what leaves browser-only output unchanged. Both are
now asserted through the render harness the surface matrix already had.

A caller arriving while a rescan is queued shares it rather than appending
another. One rescan converts whatever the run in flight left and every later
one would find an allocated id on every action, so a chain bought nothing and
turned a single stalled upload into a course that never adopts again.

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

* fix(media): treat a refusal for room as a fact about one clip, not the deck

The asset store checks each write against the headroom it has left, so a store
that refuses a long opening clip can still hold every short clip behind it.
Narration adoption assumed the opposite: it broke the deck at the first refusal
and then re-attempted that same first clip on every later load, because the
document names it first. A deck whose longest clip exceeds current headroom
therefore never converted the clips that would have fit, with no affordance to
recover it -- the state the probe was introduced to remove, reached through a
narrower door.

An unmarked load now attempts every clip, skipping the ones that do not fit,
and remembers the condition only if the load ends with clips it still could not
store. A marked load spends its single upload on the smallest clip left rather
than the first one named: that is the clip that answers the question the marker
asks, because if the smallest does not fit nothing does. The media pass keeps
stopping at its first refusal, and for a reason adoption does not share --
every element it attempts costs a provider call.

A rescan several callers share took the newest caller's signal, and the newest
caller is not necessarily the one still there: a surface that opened a course
and closed it again would stop work a surface still showing that course was
waiting for, and that surface is latched, so it would never ask again. The
shared run now takes a signal that is aborted only once every caller has left.

The comment claiming the shared rescan contains a stalled upload was wrong --
the rescan is chained off the run in flight, so a stalled upload leaves every
caller pending exactly as a chain would. It claims the bounded queue it
actually provides, and the stall is recorded as a limitation.

The failed-state containers took their stacking classes unconditionally, so
markup differed in browser-only mode even though nothing moved on screen. Those
classes are applied only when there is a notice to stack, and the tests assert
the exact class attribute rather than a substring.

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

* fix(media): stop narration adoption writing the media pass's store-full marker

The marker means "do not call a provider for this course". A path is entitled
to write it only if its own refusal cost a provider call, and narration
adoption's refusals cost nothing: it uploads bytes this browser already holds.
The store also checks each write against the headroom it has left, so a clip
that does not fit says nothing about whether a slide's image would. Adoption
was writing it anyway, and one over-long narration clip was therefore enough to
stand a course's entire image pass down on every later load -- on a store that
had just accepted adoption's other clips. The author could still recover each
element by hand, every load, for ever.

Three rounds of narrowing this seam produced a finding each time, so it is
removed rather than narrowed again. Gone: the marker read, the single-clip
probe, the smallest-clip selection, and the up-front read of every row into an
array -- which also retires a sampled-then-stale flag and the retention of a
whole deck's blobs for the length of a run, and returns the loop to streaming
one row at a time.

Adoption's rule is now that every load attempts every clip it holds, once; any
failure skips that clip and the load continues. The noise the coupling was
meant to avoid does not arise, because after the first load the clips still
outstanding are exactly the ones that did not fit -- normally none, or one.

A successful write still clears the marker, and that is a different kind of
statement: a write that went through is a fact this run established, where a
refusal is an inference about what some other write would cost. For a course
whose media needs nothing, adoption and generated narration are also the only
paths that can establish it.

The failure module still documented the deck-wide premise this contradicts. It
now says what is true: the check is per write, and the media pass stops the
deck as a judgement about cost rather than about certainty.

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

* fix(media): bound a full store's cost from the store's own arithmetic

Removing the store-full marker from narration adoption removed its bound too,
and the code then asserted the bound was unnecessary. It is, on a store with
room for most of a deck. On the store the whole mechanism exists for -- the
ceiling reached, nothing fitting -- the outstanding set after every load is the
entire deck, so a thirty-clip course posted thirty full blobs on every load,
indefinitely. Each of those is not a cheap refusal: the bytes are uploaded, the
server hashes the whole payload, and only then takes a per-principal lock and
sums every entry that principal owns before saying no.

The bound needs no flag, no key and nothing carried between loads. The store
asks whether `used + addedBytes` exceeds the ceiling, and `used` only grows
while a run is uploading, so a clip refused for want of room implies every clip
at least that large is refused for the rest of that run. The run keeps the
smallest size it has been refused and skips anything no smaller without
uploading it; a smaller clip is still attempted, because it may fit. A deck the
store refuses entirely now costs one upload per successive size minimum instead
of one per clip, and a deck it has room for costs nothing extra, because
nothing is refused. Only a refusal for room lowers the bar: a dropped
connection says nothing about how much room there is.

The deck-wide certainty premise the failure module retracted last round still
stood verbatim at the site that implements the stand-down. Both copies now say
the same thing: the check is per write, and the pass stops the deck as a
judgement about cost rather than about certainty.

The comment on adoption's marker clear now names its price. Narration of a few
hundred bytes fits in headroom an image does not, so a proven write can let the
next pass buy one more image that is refused again -- bounded at one, and the
price of the alternative being a course whose media never generates again.

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

* docs(media): state the adoption bound exactly, and stop three comments describing the old rule

The comment introducing the in-load bound gave its cost as "at most a handful,
and the first load pays the most". Neither clause is a property of the rule. A
clip is skipped only when something no larger was already refused, so a
fully-refused deck costs one upload per successive size minimum in document
order: one when the clips grow, about ln N for an arbitrary order, and one per
clip when they only shrink -- a long opener followed by terser lines is exactly
that shape. And no load is cheaper than the first, because the bound resets per
run and a refused clip stays outstanding. The comment now says that, and points
at what would make it exactly one for any ordering: the store returning its
remaining headroom in the refusal's existing details channel, which the server
leaves empty today.

Two other comments still described the previous rule -- "attempts every clip it
holds, every load" -- one of them twenty lines above the paragraph that
introduces the bound, in the same block. Both now say what the code does.

The bound's soundness is worth stating where a maintainer will look for it:
quota is charged at full length with no discount for a duplicate, the sum it is
checked against joins entries to blobs so the collector cannot lower it, the
check takes a per-principal lock before summing, and replace and delete are
refused to every browser. Nothing a run can do makes room appear inside it.

One test installed a row implementation and replaced it wholesale a few lines
later, so the first was dead and the survivor dropped the text the first clip's
import-shaped key needs for the ownership rule -- it passed on the coincidence
that the fixture's default text is the action's. Merged into one.

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

* fix(media): keep a Retry from re-buying parked media, and state the store seam once

Five findings from an inline review.

The standalone classroom route asked the ownership sidecar once per load and
recorded only stage ownership when that ask failed. Every non-answer fails
closed, so one transient 5xx left the genuine author with no resume, no Retry
affordance and no legacy narration converted for the rest of the load, with
nothing to change it short of a reload. The failure now records the fail-closed
answer explicitly -- an answer an earlier load established must not outlive the
failure that replaced it -- and an unresolved answer is asked for again, a few
times over a few seconds. A real answer, however unwelcome, is final.

A Retry could pay a provider for media the pool already held. When the bytes
are stored and only the write-back fails in a way that keeps the allocation, it
is parked and no local row exists, because that row is written only after a
successful write-back. Retry now reads the parked queue exactly as the pass
does and re-attempts the write-back: it re-keys the task done when the document
takes it, leaves the entry parked when the slide still does not exist, and
stays failed and retryable when the document refuses again.

Object URLs a parked allocation owns are revoked when the entry is dropped. The
commit path leaves them alone while the entry is parked, because it is then the
only thing holding bytes this tab can render, so a course switch or a stage
deletion was pinning the whole blob for the life of the tab. An entry a slide
has already taken is left alone: the task table is displaying those URLs.

The fallback lookup for cached bytes is a stage-scoped scan, and the keyed
lookup misses for every row the commit path writes, so a pass was materializing
and sorting the course's whole media table once per element. One scan per pass
now, built on the first miss. It is sound and not merely cheaper: an element
asks only for its own placeholder, and every row a pass writes carries the
placeholder of the element that wrote it.

"The store accepted a write, so it is not out of room" was enforced at three
call sites under slightly different conditions, which made it a convention the
next pool write path could silently break. It is stated once, in putAsset, for
the course whose bytes it just stored.

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

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
Co-authored-by: 杨慎 <117187635+cosarah@users.noreply.github.com>
2026-09-11 19:38:08 +08:00
puxiao 2baf616843 fix(materials): sanitize owner id in byte-store key so uploads work on Windows (#1426) 2026-09-11 12:42:22 +02:00
Mahesh Singh 57782f928f fix(tts): reject non-audio 200 responses before storing or billing (#1395) (#1405)
* fix(tts): reject non-audio 200 responses and resolve audioUrl envelopes (#1395)

* fix(tts): address review feedback on #1405 — drop follower, protect raw pcm, test billing invariant
2026-09-10 20:52:35 +02:00
wyucandwyuc 0bab621e09 harden input validation and outbound URL handling (#1387)
* harden input validation and outbound URL handling

- Validate the classroom id on the write path with the same allowlist the
  read path already applies, and assert in the storage layer that a resolved
  classroom file stays inside CLASSROOMS_DIR.
- Apply validateUrlForSSRF at every call site regardless of NODE_ENV; the
  guard already owns the ALLOW_LOCAL_NETWORKS escape hatch, so the extra
  environment condition only disabled the check outside production builds.
  Add a repository-scanning test so a gated call site cannot reappear.
- Re-validate every redirect hop of an outbound provider request through a
  shared transport, mirroring the per-hop loop proxy-media already uses.
- Restrict stored slide HTML to the formatting vocabulary the renderer
  produces, sanitizing at the classroom persistence boundary on both write
  and read, with a separate policy for KaTeX snapshots.
- Refuse the development persistence authenticator under NODE_ENV=production
  unless an explicit opt-in is set, and document it in .env.example.

* drop credential headers on a cross-origin redirect hop

The manual redirect loop reused the caller's init on every hop, so provider
credentials were re-sent to the redirect target even on a different origin.
The platform fetch drops Authorization itself when it follows redirects, so
mirror that: strip authorization, api-key, x-api-key and x-goog-api-key
before a cross-origin hop, matching case-insensitively and preserving the
shape of the caller's headers. Same-origin hops are untouched.

A streaming request body cannot be replayed onto the next hop, so fail with
a clear message rather than sending an empty body.

---------

Co-authored-by: wyuc <dhq1024@proton.me>
2026-09-05 23:56:37 -07:00
Yizuki_Ame eb2cbc701a feat(pro): generate concise conversation titles (#1275)
* feat(storage): add automatic conversation title lifecycle

* feat(agent): generate concise conversation titles

* feat(agent): schedule automatic conversation titles

* fix(agent): harden automatic title lifecycle

* fix(agent): refine conversation title prompt

* fix(agent): harden automatic title boundaries
2026-09-04 10:52:25 -04:00
dd74cfe23b feat(web-search): add Exa provider (#1342)
Co-authored-by: white638 <212083322+white638@users.noreply.github.com>
Co-authored-by: wyuc <wang-yc24@mails.tsinghua.edu.cn>
2026-09-03 06:14:05 -04:00
宙见·星野andwyuc 9b7b42c4c4 fix: normalize GPT Image 2 generation sizes (#1346)
Co-authored-by: wyuc <wang-yc24@mails.tsinghua.edu.cn>
2026-09-03 04:48:42 -04:00
Yizuki_Ameandwyuc 3214cead2c fix(workbench): render LaTeX in assistant messages (#1309)
* fix(workbench): render LaTeX in assistant messages

* fix(workbench): harden streamed math rendering

* fix(workbench): preserve streamed math boundaries

* refactor(workbench): use standard math syntax

* fix(workbench): preserve math in course link labels

* fix(workbench): disambiguate single-dollar math

---------

Co-authored-by: wyuc <wang-yc24@mails.tsinghua.edu.cn>
2026-09-02 02:21:59 -04:00
Yizuki_Ame 9d16e68296 fix(workbench): persist Pro conversation titles (#1273)
* feat(storage): persist manual session titles

* feat(agent): add session title patch route

* fix(workbench): preserve renamed session snapshots

* fix: use typed session title store

* style(storage): format session title tests

* test(storage): align agent session schema contract

* fix(workbench): fence stale session title bootstrap

* fix(workbench): close session title races

* fix(workbench): harden session title consistency

* fix(workbench): preserve unconfirmed session titles

* fix: harden session title reconciliation

* fix: finalize session title consistency

* test(workbench): document session title recency
2026-08-31 04:21:01 -04:00
wyuc dfebbcf33f perf(classroom): speed up classroom loading (media hydration, sidebar thumbnails, media range requests) (#1276)
* perf(classroom): defer non-priority media blob hydration off the load path

Entering a classroom awaited the full mediaFiles restore — one object URL
per restored image/video blob — before the loading gate opened. For
video-heavy courses that is hundreds of MB of IndexedDB materialization
blocking first paint.

Split the restore into two phases:

- The awaited phase now builds metadata-complete task entries but only
  creates object URLs for failed rows (none needed) and for media
  referenced by the scene the classroom opens on (persisted cursor, else
  the first scene). buildRestoredMediaTasks gains an optional
  shouldHydrateBlob predicate, defaulting to eager hydration so existing
  callers are unchanged.
- Remaining blob-backed records hydrate in the background, chunked over
  requestIdleCallback (setTimeout fallback). Each deferred task stays
  'done' — so generation resume never re-runs it — but carries no
  objectUrl yet, which the media resolution state machine already renders
  as a pending skeleton until the URL lands.

Background hydration is guarded per record: a task that was replaced
(classroom switch, regeneration, retry) or that belongs to another stage
is skipped and its freshly minted URLs are revoked immediately.

* perf(classroom): lazy-render slide thumbnails in the playback sidebar

The playback scene sidebar mounted a full SlideCanvas for every slide
scene the moment the classroom opened (and every off-screen video element
opened a preload="metadata" fetch), because SlideThumbnail's existing
visible prop was never passed.

Extract the editor nav rail's near-viewport IntersectionObserver hook to
lib/hooks/use-near-viewport.ts and gate the sidebar's slide thumbnails
through it: only scenes within 200px of the viewport render the live
canvas; the rest show SlideThumbnail's existing placeholder until scrolled
near. The placeholder keeps the same box size, so gating never shifts
layout.

The shared hook now starts hidden instead of eager: with the previous
eager-initial state, opening a long deck still mounted every canvas for a
frame before the observer could flip off-screen items off. The observer's
guaranteed initial delivery flips near-viewport items within a frame; the
no-IntersectionObserver fallback (e.g. jsdom) defers to a microtask so
the effect never synchronously re-renders.

* perf(media): lazy image decoding and HTTP Range support for classroom media

Renderer: BaseImageElement's <img> now carries loading="lazy" and
decoding="async", so thumbnail-heavy surfaces (playback sidebar, editor
nav rail, course cards) no longer fetch and decode every slide image up
front. In-viewport images are unaffected — the browser fetches them
immediately. slideToPng forces eager loading inside its permanently
off-screen snapshot tree, where lazy images would otherwise never fetch
and exports would capture blank slides.

Server: GET /api/classroom-media/[classroomId]/[...path] now answers
single byte-range requests with 206 Partial Content (Content-Range,
Accept-Ranges, correct Content-Length), enabling progressive playback and
seeking for hosted video/audio instead of downloading whole files. Suffix
ranges are supported; unsatisfiable ranges get 416 with the full size;
unsupported units or multi-range sets fall back to the plain 200 full-body
response, which is always a legal answer. Existing caching headers are
kept on every response shape.

* chore(renderer): bump version to 0.1.4 for image lazy-loading change

* fix(classroom): guard deferred hydration against restarted tasks and bound idle waits

A deferred record is only ever 'done' without an objectUrl; a task that
regeneration or retry restarted passes through pending/generating, so skip
those instead of attaching stale persisted bytes. Also give the idle
scheduling a timeout so a busy main thread cannot starve hydration.

* fix(classroom): stop superseded deferred hydration before minting URLs

A restore epoch captured at apply time now gates each idle chunk: loading
another classroom or reloading the same one invalidates any older hydration
loop, so it neither keeps scheduling work for an abandoned classroom nor
attaches bytes read by an older load to a newer load's tasks.

* fix(classroom): keep 416s uncached and classify element-keyed media as priority

A 416 with public immutable caching can poison the media URL for later
valid requests, so range errors now send Cache-Control: no-store. Priority
classification also collects the opening scene's media element ids, since
task lookup binds records keyed stage:<elementId> even when the slide slot
carries a different opaque ref.

* fix(classroom): tie deferred hydration liveness to the classroom load token

The restore epoch only advanced when the next load reached apply, so an
abandoned classroom kept hydrating during the next load's storage/network
phase. Compose the epoch with the load's isCurrent (load token plus effect
cleanup) so navigation stops the loop at the next idle boundary.

* fix(classroom): classify legacy-recovered media before deferring it

Legacy singleton video recovery assigns a placeholderRef only at the end of
the task build, so a record keyed by an allocated id without placeholderRef
was deferred even when it backs the opening scene's gen_vid_* element. Run a
metadata-only build first to learn each record's effective ref and classify
against it, keeping the first visible page's legacy video eager.

* fix(action): wait for deferred video bytes before starting play_video

executePlayVideo treated status done as immediately playable, but a deferred
restore is done without an objectUrl: the renderer shows a skeleton, no
<video> exists, and the later hydration never retriggers play, leaving the
action stuck until the safety timeout. Readiness now follows the renderer's
contract (only done-with-bytes is playable) at the initial check, the
subscription exit, and the post-subscription recheck; the failed skip applies
whether or not a wait happened.

* fix(classroom): include the stage whiteboard in priority media refs

The stage-level whiteboard stays open across standalone classroom switches,
so its media can be visible before any scene is. Classifying it as deferred
left visible whiteboard media pending behind idle hydration chunks.
2026-08-30 03:48:04 +08:00
LING DUANandwyuc 9a28ca39d5 feat(chat): add single PPT element references to Pi (#1224)
* feat(chat): add PPT element references to Pi

* fix(chat): include chart values in reference routing

* fix(chat): harden slide element reference evidence

* fix(playback): clear element reference on scene navigation

* fix(pi): preserve element grounding across child retries

---------

Co-authored-by: wyuc <wang-yc24@mails.tsinghua.edu.cn>
2026-08-29 14:38:15 +08:00
wyucandClaude Fable 5 04621578de 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>
2026-08-27 19:26:25 +08:00
wyuc 98904c1c5d fix(providers): three silent-behavior fixes from the provider audit (#1196)
* fix(extract): fail loudly instead of silently falling back to MinerU Cloud

A request that selects the self-hosted MinerU extractor without a
configured base URL used to fall back to MinerU Cloud whenever a cloud
API key was available, sending documents to a third party without the
operator's knowledge. The cloud fallback now requires an explicit
operator opt-in (ALLOW_MINERU_CLOUD_FALLBACK, default off, documented
in .env.example); otherwise the route answers a 422 that names what was
configured (self-hosted MinerU) and what was unavailable (its base
URL), and points at both remedies.

* fix(media): remove the selectable video provider that has no adapter

The Sora entry in the video provider catalog could be selected in the
settings UI and server config, but had no connectivity or generation
dispatch case, so choosing it failed only at execution time. A catalog
entry that cannot execute is worse than an absent one: the entry, its
type-union member, UI names/icons, store defaults, and the VIDEO_SORA
env mapping are removed. A new catalog test pins that every selectable
video provider dispatches to its own adapter in both switches.

* fix(storage): delete the obsolete no-op storage provider abstraction

getStorageProvider() unconditionally returned a NoopStorageProvider and
the module swallowed every operation into silence, so any caller
believing it had storage got neither bytes nor an error. No real caller
exists anywhere in the repo (only lib/storage/client.ts, a separate
module with a documented null contract and a live caller, remains). The
dead entry point, its type file, and the no-op provider are deleted; a
test pins the removal and that the real client upload helper is kept.
2026-08-27 11:49:23 +08:00
wyuc b8fbac1a54 feat(providers): uniform capability force-off and consistent missing-key contract (#1181)
* feat(providers): uniform capability force-off and consistent missing-key contract

* fix(providers): close force-off bypasses found in review
2026-08-26 10:59:43 +08:00
wyuc 4a9f906a13 feat(media): resolve image, video, and ASR models from server config (#1175)
* feat(media): resolve image, video, and ASR models from server config

The image generation chain never consulted the server-side
IMAGE_<PREFIX>_MODELS config: the route only forwarded the client
x-image-model header and each adapter silently fell back to a hardcoded
vendor default, so a managed provider could generate with a model the
operator never chose. The provider itself also defaulted to a hardcoded
vendor id when the client sent no preference.

- Add resolveImageModel / resolveVideoModel / resolveASRModel and
  resolveServerImageProviderId / resolveServerVideoProviderId to the
  server provider config, mirroring how TTS already resolves
  server-managed settings.
- Routes resolve provider and model server-side when the client sends
  no preference and fail loud with a clear 400 when nothing resolves;
  no more hardcoded vendor defaults.
- Adapters require an explicit model via requireModel; connectivity
  probes keep a fixed probe payload since they are auth handshakes,
  not user-facing generation.
- Tests cover the resolver precedence and the fail-loud paths.

* fix(media): align model resolution semantics across capabilities

resolveImageModel and resolveASRModel let the server pin win
unconditionally, while resolveVideoModel implements allowlist semantics
(client choice wins when it is in the pinned list, otherwise the first
pinned entry). Align image and ASR to the video allowlist semantics so a
client picking any server-pinned model is honored, and update the
resolver docstrings and tests to pin the aligned behavior for all three
capabilities.

The classroom media path has no client model and no HTTP response to
fail loud with: it now falls back to the first catalog model when the
operator pins no _MODELS list, so key-only deployments keep generating
media instead of silently skipping every element via the adapter's
requireModel backstop.

* fix(media): normalize configured model lists and client model ids
2026-08-25 22:42:22 +08:00
wyucandClaude Fable 5 4f00cf7ad6 feat(materials): material library manifest and id-addressed generation images (#1153 part 2) (#1168)
* feat(materials): material library manifest and id-addressed generation images (#1153 part 2)

* fix(materials): library-owned references and failure-path parity (#1153 part 2)

* fix(generation): resolve-then-slice vision images and close allocation leaks (#1153 part 2)

* fix(generation): bound vision resolution and make library writes leak-safe (#1153 part 2)

Bound the resolve-with-refill phase with an aggregate 15s budget raced
against every probe plus a 3-consecutive-failure fuse — either stop strips
unresolved and unprobed ids from the mapping and proceeds (text-only at
worst), never failing the request. Restore the original interleaved text
ordering for non-vision runs with a mapping present. Guard the library's
failed-entry-write release with an ambiguous-commit re-read: release only
when the entry provably does not point at the new id, keep it when the
write actually landed, and prefer a recoverable leak over a dangling entry
when the re-read fails. Drop the unused package export.

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

* chore(generation): bump @openmaic/generation to 0.3.1 (#1153 part 2)

The package's inputs changed (partitionImagesForVision, resolvedVisionImages
option, non-vision ordering fix); the merge-time gate requires a version bump.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-24 18:08:40 +08:00
wyucandClaude Fable 5 8cc8845a2c feat(document): derived assets with lineage and an extraction cache (#1153 part 1) (#1164)
* feat(document): cache extraction as derived pool assets with lineage (#1153 part 1)

* fix(document): harden the extraction cache keys and write path (#1153 part 1)

* fix(document): bound alias trust and unblock the extract path from cache writes (#1153 part 1)

* fix(document): keep every extraction-cache failure inside the degrade-to-miss net (#1153 part 1)

Move the lookup's key construction (config fingerprinting included) inside
its guarded region so a fingerprint failure degrades to a cache miss instead
of rejecting the caller's extraction; guard the page-side dedupe fingerprint
the same way (falls back to un-deduped extraction). Pin the remaining review
coverage: malformed/missing createdAt treated stale, the documented
future-dated residual, fingerprint-throw degradation on lookup/write/dedupe,
and supersede leaving the superseded record's assets untouched.

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

* fix(document): keep server-only extractor implementations out of the client bundle (#1153 part 1)

* fix(document): fall back to the localized message for non-JSON extraction responses (#1153 part 1)

A proxy error page (or any non-JSON body) on either the error or the success
read now surfaces parseFailedMessage instead of a raw SyntaxError, making the
docblock's fallback promise universal. Review nit from #1164.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-24 15:08:08 +08:00
wyuc e3fd494658 feat(tts): add Qwen TTS voice cloning (#1160)
* feat(tts): add Qwen TTS voice cloning via the registration adapter

* feat(settings): add Qwen TTS clone-voice manager

* fix(tts): harden Qwen voice cloning integration

* fix(tts): enforce voice-bound Qwen synthesis

* fix(tts): close Qwen voice clone tail issues

* fix(tts): close final voice clone review issues

* fix(agents): harden voice token parsing

* fix(settings): hide the voice-clone model from manual selection

* fix(agents): pin the narrator voice to the user's selected voice

* fix(agents): keep narrator fallback alive when the pinned voice is unusable

* fix(i18n): correct the narration fallback notice copy

* fix(tts): bound the narrator voice retry to a single fallback hop

* fix(tts): tolerate transient vendor errors in the voice existence precheck
2026-08-24 14:33:43 +08:00
LING DUANandwyuc a0dccbe78d feat(pi): add Native Child additive whiteboard tools (#1156)
* feat(pi): add Native Child additive whiteboard tools

* chore(renderer): bump package version to 0.1.3

* fix(pi): reject invisible whiteboard mutations

* fix(pi): close native whiteboard review blockers

---------

Co-authored-by: wyuc <wang-yc24@mails.tsinghua.edu.cn>
2026-08-24 12:12:07 +08:00
wyucandClaude Fable 5 5a3e81c4d9 feat(document): ingest uploaded sources into the asset pool and extract by id (#1153 part 0) (#1154)
* feat(document): declare a version on every extractor provider (#1153 part 0)

* feat(api): accept asset-id input in the extract-document route (#1153 part 0)

* feat(upload): ingest selected course materials into the asset pool (#1153 part 0)

* fix(document): harden asset-id extraction inputs and close upload ingest leaks (#1153 part 0)

* fix(document): scope extraction fallback and harden the asset-id path (#1153 part 0)

- G1: byte fallback only on pre-extraction failures; PARSE_FAILED
  (422/500) is surfaced without re-running the paid extractor
- G2: freeze the course-material set for the duration of generate-prep
  (guarded add/remove + disabled toolbar affordances, session built
  from the click-time snapshot, belt-and-braces drain)
- G3: bound the pre-generation ingest await (~15s); timed-out ingests
  go byte-path and their late-resolving ids are released
- G4: reject non-object JSON bodies (null/array/string/number) as 400
- G5: JSON-path provider pre-validation with generic messages, generic
  JSON-path PARSE_FAILED/422 responses, control-char-free log values
- G6: size cap enforced via identify() before resolve() materializes
  bytes; too_large maps to the existing 413

* fix(document): mirror provider-hint semantics on the asset-id form and freeze prep-time controls (#1153 part 0)

* fix(document): keep the asset-id form's responses free of caller input (#1153 part 0)

Address review findings: answer the extractor-selection failure with a
generic static message on the asset-id form (the registry's interpolated
message carries the caller's MIME type; multipart keeps it verbatim, both
sides now pinned by tests), dedupe same-batch material additions by content
fingerprint inside the pure updater, document the network-error retry
exception in shouldRetryWithByteUpload's docstring, and fix a test name
asserting 500 but named 400.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-24 11:45:31 +08:00
LING DUAN 2cbe4ae293 feat(pi): add Native Child whiteboard tools (#1152)
* feat(pi): add native whiteboard tools

* style(pi): format native whiteboard tests

* fix(pi): clarify whiteboard CAS handoff

* fix(whiteboard): reconcile runtime projection on open
2026-08-21 18:07:49 +08:00
LING DUANandwyuc 59adbef03b Simplify Pi agent tool ownership and completion (#1148)
Co-authored-by: wyuc <wang-yc24@mails.tsinghua.edu.cn>
2026-08-20 00:26:07 +08:00
LING DUANandwyuc 0d308ed78d feat(pi): add Native Child Web Search (#1133)
* feat(pi): add native child web search

* fix(pi): align native web search with provider system

* fix(pi): harden native web search configuration and cancellation

* fix(pi): guide Native Child web search selection

---------

Co-authored-by: wyuc <wang-yc24@mails.tsinghua.edu.cn>
2026-08-19 10:35:44 +08:00