Commit Graph
52 Commits
Author SHA1 Message Date
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
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
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
wyuc fcb464bdf0 fix(dsl): migrate away legacy rotate/height fields on line elements (#1261)
* fix(dsl): strip legacy rotate/height from line elements on migration and import (#1260)

* fix(dsl): cover whiteboard slides in the legacy line strip, drop redundant import wiring

Round-1 review findings (#1261): the strip only walked
scenes[*].content.canvas.elements, leaving the same bug class latent on
interactive whiteboard slides (scenes[*].whiteboards[*].elements); and the
import-site sanitize was redundant because the document store already runs
the ladder on save, while its comment misdescribed that mechanism. Extend
the strip to whiteboards and let the ladder alone own the cleanup.

* fix(dsl): cover stage-level whiteboard boards and row envelopes in the legacy line strip

Round-2 terminal audit findings (#1261): the strip walked only the
document envelope's scene surfaces, so dirty line elements under
stage.whiteboard (the stage-level explainer boards) and under bare
Scene/Stage row envelopes passed through while the runner stamped the
document current. Walk every line-element surface of every migratable
envelope: scene canvas, scene whiteboards, and stage whiteboard, at
document, Scene-row, and Stage-row roots.

* fix(dsl): gate the canvas strip on the slide discriminant; run the ladder on legacy-only exports

Round-3 terminal audit findings (#1261): the canvas walk fired on any
canvas-shaped content regardless of the scene kind, so a non-slide scene
carrying a canvas-shaped app extension would have fields deleted from it
— gate on content.type ('slide' or absent, the dirty-line epoch predates
schema enforcement). And the lock-free exportDatabase fallback
hand-stamped DSL_VERSION onto a payload the ladder never walked, so a
backup could read as current on restore and permanently skip the
migration — build the export unstamped and let migrate stamp it.

* fix(agent-runtime): bring stale-stamped documents current before incremental scene writes

Round-4 audit finding (#1261): putScene rejects documents whose stored
DSL stamp is older than the current one, and the aggregate read migrates
in memory only, so the first server-side tool write into a course stored
at an older version (every pre-bump course, including previously
imported ones) failed with a version error. Route the server tools'
incremental scene writes through a wrapper whose not-current fallback
reloads the migrated aggregate and full-saves it with the scene spliced
in — the server-side counterpart of the app autosave's
catch-and-full-save.

* style: prettier

* docs(agent-runtime): record the reviewed concurrency window on the stale-stamp fallback
2026-08-28 14:06:03 +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 e15780835a feat(media): convert legacy references to allocated asset ids (#1101)
Lands epic #1007 part 2(c): the app-side reference converter, the `audioUrl`
removal, and the `DSL_VERSION` bump as one delivery unit.

`convertDocumentAssetRefs` walks a loaded document (slide canvases, whiteboards,
video-manifest keys, speech actions) and rewrites legacy handles to allocated
pool ids: placeholders and `audioId`s with local bytes are ingested, a co-present
`audioUrl`/`audioId` pair collapses to one asset, a definitively dead URL
converts to an emptied reference, and a transient failure keeps both handles for
a later retry. Unavailable bytes leave the document untouched for a later open,
and a converted document returns by identity with no writes.

Conversion runs lazily on document open and at fetch time for server-generated
classrooms, with conversion and the first document save sharing the per-stage
document lock: once the document commits it owns every allocation, and every
failure exit rolls its pass ledger back. Legacy URL probes are bounded by a
per-probe timeout and a shared 60-second pass budget.

`SpeechAction.audioUrl` is removed from the DSL and every consumer reads the
converted shape; the server classroom generator keeps its derived-id + serving-URL
pair as an explicitly typed pre-conversion transport, consumed before persistence.
`DSL_VERSION` becomes 0.2.0 with the first real ladder entry, and
`@openmaic/dsl` goes to 0.9.0 per the release rule.

Deferred follow-ups are tracked in #1129: production principal derivation with
document/asset ownership aligned, conditional document writes, and hoisting the
aggregate probe budget into the export and video-timeline paths.

Refs #1007
2026-08-15 17:38:04 +08:00
杨慎 4ac70cceeb feat(video-export): capture self-contained static interactive HTML (#1086)
* feat(video-export): capture static interactive HTML

* fix(video-export): harden interactive HTML capture diagnostics

* fix(video-export): initialize runtime diagnostics in manifest

* fix(video-export): close interactive capture review findings

* fix(video-export): preserve packaged interactive resources

* fix(video-export): address packaging review feedback

* fix(video-export): harden interactive HTML packaging

* fix(video-export): unify interactive asset packaging

* fix(video-export): close remaining interactive packaging gaps
2026-08-11 22:16:58 +08:00
PercyandPercy b8bba7653e feat(courses): add folder grouping (#1005)
* feat(courses): add folder grouping behind a feature flag

Group courses into user-created folders on the home page. Folders are
device-local organization metadata kept in the existing IndexedDB
database (new folders + course-to-folder mapping tables, Dexie v16);
the course document aggregate owned by the DocumentStore is untouched.

- Create / rename (inline) / delete folders (keep or remove members)
- Move a course between folders via a hover menu
- Navigate into a folder with a breadcrumb back to all courses
- Search flattens the list, annotating each course with its folder
- Deleting a course cleans up its folder membership
- All UI strings internationalized across 9 locales
- Gated behind NEXT_PUBLIC_ENABLE_COURSE_FOLDERS (default OFF)

* test(courses): update db mocks and version assertion for folder tables

The folder feature adds the `stageFolders` table to the deletion cascade
and bumps the Dexie schema to v16. Update the affected test db mocks to
include the new table and bump the version assertion accordingly.

* fix(courses): stop course click-through when selecting from the move menu

The move-to-folder menu is rendered inside the course card's clickable
container. Selecting a folder item could let the click event reach the
card's onClick (opening the course) because only the trigger button
stopped propagation. Stop propagation on pointerdown for the trigger
and on click for every menu item so selecting a destination never opens
the course.

* fix(courses): open a dialog for the move-menu new-folder entry

The move-to-folder menu's inline "new folder" input never worked: a Radix
DropdownMenu is modal, so a raw <input> inside it cannot keep focus —
Radix closes the menu the instant the input is focused, dropping the
field before anything can be typed. Move the new-folder entry out of the
menu: it now asks the caller to open the existing NewFolderDialog and,
on confirm, moves the requesting course into the freshly created folder.

* feat(courses): drag-to-file folders and stacked cover thumbnails

Per review feedback, make folders feel complete:

- Drag a course card onto a folder tile to file it there. The tile turns
  into a clear drop target (ring + overlay) while a drag is over it. The
  hover 📂 menu remains as the accessible fallback for keyboard/touch.
- Replace the placeholder folder icon with a stable stack of up to three
  member course covers (most recently updated frontmost). Empty folders
  keep the folder icon; the name and course count are always visible.

* feat(courses): refine folder cover stack to a tidy fanned layout

Tune the folder-tile cover stack per review: front cover centered and
upright, rear covers peek out from alternating sides with a slight tilt
and reduced opacity, soft shadow and hairline ring. Reads as a neat pile
of course covers rather than an exaggerated fan.

* fix(courses): address review — dialog mount, empty-list, rename validation, partial-delete refresh, breadcrumb dedup

Per review (wyuc, CHANGES_REQUESTED):

1. Mount NewFolderDialog/DeleteFolderDialog outside the collapsible Recent
   subtree so they are reachable while it is collapsed; the New-folder
   button now expands the section before opening the dialog.
2. Keep the Recent/folder surface alive when the course list is empty so
   folders remain reachable and a first folder can be created.
3. Enforce folder-name validation (width + uniqueness) on rename as well
   as create, at the storage boundary (FolderNameError) and in the UI.
4. Refresh authoritative state on folder-delete both success and failure,
   so a partial "remove" failure does not leave stale cards/counts.
5. Drop the duplicated folder breadcrumb from the top section header;
   keep a single navigation breadcrumb in the content area.

Add focused storage tests covering rename validation, membership writes,
both deletion modes, and partial-failure propagation.

* fix(courses): address 2nd review — folder-view layout, drop highlight, validation parity, a11y, i18n

Per review (wyuc, CHANGES_REQUESTED on 9f77686f):

P1 — folder view layout: opening a folder compacts the hero and surfaces
the library content; the centered header shows a single path
"Recent > Folder name"; the empty-folder state sits directly below it;
clicking Recent returns to the root grid. The duplicate content
breadcrumb is dropped (kept only for search).

P2 — correctness & a11y:
- Clear the folder drop highlight on every drag exit (dragenter counter)
  and gate it on the text/stage-id payload.
- Duplicate-name check in the dialog is now case-insensitive, matching
  the storage boundary; FolderNameError is mapped to specific messages.
- Renames report the actual submitted width, not a hardcoded 0.
- createFolder/renameFolder run read-check-write in one read-write
  transaction so the uniqueness invariant cannot race across tabs.
- The move-menu trigger is focus-visible and pointer-coarse reachable
  (visible on touch / keyboard), not hover-only.

Extra:
- Empty library (no courses, no folders) shows a dedicated hint instead
  of the search-empty string.
- Translate all newly added folder strings across ja/ko/pt/ru/ar, fix
  Simplified-Chinese text in zh-TW, and add the emptyLibraryHint key.

* fix(courses): address 3rd review — ungate folders, fix empty-library hero, stabilize the bar

Per review (wyuc, CHANGES_REQUESTED on 19729038):

P1 — ship folders unconditionally: remove NEXT_PUBLIC_ENABLE_COURSE_FOLDERS,
the isCourseFoldersEnabled helper, the .env.example entry, and every
flag-on/flag-off UI branch. Folder metadata is always loaded; create,
move, drag, and folder navigation are always available. The IndexedDB
v16 schema stays intact.

P1 — full-screen landing hero only when the library is truly empty: the
hero uses min-h-[calc(100dvh-8rem)] only when there are zero courses AND
zero folders. A `hydrated` flag waits for both async loads before
selecting the layout, so folders arriving from storage do not flip the
hero from full-screen to compact.

P2 — geometrically stable centered bar: the Recent bar gets a fixed
height (h-9) so entering/leaving a folder (which toggles the New-folder
action and the folder path) does not shift the search/import controls.

* fix(courses): single stable library action bar across root, folder, and empty states

Per review (wyuc, CHANGES_REQUESTED on 860a1170):

P2 — remove the duplicate floating import controls. The hero section
rendered a second Import Classroom / PPTX cluster whenever the course
list was empty, duplicating the Recent bar's actions and floating above
it as the hero switched layout modes. Import now lives only in the
Recent action bar.

P1 — keep folder creation reachable for a truly empty library. The
Recent section is now always rendered after hydration (not gated on
having courses or folders), so a new user with zero of each can still
create the first folder or import. The empty-library hint renders below
the single stable action bar.

Invariant: one library action bar across root, folder, and empty
states; state changes alter the path and enabled actions only.

* chore: trigger CI after ready-for-review

* fix(courses): inherit folder context when importing from inside a folder

Per review (wyuc, CHANGES_REQUESTED on 37be4716):

[P1] Courses imported from inside a folder were silently placed at the
root. The import contract now carries the new stageId to the success
callback; the page captures the active folder when the file picker opens
(not when the async import resolves) and files the imported course into
that folder before the list refresh, so the card appears immediately and
the folder count increments. Root imports remain ungrouped. A failed
folder assignment surfaces an explicit error toast instead of silently
falling back.

* fix(courses): stable hero on folder delete, lightweight delete menu, breadcrumb count, empty-name validation

Per review (wyuc, CHANGES_REQUESTED on 5e12984f) + QA findings:

P1 — deleting the last folder must not expand the hero. The full-screen
landing hero is a first-visit treatment only: a session-scoped
"librarySeen" flag latches true once the library bar renders, so the
hero stays compact across all subsequent create/delete transitions.

P2 — replace the heavy two-card delete dialog with progressive disclosure.
Empty folder: an inline confirmation overlay on the card tile (matching
the course-delete pattern), with the empty-folder copy. Non-empty
folder: a compact dropdown beside the delete icon — "Delete folder only"
(courses move to unfiled) executes directly; "Delete folder and N
courses" opens a lightweight destructive confirmation. The full modal is
gone.

QA fixes:
- Breadcrumb count is now contextual (total at root, in-folder count
  inside a folder) instead of always showing the global total.
- Renaming a folder to empty/whitespace shows a "name cannot be empty"
  error and shakes, instead of silently exiting edit mode.

* fix(courses): cross-review cleanup — dead i18n keys, missing translations, delete-overlay reset

Post-commit cross-review (leak audit PASS, wyuc 19/19 PASS) found:

- Remove 6 dead i18n keys left over from the removed two-card delete
  dialog (deleteFolderDesc, deleteFolderUngroupTitle, etc.) across all
  9 locales.
- Translate all remaining English folder strings in ja/ko/pt/ru/ar
  (newFolderTitle, folderNameLabel, folderCreate, deleteFolderTitle,
  etc.).
- Fix zh-TW: convert ~17 simplified-Chinese folder strings to
  Traditional (新增資料夾/建立/刪除/etc.).
- Delete the orphaned "feature flag" comment (flag was removed earlier).
- Close the inline delete-confirm overlay before the async delete, so a
  failure leaves the card interactive instead of stuck behind the
  backdrop.

* fix(courses): clear drop highlight on drag end, map limit error in dialog

Two minor findings from cross-review:

- Escape-cancelled drags may not fire dragleave on every folder target,
  leaving a highlight ring. The course card now dispatches a
  'course-drag-end' window event on dragEnd (fires for both normal drop
  and Escape cancel); folder cards listen and clear their drop state.
- FolderNameError kind 'limit' (thrown at the storage boundary when
  FOLDER_COUNT_LIMIT is reached in a cross-tab race) now maps to the
  specific folderCountLimit message in the dialog instead of falling
  through to a generic hint.

Also fixes an SSR hydration mismatch: librarySeen is now initialized
to false and read from sessionStorage in useEffect (not in the
useState initializer).

* fix(courses): atomic folder removal, always-compact hero, no-cover placeholder

Per review (wyuc, CHANGES_REQUESTED on 470f5fea):

P2 — close the orphan-membership race in 'remove' mode. deleteFolder now
captures members, deletes the folder row, and clears all memberships in
ONE transaction BEFORE the course-deletion cascade. The folder is gone
from the moment the cascade starts, so a concurrent setStageFolder
(which checks existence in its own transaction) rejects the assignment.

P2 — remove the first-visit full-screen hero. The librarySeen flag
caused a visible layout jump on refresh (SSR renders full-screen, then
the effect reads sessionStorage and switches to compact). The hero is
now always compact (mt-[10vh]); no sessionStorage, no hydration
mismatch, no geometry regression.

P3 — distinct no-cover fallback for non-empty folders. A folder with
courses but no cached thumbnails now shows a neutral stacked-card
placeholder instead of the empty-folder icon.

Merged with latest main; no conflicts.

---------

Co-authored-by: Percy <percy@PercydeMacBook-Pro.local>
2026-08-10 17:56:56 +08:00
ceb05adb41 feat(media): allocate generated assets through the registry (#1007 part 2, step b) (#1039)
* feat(media): establish shared asset ownership primitives

Introduce global browser asset-pool ownership, asset-reference collection, stage reclamation planning, and lease-based URL access. Carry allocated media identity through storage and generation boundaries with regression coverage.

* fix(media): enforce safe resolution across every consumer

Route image, video, thumbnail, presentation, and video-export consumers through one resolution state machine. Prevent opaque allocated or generated references from reaching render and export sinks, with fallback and ownership tests.

* fix(media): protect document-owned assets across mutations

Allocate pool bytes before compatibility writes and document commits, then roll back uncommitted generations safely. Preserve document ownership across edits, retries, imports, speech generation, scene changes, and stage deletion.

* fix(media): scope retries and tighten ownership guard

Scope retries to the target scene and slide and refuse ambiguous shared-reference mutations. Expand retry rendering coverage and keep direct pool URL resolution behind the shared lease owner.

* fix(storage): avoid nested lock during stage cleanup

Execute prepared reclamation plans against an explicitly deleted document so the compatibility cascade cannot re-enter the per-document lock. Cover deletion of stage-owned media rows even when the document has no references.

* refactor(media): confine reclamation to stage deletion

Remove inline pool and compatibility-row cleanup from element, speech, scene, and audio replacement flows. Keep whole-stage reclamation behind explicit stage ownership, preserve stage-less legacy audio rows, and document deferred document-truth sweeping.

* fix(media): close retry and resolution gaps

Restore shared source tasks after successful forks, scope retries across both whiteboard locations, and wait for parallel TTS workers before rollback. Resolve background media through import, rendering, and PPTX export paths while keeping retry controls visible over last-good bytes.

* test(media): execute the consumer safety matrix

Replace source-substring checks with resolver seam execution across all six UI consumers, stage hydration, and both exporters. Pin each rollback layer independently and harden the ownership guard against aliased pool imports.

* chore(packages): publish the additive DSL field

Bump the DSL patch version for the optional speech-action field. Keep the transitional reclamation policy app-owned and document the legitimate transaction rollback removals there.

* fix(media): make retry rollback task-safe

Delay allocation task re-keying until final document reconciliation succeeds, restore shared source tasks on failed forks, and require exact stage-whiteboard targets before falling through from a missed scene.

* fix(media): clear private assets and refresh leases

Delete the asset-pool database during the confirmed local-data wipe. Notify the app lease layer after same-id replacement so mounted consumers re-resolve current bytes without reaching into storage internals.

* test(media): pin closure safety guards

Exercise the CSS allocation boundary, unfiltered legacy-row ownership, delete-and-undo byte survival, and real distinct consumer seams. Restore the video-only manifest overwrite condition and share the direct video resolution hook across both element variants.

* test(media): narrow rollback element assertion

Narrow the reconciled slide element to an image before checking its source so the rollback regression remains type-safe under the full root compiler configuration.

* fix(media): close lease refresh races

Gate the first batch publication by unique resolved refs, then publish every replacement snapshot without mutating the prior React state object. Serialize invalidation behind pending releases, evict rejected refreshes, and register replacement observation at the pool boundary.

* fix(media): reopen pool after clear failures

Always evict the singleton once its store has been closed, including blocked and failed database deletion paths. Report blocked deletion as deferred and prove a later write uses a fresh live store.

* fix(media): isolate shared retry progress

Track forked regeneration under the selected element until it receives a fresh asset identity, leaving the shared source task and bytes untouched. Surface targeted failures through renderer task lookup and skip the redundant fork reconciliation lock.

* fix(media): close final asset retry gaps

Keep blocked asset clears fail-loud until a successful retry, with actionable settings guidance.

Clear failed shared-fork state across durable and live key spaces, and pin renderer lookups, lease publication identity, and committed-ref rollback protection.

* fix: preserve actionable retry failures

Localize the blocked cache-clear recovery hint across every supported locale and pin the deferred-error mapping.

Retain durable fork failure rows while retries run, deleting them only after successful generation so unstructured failures survive reload.

* fix(media): hydrate legacy stored video thumbnails

Home-page recent-video thumbnails regressed for legacy Dexie mediaFiles
rows keyed by gen_vid placeholders: the reworked hydration resolved the
row's bytes through the sealed resolver but never surfaced the stored
blob (and its poster) as object URLs for the preview card, so the CI
recent-video-thumbnail e2e specs found no visible element.

Hydration now materializes legacy stored video rows into blob URLs for
both the element src and poster while keeping the resolver invariants:
opaque refs still never reach a DOM src, and concrete addresses are
never blanked. Unit pins cover the seam so the vitest suite catches this
class without a browser.

* fix(media): resolve sole restored legacy video

Classroom playback restored tasks by exact document media references. Legacy gen_vid references can outlive the key used by the one persisted video row, leaving the player on a placeholder even though bytes were restored.

Select the sole completed stage video only for legacy sequential refs after exact and reconciled matches. Keep exact failures authoritative, refuse ambiguous candidates, and cover success, ambiguity, and failure precedence in unit tests.

* fix(media): scope legacy video recovery

Decide restored legacy video recovery once from the complete document and record it through the shared task lookup consumed by playback, editing, and resolved slides.

Keep ambiguous documents as placeholders, apply the same decision to thumbnail hydration, and preserve exact failure precedence.

* fix(media): exclude claimed video recovery tasks

Model restored legacy recovery as a two-pass match across document video elements and task rows. Remove tasks claimed by exact, targeted, or placeholder lookup before applying the sole-candidate fallback, and cover the ownership/cardinality matrix.

* fix(media): unify video element resolution

Centralize source, task, poster, and legacy recovery decisions for every video consumer. Ensure direct URLs win over opaque refs and play_video waits on element-targeted retry tasks.

* refactor(media): route legacy recovery through resolver

Let document-aware consumers request legacy video recovery through the unified element binding API, keeping thumbnail hydration on a single decision path.

* fix(media): preserve import refs and prefer pool bytes

Recognize unambiguous extensionless relative media addresses during classroom import. Resolve allocated export and thumbnail assets from the shared pool before falling back to lagging compatibility rows.

* fix(export): preserve concrete video sources

Route PPTX video elements through the shared media binding resolver so an unresolved opaque reference cannot replace a playable source. Complete browser and persisted-store cleanup when asset-pool deletion is deferred, while retaining distinct hard-failure behavior.

* fix(media): fork retries without exclusive ownership

Enumerate logical asset owners across every persisted document before allowing global pool replacement. Thread explicit targets through fresh-id rewrites and cover cross-document aliases plus unreadable ownership.

* fix(media): guard global asset reclamation

Share a fail-closed persisted-document liveness check between stage deletion and retry replacement. Preserve cross-document pool aliases while deleting stage-owned compatibility rows and cover enumeration failures.

* fix(media): cover complete slide asset references

Route slide media traversal through a shared mutable slot contract so backgrounds participate in export, thumbnail hydration, collection, and rewrite lifecycles. Snapshot complete surviving-document refs once per reclamation and preserve manifest-only owners while retaining fail-closed behavior.

* fix(media): preserve exclusive retry asset ids

Allow targeted retries to replace exclusively owned pool assets in place. Keep shared and unprovable ownership paths on fresh allocations, and pin production-shaped retries plus compatibility-row cleanup.

* fix(media): revalidate asset bindings at completion

Recheck repository-wide ownership before replacing generated media and fork scoped retries when exclusivity changed. Route video export selection through the unified resolver and keep concrete posters independent of task state.

* fix(media): count unflushed owners and broadcast replacements

The completion-time exclusivity proof read only the persisted document, but
slide duplication updates the Zustand aggregate synchronously and schedules
persistence behind a debounce. A retry finishing inside that window saw a
single persisted owner and replaced the bytes behind a reference the
duplicate also held. The proof now also counts owners in the live stage
snapshot when that snapshot represents the stage being retried, so an
unflushed duplicate forks instead.

Same-id replacement notifications were realm-local, so a second tab showing
the same classroom kept its lease pinned to the superseded blob URL. The
notification now travels over a BroadcastChannel; each receiving realm runs
its own observers against its own pool, so a spoofed message can at most
force a re-resolve. A missing or failing channel never fails the replacement.

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

* fix(media): bind replacement listeners and spare shared audio rows

A realm that only renders never sends a replacement, so binding the channel
from the sender path left passive tabs deaf to peers. Binding now happens
where the observer is registered, when the asset-pool module loads, and the
receiving realm resolves its own pool lazily so a cleared or unavailable
pool degrades to the next resolve instead of throwing.

Observer notifications ran under Promise.all, so a rejection surfaced after
BrowserAssetStore.replace had already committed and turned a durable success
into a reported failure. They are settled individually now; the callback is
wrapped because a synchronous throw would otherwise escape before allSettled
sees the array.

audioFiles rows are keyed globally by audioId, so deleting a stage removed
the sole row for an id a surviving document still referenced — playback and
both export paths read that table directly and cannot fall back to the
preserved pool blob. Rows are now filtered against surviving references,
while a failed enumeration still withholds only the irreversible pool
removal.

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

* fix(media): serve replaced bytes from the pool everywhere

Unknown survivor liveness deleted every planned audioFiles row. Those rows
are keyed globally by audioId, and playback plus both export paths read the
table directly, so losing one is as irreversible for them as removing the
pool entry. Unknown liveness now preserves the rows too, leaving bounded
garbage for a later pass that can prove exclusivity.

The earlier pool-first change covered PPTX, video collection and thumbnail
hydration but missed classroom ZIP export, which still serialized the stale
compatibility row after a lagged same-id replacement, shipping media the
classroom no longer renders.

Auditing every direct reader of the media and audio tables surfaced the same
gap in playback: speech regeneration also replaces bytes under a stable id
and does not roll the pool back when the compatibility write fails, so the
player kept serving superseded narration. It now resolves the pool first and
falls back to stored rows for legacy and imported audio.

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

* fix(audio): replace exclusively owned speech clips in place

regenerateSpeechAudio accepted the action's audioId but always passed
undefined as replaceAssetId, so regenerating an exclusively owned
pool-backed clip allocated a new asset every time, rewrote the action and
orphaned the previous pool entry and compatibility row until stage
reclamation — contradicting the stable-id path generateAndStoreTTS already
implements for media.

Ownership is now established before synthesis, and the rule itself moved to
the shared reference module so media retries, poster replacement and speech
regeneration consume one implementation instead of restating it. An
exclusively owned clip keeps its id and has its bytes replaced; a shared
clip, a legacy id with no pool entry, or unprovable ownership still gets a
fresh allocation so other holders keep their audio.

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

* chore(media): drop the import left behind by the ownership move

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

* fix(media): resolve audio pool-first and fence peer realms

Stable-id TTS regeneration commits replaced narration to the pool before the
audioFiles mirror write, so a failed mirror leaves the row stale. AudioPlayer
already resolved pool-first, but audioObjectUrl, collectAudioFiles and the
video timeline dependencies still read Dexie directly and would serve the
superseded clip. All allocated-audio readers now share one resolver, with
Dexie kept as the fallback for legacy and imported rows.

The exclusivity proof modelled unflushed owners in the active realm only, so
another tab duplicating the same asset during its save debounce could still
be observed as a single owner and have its bytes replaced globally. A peer's
pending state cannot be read across realms, so presence is probed instead:
any realm holding the stage forces the fork path.

A deferred asset-pool deletion no longer reloads the page. The database is
still on disk and the guidance asks the user to close the other tab and
retry, which the reload discarded. The decision moved into a shared helper so
the rule is pinned rather than living inline in the component.

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

* fix(media): fail closed when presence cannot be probed

The presence helper documented that an unanswerable probe must count as a
peer, but every unavailable path — no BroadcastChannel, a constructor that
threw, a send that failed, and the window before the pool's asynchronous
binding completes — returned false, so the ownership proof cleared a single
local owner and replaced globally shared bytes in place.

Probing now returns present, absent or unknown, and only a probe that was
actually sent and went unanswered is absent; the ownership decision treats
unknown exactly like present. The pool declares its binding intent
synchronously so a probe issued during the load-time window waits for the
bind instead of concluding that presence is unavailable, and releases that
gate if the import fails.

Coverage reaches the write boundary: with presence unknown, a
production-shaped targeted retry forks to a fresh id instead of calling
replace, and the original bytes stay intact for a peer's unflushed owner.

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

* chore(dsl): bump to 0.6.3 after the release dedupe took 0.6.2

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

* fix(i18n): add the deferred-clear guidance to fr-FR

The locale landed on main after this branch added the key, so the alignment
check flagged it as the one missing translation.

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

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: 杨慎 <117187635+cosarah@users.noreply.github.com>
2026-08-06 18:49:34 +08:00
wyuc ad30061b6e fix(chat): harden runtime sync, legacy migration, and record validation (#1050)
* fix(chat): harden runtime sync and legacy migration

* fix(storage): validate HTTP runtime appends client-side

* fix(storage): harden runtime validation and legacy guards

* fix(chat): skip malformed legacy messages
2026-08-05 06:23:45 -04:00
b4e3145c90 feat(roster): single-source the generated agent roster on the stage document (#994)
* feat(roster): single-source the generated agent roster on the stage document

The generated agent roster used to live in two places at once: the stage
document carried generatedAgentConfigs, while a per-stage IndexedDB mirror
(db.generatedAgents) was the actual read authority — and the only home of
each agent's voice binding. The split caused real losses: voices vanished on
device switches, exports, and shares (the mirror never travels), the load
path trusted a device-local table over the portable document, and roster
edits persisted through a bespoke debounce with no beforeunload flush.

Make the stage document the single source of truth:

- Contract: add optional voiceConfig/voiceDesign to GeneratedAgentConfig in
  @openmaic/dsl (VoiceDesign moves into the contract; the app re-exports it).
  Additive optional fields — not a breaking serialized-shape change, so
  DSL_VERSION stays put per the version policy.
- Writes: remove every mirror writer (registry saveGeneratedAgents, the
  stage store's debouncedSaveAgents bypass, classroom-load hydration, import
  direct writes). setStageAgents now only marks the stage dirty for the
  shared persistence scheduler plus synchronous in-memory registry/selection
  mirrors; generation and import embed the roster on the stage.
- Reads: classroom load hydrates the registry from the loaded stage's
  generatedAgentConfigs; export reads the stage roster directly.
- Lazy migration: the mirror is retained read-only. On load, a roster (or
  voice fields) missing from the document is backfilled from the mirror,
  committed onto the in-memory stage, and persisted by the next flush —
  idempotent, and gated on the current stage id after every await so a
  classroom switch cannot leak a stale roster into the registry.
- Deletion: deleteStageData discards the deleted stage's pending
  persistence work so a queued flush cannot resurrect the document.
- Round trip: classroom ZIP manifests now carry the voice fields, so
  export/import preserves agent voices.

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

* fix(roster): harden deletion, migration, selection, and ingress paths

Review hardening on top of the roster single-source change:

- Deletion vs in-flight persistence: deleting a stage now registers a
  session-scoped tombstone (lib/utils/deleted-stages.ts) before the cascade
  starts. Every persistence landing point checks it — the scheduled flush and
  the departing-stage retry drop their snapshots, and the aggregate/incremental
  saves re-check under the document lock, including the incremental path's
  full-save fallback that would otherwise rebuild a deleted document from the
  in-memory snapshot. A deletion that fails before the document is removed
  lifts the tombstone so the still-existing stage keeps persisting.
- Lazy migration termination: a fruitless legacy-mirror probe (nothing to
  merge — e.g. server-generated rosters, which are voiceless by design) is
  remembered per stage for the session instead of re-querying the mirror on
  every load forever. Successful merges are not memoized so a failed flush is
  retried on the next load. Docstrings now state the real lifecycle.
- Selection provenance: setStageAgents no longer overwrites a user-set agent
  selection with the full roster. Stage-derived selections still track the
  roster; a user-set auto selection is only narrowed to surviving agents
  (newcomers are not auto-selected, the user-set flag is untouched), and a
  user-set preset selection is left alone — matching restoreAgentSelection.
- Mirror hygiene: deleteStageData clears the deleted stage's rows from the
  legacy roster mirror (best-effort), and the mirror table's docs now describe
  its actual access pattern (migration reads + deletion hygiene).
- Registry persistence: the agent registry's persist snapshot partializes
  generated agents out of localStorage, making the stage document the roster's
  only durable home; the rehydration merge filter remains as defense in depth.
- Ingress validation: imported manifest voice fields are structurally
  validated (malformed bindings dropped per field, the agent survives), and
  applyGeneratedAgentsToRegistry validates providerId against the known TTS
  provider registry instead of casting, treating unknown providers as "no
  bound voice".
- Contract: drop the speculative AgentVoiceConfig.modelId — an audit of every
  roster producer, current and historical, found none that ever emitted it.
  Contract fields are added once a producer exists, not before.

New tests cover the deletion tombstone at both the store and storage layers
(including the missing-destination full-save pin and the lock-race re-check),
the fruitless-probe memo, the selection-provenance matrix, persistence
exclusion of generated agents, and voice-field sanitization on import.

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

* fix(roster): close tombstone lifecycle, probe-memo, and selection-intent gaps

Second hardening pass over the deletion/migration/selection fixes:

- Tombstone lifecycle: a deleted classroom id can legitimately come back
  (deletion is client-side only). Explicit (re)creation points now lift the
  tombstone — server-copy restore in applyClassroomStageAndScenes and backup
  restore in importDatabase — so edits after a same-session restore persist
  again, while in-flight flushes stay fenced.
- Failed deletion now restores the dirt it discarded: deleteStageData
  snapshots the pending map plus the in-flight round before tombstoning, and
  re-marks it when the delete fails with the document still present.
- Legacy-mirror probe memo now distinguishes a FAILED read (null, retried on
  the next load) from a confirmed-empty mirror (memoized), so a transient
  IndexedDB error cannot suppress migration for the whole session.
- Incremental save tail (currentScene KV row + chat sessions) is tombstone-
  fenced like the aggregate path, and both paths re-check the tombstone
  immediately before every write, with comments scoped honestly for the
  lock-free (no Web Locks) best-effort LWW fallback.
- Roster-edit selection mirror: an empty intersection now falls back to the
  full roster and clears the user-set flag (matching restoreAgentSelection's
  length > 0 gate), and a user-set selection that equals the pre-edit full
  roster keeps tracking the roster wholesale so newcomers are not excluded
  forever by the AgentBar auto-toggle snapshot.
- TTS/ASR provider lookups use Object.hasOwn instead of `in`/bare indexing,
  so prototype-chain keys ('toString', 'constructor', ...) no longer pass the
  provider whitelist or resolve to Object.prototype members.
- Documented the per-tab limit of the tombstone set.

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

* fix(roster): replace the boolean deletion tombstone with a deletion-generation model

A boolean tombstone cannot express the deletion lifecycle once a deleted id
legitimately comes back (server-copy restore on revisit, backup import): after
the restore lifts the flag, a pre-delete flush still in flight is
indistinguishable from a post-restore edit and can overwrite the restored
document with pre-delete content. Two rounds of point fixes kept leaking
variants of this race, so this restructures the fence instead.

deleted-stages.ts now keeps a per-stage monotonic deletion epoch plus a
deleted flag. markStageDeleted bumps the epoch; unmarkStageDeleted clears
only the flag and never rewinds the epoch. Every persistence path captures
the epoch at the moment it captures the data it will write (flush-round
snapshot, departing-stage snapshot, aggregate save entry) and re-checks
"captured epoch is current AND not deleted" immediately before each
individual write. Invariants:

- A write captured before a deletion can never land after it, even when a
  same-id restore has lifted the deleted flag (the captured epoch is
  permanently stale).
- A write capturing data after a restore observes the current epoch and
  persists normally.
- A failed deletion lifts only the flag; the restored pending changes are
  re-queued as descriptors, so their flush re-captures current store state
  under the current epoch and is neither dropped nor a stale replay.

Also fixed in the same restructuring:

- Incremental tail writes (currentScene, chats) each re-check independently;
  a delete landing while the first tail write is awaiting now fences the
  chat write instead of riding the earlier check.
- Deleting a classroom evicts it from the warm in-memory store, and
  loadFromStorage treats a warm-but-deleted stage as not loaded (discarding
  the ghost), so navigating Back to a deleted classroom reaches the
  server-restore path instead of rendering an editable ghost whose every
  edit is silently dropped.
- importDatabase records each document's pre-import deletion state and
  reinstates it when a failed import rolls the document back, so an
  outstanding flush cannot recreate the rolled-back document.
- Docstrings on the failed-deletion restore path now state that only dirt
  captured before the deletion is restored; edits refused during the
  deletion window are not part of the snapshot.

New tests cover the epoch invariants at the storage layer (pre-delete rounds
dropped across restores on the incremental, aggregate, and tail paths;
post-restore captures landing; strictly increasing epochs), the scheduler
layer (departing-retry delete+restore straddle; restored dirt flushing under
the current epoch), the warm-ghost restore driven through the real
loadFromStorage, and the import lift/rollback pair.

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

* fix(deletion): report fenced drops, gate ghost eviction on cascade settlement

Edge hardening around the deletion-epoch fence; the epoch model itself is
unchanged.

- An epoch-stale drop is now reported, not disguised: saveStageData /
  saveStageDataIncremental return a distinct 'stale-dropped' status (entry
  check, every in-mutation re-check, and both tail-write re-checks) instead
  of a shape identical to success. saveToStorage reacts by skipping all
  success bookkeeping - no chatSnapshot rebind to a snapshot that never
  landed, no pending-dirt clearing - and returns false, keeping its
  "true means verified write" contract honest. persistDirtySnapshot keeps
  its existing (correct) semantics explicitly: a drop has nothing to retry.
- Warm-ghost eviction is settlement-gated: the deletion cascade records an
  in-flight bit (begun by deleteStageData, settled in its finally), and the
  deleted-warm branch of loadFromStorage keeps the warm state while the
  cascade is unsettled - a delete can still fail before removing the
  document, in which case the warm state plus the restored pending dirt is
  the only copy of the pre-delete edits. Once settled, the ghost discard
  and server-restore path behave as before.
- Read-side landing re-check: loadFromStorage re-checks isStageDeleted
  after hydration, immediately before set(), mirroring the write-side
  "re-check immediately before landing" discipline - a lock-free
  mid-cascade read can no longer re-materialize a ghost classroom.
- clearStoreForDeletedStage re-checks isStageDeleted at eviction time, so a
  same-id restore completing inside the cascade-tail window is never wiped.
- capturedEpoch is now a required parameter on both storage entry points:
  the default (call-time capture) silently reopened the capture-point/
  validation-point split for future callers; the type system now enforces
  the pairing. All production callers already passed it explicitly.

New tests cover the dropped-status contract, the cascade in-flight
lifecycle (including both failure shapes), keep-warm during an unsettled
delete with end-to-end pending restore after a failed delete, settlement-
then-eviction on success, the eviction guard against a tail-window restore,
and the read-side hydration re-check.

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

* fix(deletion): settle the deleted-warm load by awaiting the cascade outcome

Hardening around the deletion settlement gate introduced previously; the
epoch model is unchanged.

- The deleted-warm branch of loadFromStorage no longer completes the load
  while the deletion cascade is undecided (which exposed a classroom whose
  edits were refused without being restorable, and left a successful
  deletion's eviction with no follow-up reload). It now parks on a per-stage
  settlement promise (stageDeletionSettled) and branches on the outcome:
  a failed delete keeps the warm, restored classroom; a successful delete
  falls through to a full cold load so the server-restore path recovers the
  route; a navigation that moved on during the park is stopped by the load
  token guard. The eviction (clearStoreForDeletedStage) deliberately stops
  claiming a new load token so the parked load stays current - ghost
  re-materialization is fenced by the read-side deleted re-checks instead.
  No deadlock: a parked load holds no document lock, and the cascade never
  waits on a load.
- 'stale-dropped' now propagates through the debounced flush path:
  persistDirtySnapshot returns the sentinel instead of an empty failure set,
  and startFlushRound skips the chatSnapshot rebind on it (mirroring
  saveToStorage) - a falsely rebound baseline would let later chat saves
  no-op-skip chats that never landed and corrupt cross-tab conflict
  detection. Pending-map clearing keeps its empty-failure semantics
  (restored descriptors mint fresh revisions).
- The cascade in-flight state is a counter (begin++/settle--) instead of a
  boolean, and deleteStageData is single-flight per stage (a concurrent
  second call joins the first cascade), so overlapping deletions can never
  expose an undecided cascade as settled.
- The StaleDroppedSave docstring no longer overpromises: a tail
  saveCurrentScene can land after the cascade's clearCurrentScene; the
  orphaned cursor row is ignored by the load path rather than removed by
  the cascade.

Net +8 tests: overlapping-cascade counting, settlement-promise resolution
(success and failure), concurrent double-delete single-flight join, the
parked mid-cascade Back in both outcomes plus the navigation-away guard,
and the fenced flush round keeping an honest chat baseline end-to-end.

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

* fix(deletion): re-verify store identity on keep-warm resume, re-run joined deletes after a mid-cascade restore

Three hardening refinements on the deletion/persistence fencing:

- loadFromStorage's parked keep-warm branch now mirrors the success
  path's store-identity guard: if a tokenless writer (e.g. a database
  import) replaced the stage during the park, a failed deletion falls
  through to the cold load instead of reporting another classroom as
  the kept warm state. The ghost-discard block is correspondingly
  gated on the deleted flag so the fall-through does not mislabel a
  surviving document as a ghost.

- deleteStageData: a joined call carries its own deletion intent. When
  the first cascade fulfills but a same-id restore lifted the deleted
  flag in the meantime, the joined caller now runs one fresh cascade
  against the restored document instead of reporting the pre-restore
  outcome. Exactly one re-check per call (no recursion); a rejected
  first cascade still propagates unchanged to every joined caller.

- Narrowed the failed-delete restore-ownership comments: the restore
  covers only the pending map plus an in-flight flush round's dirt;
  a departing-stage snapshot is outside that capture and is fenced
  and dropped by design. Comment-only, no behavior change.

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

* fix(roster): gate deleted-warm handling on stage identity, keep an explicit empty roster authoritative

Final-audit fixes, three items:

- loadFromStorage keyed its deleted-warm settlement handling (park /
  keep-warm / ghost discard) behind the scenes.length > 0 shortcut, so a
  zero-scene warm ghost of a deleted classroom skipped it entirely: the
  cold load found no document, the ghost stayed in the store, and the
  classroom loader's server-fallback gate (!getCurrentStage()) never ran
  — the silently-uneditable trap this branch exists to close, in its
  scene-less form. Deletion handling now keys on stage identity alone,
  before any scene-count shortcut; the non-deleted warm skip still
  requires scenes.

- rosterNeedsLegacyFallback collapsed "roster field absent" (document
  predates roster persistence) with "explicitly persisted empty roster".
  A future writer persisting [] on a device whose read-only mirror still
  held stale rows would have its emptied roster resurrected on every
  load. The full-lift branch now triggers only when the field is absent
  (undefined); an explicit [] is authoritative and never probes the
  mirror. No current writer produces [], but the read side no longer
  depends on that invariant.

- The GeneratedAgentConfig docstring claimed the voice fields are not a
  breaking change; that only holds for this codebase's tolerant
  structural validators. The generated stage.schema.json sets
  additionalProperties: false, so consumers pinning an older published
  schema artifact reject documents carrying the new fields. The
  docstring now states both sides honestly.

Tests: a zero-scene deleted warm ghost is restored through the real
loadFromStorage (fails pre-fix), an explicit empty roster does not
resurrect stale mirror rows (fails pre-fix), and the
rosterNeedsLegacyFallback unit matrix covers undefined vs [].

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

* fix(deletion): re-queue aggregate persistence when a failed deletion is recovered

Direct aggregate saves (saveToStorage: generation completion, server-restore
hydration) run outside the pending map and the flush round, so the deletion
path's pre-delete dirt snapshot cannot describe them. When a deletion bumped
the epoch while such a save was in flight, the save was correctly fenced
('stale-dropped') — but if the deletion then failed before removing the
document, the recovery only re-queued the snapshotted scheduler descriptors.
The fenced aggregate-only content stayed memory-only with no retry anywhere,
and a later reload lost it.

The failed-deletion restore now merges a full re-mark of the aggregate
(structure / stage / outline / currentScene / chats plus a descriptor for
every current scene) into the recovery set, still guarded on the store
holding the stage. Because the flush recaptures the CURRENT store state under
the CURRENT epoch, this necessarily carries whatever the fenced aggregate
save held — and, for the same reason, edits refused by the scheduler during
the deletion window, which previously stayed memory-only as well; the scope
docstrings now say so instead of declaring them excluded.

New integration coverage drives the real store scheduler and the real storage
layer end to end: a genuinely fenced in-flight saveToStorage (generation
completion) plus a pre-removal deletion failure ends with the completion flag
durable on the next flush; a deletion-window scene edit survives the same
way; and a successful deletion re-marks nothing (control). Both recovery
tests fail against the previous restore logic.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: 杨慎 <117187635+cosarah@users.noreply.github.com>
2026-07-27 14:39:19 +08:00
be3d313bc3 feat(document): dirty-set incremental saves + operation-level flush (#983)
* feat(document): dirty-set incremental saves + operation-level flush + unload protection

Every store mutation used to trigger a debounced FULL aggregate rewrite —
editing one word rewrote the whole document, scene switches during playback
rewrote it for nothing, and concurrent editors clobbered each other at
document granularity. Mutations now mark precise dirt (scene:<id> /
structure / stage / outline / currentScene / chats): scene edits flush as
version-gated putScene, stage metadata as putStage, structure and outline
changes keep the full-save path, and currentScene/chats no longer touch
the document at all. Dirt entries are revisioned so a mutation landing
during a write is never accidentally cleared, and failed writes retain
their dirt for retry. flushStageSave() drains the debounce single-flight;
agent apply operations await it so each AI edit is durable before its tool
result returns; hidden-page/unload kick a best-effort immediate flush.
The PendingChange type stays neutral about the change unit so a future
operation-log backend can replace the flush internals at one seam.

Co-authored-by: Codex <codex@openai.com>

* fix(document): incremental-persistence review round — bypass-proofing, true drain, no stranded dirt

Structural mutations carry the cursor they changed (and cursor resolution
validates membership on load, so a stale device cursor can never resurrect
a deleted scene). The Stage API now wraps the real store in a guard that
diffs pre/post state and centrally classifies every raw write — whiteboard,
element, canvas, and action-engine mutations can no longer bypass dirty
tracking, and new API namespaces inherit the guard by construction (with a
lint-like inventory test). flushStageSave is a bounded true drain: an
awaiter whose mutation landed mid-flight loops until its own dirt is
durable. setStage hands the old document's dirt to a departing flush
instead of discarding it, and a failed flush always reschedules. Mixed
dirty batches (scene+stage/outline) use the atomic full save — the
incremental fast path is homogeneous-only. Chat-half failures keep the
chats dirt for retry without changing the tolerated-split outer semantics.

Co-authored-by: Codex <codex@openai.com>

* fix(document): reviewer round — drain covers caller dirt, full-path chat retry, stamping unification

flushStageSave's early-return now only fires when the failing round's
snapshot actually covered the caller's dirt — an agent edit landing beside
an in-flight failing round is written before the promise resolves, keeping
the tool-result durability guarantee unconditional. The full-save path
reports chat failures the same way the incremental path does (chats dirt
survives and reschedules; the tolerated-split semantics are unchanged).
The departing-stage flush retries once and its doc-comment states the
best-effort semantics honestly. Stage and scene stamping are extracted and
shared — fixing the incremental putScene branch silently dropping the
order ?? index normalization the full path applies — and the dead outline
mark in setGenerationComplete is gone.

Co-authored-by: Codex <codex@openai.com>

---------

Co-authored-by: Codex <codex@openai.com>
Co-authored-by: 杨慎 <117187635+cosarah@users.noreply.github.com>
2026-07-24 00:00:14 -04:00
wyucandCodex e39c64cd9e feat(persistence): one-command server-backed stack (compose profile + embedded API + docs) (#982)
* feat(persistence): one-command server-backed stack — embedded API + compose postgres profile

docker compose --profile server-persistence up --build runs exactly two
containers: the app (persistence HTTP server embedded at /api/persistence,
enabled only when DATABASE_URL is set — unset keeps today's browser-only
behavior byte-for-byte) and PostgreSQL 16. Client bootstrap configures both
storage seams against the same-origin route when NEXT_PUBLIC_PERSISTENCE=1,
riding the existing lazy migrations so prior browser data reaches the
server per-course on first open. Dev auth (bearer token + client-asserted
learner header) lives in one overridable module and is loudly documented
as development-grade. Verified end-to-end against the real stack:
document PUT/GET and runtime session create land in Postgres; bad tokens 401.

Co-authored-by: Codex <codex@openai.com>

* fix(persistence): review round — structural bootstrap ordering, retryable init, honest failure surfaces

The bootstrap side-effect import moves into the two storage seam entry
modules, so any module graph that can resolve a store necessarily
evaluates it first — no client-entry ordering luck, and the two configure
calls are all-or-nothing. A failed server init clears its cached promise
(next request retries instead of permanent 500s). listStages rethrows
backend failures and the dashboard surfaces a persistence-unavailable
error instead of rendering an empty list that reads as data loss. Docs
state the build-time nature of NEXT_PUBLIC_PERSISTENCE, the real token
boundary (any visitor can extract it and impersonate any learner —
localhost/trusted-network only), and the correct postgres password
rotation. Dev-token compare is timing-safe; the handler singleton lives
on globalThis so HMR cannot leak pools.

Co-authored-by: Codex <codex@openai.com>

* fix(persistence): review response — no-confidentiality wording + adapter round-trip coverage

The token docs now state plainly that the NEXT_PUBLIC token is compiled
into the public bundle and provides no confidentiality or user isolation
(its only role is keeping unrelated scanners off a trusted network). The
Fetch/Node adapter — the route's most bug-prone code — gains a round-trip
integration test: PUT body streaming, 201 with multi-value headers, 204
empty body, and encoded path segments reaching the handler un-decoded.

---------

Co-authored-by: Codex <codex@openai.com>
2026-07-23 16:28:03 +08:00
d0d227424b feat(document): complete the document-persistence cutover — stage/scene/outline onto DocumentStore (#965)
* feat(runtime): persist quiz attempts in RuntimeStore

* fix(runtime): coalesce quiz draft snapshots

* fix(runtime): recover concurrent quiz attempts

* fix(runtime): handle quiz completion races

* fix(runtime): commit quiz review atomically

* fix(runtime): dedupe concurrent quiz writes

* fix(runtime): drop stale quiz drafts

* fix: harden quiz runtime review recovery

* fix: serialize quiz attempt identity

* feat: read quiz state from runtime store

* fix: persist quiz retries before resetting

* fix: preserve authoritative quiz outcomes

* fix: preserve legacy quiz retries during cutover

* fix: reconcile legacy quiz snapshots safely

* fix: drain rollover quiz write queues

* fix: drain completed quiz retry queues

* fix: reuse concurrent quiz retries

* fix(quiz): preserve drafts across abrupt reloads

* fix(quiz): recover empty retry sessions

* fix(chat): abort stalled runtime state reads

* fix(quiz): expose queued phases to readers

* fix(quiz): retain concurrent writer tails

* fix(quiz): canonicalize retry branches

* fix(quiz): keep retry rollovers monotonic

* fix(quiz): validate skipped retry siblings

* fix(quiz): close read cutover races

* test(classroom): cover legacy quiz summaries

* fix(quiz): reset async consumers on scene changes

* fix(quiz): close scene transition windows

* test(pbl): cover launch freshness guards

* fix(quiz): close cutover concurrency gaps

* fix(quiz): harden retry and context freshness

* fix(quiz): reject malformed legacy answers

* fix(quiz): validate legacy answer values

* test(quiz): cover legacy multi-answer migration

* fix(merge): retire dead ChatRequestTemplate.storeState after quiz read cutover

Main's three call sites built static storeState blocks that runAgentLoopFn
never consumed (it always rebuilds fresh state via getStoreState); the quiz
read cutover replaced that callback with the async two-phase RuntimeStore
read, leaving the template field with zero consumers. Drop it.

* feat(storage): conform HTTP/PG backends and reference server to RuntimeAppendOptions

The quiz write path's expectedLastSeq / sessionTransition / RuntimeAppendConflictError
semantics existed only in the browser backend; server-backed deployments would
silently accept conflicting appends and leave completed sessions active. Forward
the options over the wire, detect conflicts atomically under the PG transaction,
map them to HTTP 409 RUNTIME_APPEND_CONFLICT, and rematerialize the typed error
client-side so quiz retry logic works across every backend.

Co-authored-by: Codex <codex@openai.com>

* fix(chat): Pi single requests build storeState via the async runtime quiz read

Pi bypasses runAgentLoop's per-iteration getStoreState and serializes the
request template straight to /api/chat/pi, which rejects bodies without
storeState. Extract the fresh-snapshot builder (async RuntimeStore quiz read
with the scene-transition guard) and call it on the Pi path too.

* ci: whitelist the runtime-data-cutover integration trunk for PR checks

* feat(runtime): playback cutover — cursor in KV, discussion facts in RuntimeStore (#956)

* feat(runtime): cut playback over to the runtime layer — cursor in KV, facts in RuntimeStore (#869)

The fourth and last runtime family. Consumed-discussion facts become
append-only 'playback' records folded into a set at read (at-least-once
appends, no conflict machinery); the resume cursor is device-scoped
last-write-wins KV per the amended #779/#869 split. sessionStorage keeps
same-tab priority; KV takes over on fresh tabs/reloads. The dead Dexie
playbackState machinery is retired, with a one-time lazy migration of any
legacy row (cursor half + facts half) before deletion, and stage deletion
now clears both the KV cursor and any unmigrated legacy row.

Co-authored-by: Codex <codex@openai.com>

* test(runtime): include playbackState in the stage-delete db mock

---------

Co-authored-by: Codex <codex@openai.com>

* fix(playback): persist discussion facts on every consumption path (#957)

* fix(playback): persist discussion facts on every consumption path (final-review P0+P1s)

- The engine now publishes a progress snapshot the moment a discussion is
  consumed (join / skip / unselected-agent auto-skip). onProgress otherwise
  fires before the discussion action executes and a discussion is the scene's
  last action, so the fact never reached persistence.
- Reads fold records across ALL playback sessions in the learner partition
  (mergeLearner deliberately preserves same-kind sessions from both keys).
- Legacy migration appends only not-yet-durable facts, so an interrupted
  migration resumes instead of dropping the tail.
- recordConsumedDiscussion reports durability; the component drops failed ids
  from its observed set so a later progress tick retries (at-least-once).

* test(e2e): live verification of the playback persistence chain

Seeds a deterministic stage straight into the Dexie DB, starts the lecture
via the canvas overlay, and asserts the full chain: discussion auto-skip
appends a discussionConsumed record to maic-runtime, the device cursor lands
in KV, and both survive a fresh browsing context (empty sessionStorage).

* refactor(playback): consumed-discussion state is volatile by decision — cursor-only persistence (#959)

Product ruling on #869's fourth family: playback learner state is front-end
ephemeral UX, not learner data. A re-shown proactive card auto-skips, joined
discussions' content already lives in chat runtime records, and no replay
export / analytics consumer exists — so durable facts bought nothing over
in-memory + same-tab sessionStorage. Drop lib/playback/runtime.ts and the
RuntimeStore facts wiring; keep the device-scoped KV resume cursor (the half
with real UX value), the engine's consumption-time progress snapshot (cursor
freshness), and the legacy Dexie retirement (cursor half migrates, row
deletes, consumed ids are dropped).

* ci: whitelist the document-cutover integration trunk for PR checks

* feat(document): widened-stage contracts + app document seam (#860 follow-up, PR1) (#961)

Package: MaicDocument/DocumentStore/StageRow gain a TStage parameter
(defaults keep existing users unchanged), BrowserDocumentStore accepts an
injectable validateStage, and a version-guarded putStage closes the rename
read-modify-write race — same current-version gate as putScene.

App: lib/document/ seam — AppDocument types (currentSceneId is device
state, not document data, and the app stage validator rejects it), a
four-kind scene validator (slide/quiz delegate to the DSL; interactive/pbl
validate envelope + discriminant binding only), and pure legacy
canonicalizers (type rebound from content.type, singular whiteboard →
whiteboards, outline envelope). No consumer switches off Dexie yet.

Co-authored-by: Codex <codex@openai.com>

* feat(document): stage-storage cutover + locked lazy migration (PR2) (#962)

* feat(document): cut stage/scene persistence over to DocumentStore with locked lazy migration (PR2)

stage-storage now persists through the lib/document seam: aggregate
saveDocument with a mandatory outline envelope (omission means deletion in
the contract; ordinary saves fall back to the stored envelope), lazy
per-stage migration under a Web Lock (destination authoritative, one
coherent legacy snapshot, save-verify-marker, legacy rows retained
read-only; no-lock environments read legacy without migrating),
currentSceneId in device KV, rename via version-guarded putStage, and a
list that merges legacy-only stages until they migrate. stageOutlines'
second write authority folds into the document service, making the final
scene set and generationComplete one atomic commit.

Co-authored-by: Codex <codex@openai.com>

* fix(document-store): relocate the persistence seam out of lib/document

lib/document/ is the server-side content-extraction domain (sharp, cloud
SDKs); exporting the persistence seam from its barrel dragged node-only
modules into the client bundle and broke the Turbopack build. The seam now
lives in lib/document-store/ with its own barrel, and the extraction barrel
is restored. Client-only guards become capability probes (indexedDB /
localStorage presence) so injected node test environments keep working
while a true server render still fails loud.

* fix(document-store): no-lock write classification + lock-order inversion (PR2 fix round)

Lock order is now per-stage document lock → global runtime epoch (a save
never waits on a document lock while occupying the shared epoch, which
deadlocked against fair-queued maintenance). Without Web Locks, mutation
classifies instead of refusing wholesale: destination-backed and genuinely
new documents accept the product's established lock-free/LWW risk, while a
legacy-only document stays read-only so two authorities cannot fork —
matching the chat-cutover-era acceptance suite, whose save assertions now
verify through the public API instead of legacy Dexie rows.

Co-authored-by: Codex <codex@openai.com>

* style: prettier on document-store seam files

---------

Co-authored-by: Codex <codex@openai.com>

* feat(document): cut secondary consumers over to the document seam (PR3) (#963)

ZIP classroom/video exports read stage metadata through the seam; classroom
import builds one canonical aggregate under the per-stage lock and writes
canonical 'whiteboards' (fixing the import-side singular-alias defect the
manifest never had); backups become versioned document aggregates that
finally include outline envelopes, while old {stages, scenes} backups are
accepted through the legacy canonicalizers; clearDatabase now clears
maic-documents and the seam's KV keys so clear-cache cannot strand
documents; deleteStageWithRelatedData is deprecated in favor of
deleteStageData. Four e2e seeders target maic-documents directly;
classroom-interaction stays legacy-seeded to exercise real-UI lazy
migration.

Co-authored-by: Codex <codex@openai.com>

* fix(document): final-review hardening — restore pre-images, honest markers, clear-race exclusion (3 P1 + 2 P2) (#964)

- Backup rollback restores pre-existing destination documents (and imported
  current-scene KV values) from captured pre-images instead of deleting them
- The skip-migration path only writes its marker after reconciling the
  legacy snapshot against the authoritative destination; a divergent
  snapshot logs and stays discoverable instead of being falsely certified
- Migration's destination write enrolls in the global shared storage epoch
  (per-stage -> global order), so clearDatabase's exclusive epoch can no
  longer race a lazy migration into resurrecting a document
- listStages drops legacy entries whose snapshot vanished mid-merge

Co-authored-by: Codex <codex@openai.com>

* fix(review): P3 pair from cross-review — scene-id boundary + sessionTransition 400 (#966)

* fix(review): scene-id boundary for quiz context + 4xx for malformed sessionTransition (P3 pair)

Review findings on #955: didActiveSceneRemainUnchanged compared the active
scene by object identity, so a store update reallocating the scene during
the async quiz read dropped the learner's graded answers from that turn's
request — the scene id is the real boundary. The records route now
classifies a malformed sessionTransition as a validation failure instead of
letting the store's throw surface as a 500.

* fix(playback): superseded-engine cursor guard + migration write-window recheck

Second-vendor review of the #959 shrink (requested after the cross-review
noted it had single-vendor coverage) found: an engine orphaned by a scene
switch during async lecture resume could pass the idle-only recheck, be
resurrected, and publish its old scene's progress over the new scene's
debounced cursor — the resume continuation now requires identity with the
installed engine, and onProgress drops snapshots from superseded engines.
The legacy cursor migration also rechecks KV immediately before its write
so a concurrent tab's newer cursor cannot be overwritten and orphaned by
the legacy-row delete.

Co-authored-by: Codex <codex@openai.com>

---------

Co-authored-by: Codex <codex@openai.com>

* fix(review): approval follow-up P3 nits (#967)

* release: v0.3.1

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

* fix(review): approval P3 nits — ISO gate on sessionTransition, dead mocks, corrupt-timestamp guard

- The records route's sessionTransition guard now requires an ISO
  updatedAt (isIsoTimestamp), matching the sibling PATCH /status route
- Dead vi.mock factories for the deleted playback-storage module dropped
- A corrupt legacy playback timestamp falls back to 'now' instead of
  wedging migration into a permanent re-throw that disabled resume

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

* fix(document): backup covers no-lock legacy courses; clears fence stale saves (review P1s) (#968)

Backup assembly appends read-only canonicalized aggregates for legacy-only
stages the lazy migration could not persist (no-Web-Locks environments) —
courses visible in the app can no longer vanish from an export. A
device-scoped storage generation now fences document mutations: clears bump
it inside their exclusive section, and any save or migration commit that
entered before the clear re-reads it inside the shared epoch and fails loud
instead of repopulating a wiped store.

Co-authored-by: Codex <codex@openai.com>

* fix(document): review round 2 — legacy-only chat backup coverage + full-width generation fence (#972)

Backups now enumerate runtime chat sessions for every exported document,
not just store-backed ones — a legacy-only course's chat history (the chat
and document cutovers are independent seams) no longer drops out of the
export; the runtime read injects an empty legacy chat store since legacy
rows are collected by direct table reads, which also keeps the export
working without Web Locks. The storage-generation fence now guards every
mutating store method, with the exclusive-epoch delete cascades passing
storageSharedLockHeld so the guard cannot self-deadlock against their own
hold.

---------

Co-authored-by: Codex <codex@openai.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-22 23:18:24 +08:00
3eea9dc542 feat(runtime): complete the #869 learner-data cutover — quiz + playback onto RuntimeStore (#955)
* feat(runtime): persist quiz attempts in RuntimeStore

* fix(runtime): coalesce quiz draft snapshots

* fix(runtime): recover concurrent quiz attempts

* fix(runtime): handle quiz completion races

* fix(runtime): commit quiz review atomically

* fix(runtime): dedupe concurrent quiz writes

* fix(runtime): drop stale quiz drafts

* fix: harden quiz runtime review recovery

* fix: serialize quiz attempt identity

* feat: read quiz state from runtime store

* fix: persist quiz retries before resetting

* fix: preserve authoritative quiz outcomes

* fix: preserve legacy quiz retries during cutover

* fix: reconcile legacy quiz snapshots safely

* fix: drain rollover quiz write queues

* fix: drain completed quiz retry queues

* fix: reuse concurrent quiz retries

* fix(quiz): preserve drafts across abrupt reloads

* fix(quiz): recover empty retry sessions

* fix(chat): abort stalled runtime state reads

* fix(quiz): expose queued phases to readers

* fix(quiz): retain concurrent writer tails

* fix(quiz): canonicalize retry branches

* fix(quiz): keep retry rollovers monotonic

* fix(quiz): validate skipped retry siblings

* fix(quiz): close read cutover races

* test(classroom): cover legacy quiz summaries

* fix(quiz): reset async consumers on scene changes

* fix(quiz): close scene transition windows

* test(pbl): cover launch freshness guards

* fix(quiz): close cutover concurrency gaps

* fix(quiz): harden retry and context freshness

* fix(quiz): reject malformed legacy answers

* fix(quiz): validate legacy answer values

* test(quiz): cover legacy multi-answer migration

* fix(merge): retire dead ChatRequestTemplate.storeState after quiz read cutover

Main's three call sites built static storeState blocks that runAgentLoopFn
never consumed (it always rebuilds fresh state via getStoreState); the quiz
read cutover replaced that callback with the async two-phase RuntimeStore
read, leaving the template field with zero consumers. Drop it.

* feat(storage): conform HTTP/PG backends and reference server to RuntimeAppendOptions

The quiz write path's expectedLastSeq / sessionTransition / RuntimeAppendConflictError
semantics existed only in the browser backend; server-backed deployments would
silently accept conflicting appends and leave completed sessions active. Forward
the options over the wire, detect conflicts atomically under the PG transaction,
map them to HTTP 409 RUNTIME_APPEND_CONFLICT, and rematerialize the typed error
client-side so quiz retry logic works across every backend.

Co-authored-by: Codex <codex@openai.com>

* fix(chat): Pi single requests build storeState via the async runtime quiz read

Pi bypasses runAgentLoop's per-iteration getStoreState and serializes the
request template straight to /api/chat/pi, which rejects bodies without
storeState. Extract the fresh-snapshot builder (async RuntimeStore quiz read
with the scene-transition guard) and call it on the Pi path too.

* ci: whitelist the runtime-data-cutover integration trunk for PR checks

* feat(runtime): playback cutover — cursor in KV, discussion facts in RuntimeStore (#956)

* feat(runtime): cut playback over to the runtime layer — cursor in KV, facts in RuntimeStore (#869)

The fourth and last runtime family. Consumed-discussion facts become
append-only 'playback' records folded into a set at read (at-least-once
appends, no conflict machinery); the resume cursor is device-scoped
last-write-wins KV per the amended #779/#869 split. sessionStorage keeps
same-tab priority; KV takes over on fresh tabs/reloads. The dead Dexie
playbackState machinery is retired, with a one-time lazy migration of any
legacy row (cursor half + facts half) before deletion, and stage deletion
now clears both the KV cursor and any unmigrated legacy row.

Co-authored-by: Codex <codex@openai.com>

* test(runtime): include playbackState in the stage-delete db mock

---------

Co-authored-by: Codex <codex@openai.com>

* fix(playback): persist discussion facts on every consumption path (#957)

* fix(playback): persist discussion facts on every consumption path (final-review P0+P1s)

- The engine now publishes a progress snapshot the moment a discussion is
  consumed (join / skip / unselected-agent auto-skip). onProgress otherwise
  fires before the discussion action executes and a discussion is the scene's
  last action, so the fact never reached persistence.
- Reads fold records across ALL playback sessions in the learner partition
  (mergeLearner deliberately preserves same-kind sessions from both keys).
- Legacy migration appends only not-yet-durable facts, so an interrupted
  migration resumes instead of dropping the tail.
- recordConsumedDiscussion reports durability; the component drops failed ids
  from its observed set so a later progress tick retries (at-least-once).

* test(e2e): live verification of the playback persistence chain

Seeds a deterministic stage straight into the Dexie DB, starts the lecture
via the canvas overlay, and asserts the full chain: discussion auto-skip
appends a discussionConsumed record to maic-runtime, the device cursor lands
in KV, and both survive a fresh browsing context (empty sessionStorage).

* refactor(playback): consumed-discussion state is volatile by decision — cursor-only persistence (#959)

Product ruling on #869's fourth family: playback learner state is front-end
ephemeral UX, not learner data. A re-shown proactive card auto-skips, joined
discussions' content already lives in chat runtime records, and no replay
export / analytics consumer exists — so durable facts bought nothing over
in-memory + same-tab sessionStorage. Drop lib/playback/runtime.ts and the
RuntimeStore facts wiring; keep the device-scoped KV resume cursor (the half
with real UX value), the engine's consumption-time progress snapshot (cursor
freshness), and the legacy Dexie retirement (cursor half migrates, row
deletes, consumed ids are dropped).

* fix(review): P3 pair from cross-review — scene-id boundary + sessionTransition 400 (#966)

* fix(review): scene-id boundary for quiz context + 4xx for malformed sessionTransition (P3 pair)

Review findings on #955: didActiveSceneRemainUnchanged compared the active
scene by object identity, so a store update reallocating the scene during
the async quiz read dropped the learner's graded answers from that turn's
request — the scene id is the real boundary. The records route now
classifies a malformed sessionTransition as a validation failure instead of
letting the store's throw surface as a 500.

* fix(playback): superseded-engine cursor guard + migration write-window recheck

Second-vendor review of the #959 shrink (requested after the cross-review
noted it had single-vendor coverage) found: an engine orphaned by a scene
switch during async lecture resume could pass the idle-only recheck, be
resurrected, and publish its old scene's progress over the new scene's
debounced cursor — the resume continuation now requires identity with the
installed engine, and onProgress drops snapshots from superseded engines.
The legacy cursor migration also rechecks KV immediately before its write
so a concurrent tab's newer cursor cannot be overwritten and orphaned by
the legacy-row delete.

Co-authored-by: Codex <codex@openai.com>

---------

Co-authored-by: Codex <codex@openai.com>

* fix(review): approval follow-up P3 nits (#967)

* release: v0.3.1

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

* fix(review): approval P3 nits — ISO gate on sessionTransition, dead mocks, corrupt-timestamp guard

- The records route's sessionTransition guard now requires an ISO
  updatedAt (isIsoTimestamp), matching the sibling PATCH /status route
- Dead vi.mock factories for the deleted playback-storage module dropped
- A corrupt legacy playback timestamp falls back to 'now' instead of
  wedging migration into a permanent re-throw that disabled resume

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Codex <codex@openai.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-22 15:28:04 +08:00
wyuc 65bf20a84b feat(runtime): cut chat sessions over to RuntimeStore (#926)
* feat(runtime): persist chat sessions in RuntimeStore

* fix(runtime): harden chat persistence ordering

* fix(runtime): protect cross-tab chat snapshots

* fix(runtime): compare structured chat values safely

* fix(runtime): preserve chat backup and reset semantics

* fix(runtime): restore imported chat snapshots

* fix(runtime): isolate chat load failures

* fix(runtime): make cache clearing lossless

* fix(runtime): normalize empty chat saves

* fix(runtime): serialize legacy chat migration

* fix(chat): serialize backup restoration

* test(chat): cover multi-stage restore locking

* fix(chat): coordinate fallback migrations across tabs

* fix(chat): harden cross-tab migration locking

* fix(database): retire abandoned chat lease schema

* fix(chat): preserve monotonic local edits

* fix(chat): protect unseen runtime sessions

* fix(chat): order lifecycle transitions monotonically

* fix(chat): serialize runtime maintenance

* fix(chat): order queued writes before maintenance

* fix(chat): preserve cross-version lock compatibility

* fix(chat): preserve streamed and backup data

* fix(chat): isolate empty no-lock saves

* fix(chat): isolate partial runtime snapshots

* fix(runtime): close cross-store cutover races

* fix(runtime): close remaining cutover races

* fix(chat): retain caller-visible conflict baseline

* fix(chat): preserve read-only legacy autosaves

* fix(chat): avoid restore lock inversion

* fix(runtime): enroll writes before maintenance

* fix(runtime): retain maintenance ordering

* fix(runtime): close restore and deletion races

* fix(chat): order partition queues within lock epochs

* fix(chat): preserve recovery and deletion intent

* fix(runtime): recover interrupted chat restores

* fix(backup): scope chat deduplication by stage

* fix(backup): stage chats by runtime partition

* fix(backup): clear staged chats with stages
2026-07-16 18:30:40 +08:00
Yizuki_Ame 0db93bde0a fix(storage): store image files as array buffers (#923) 2026-07-14 20:43:05 +08:00
wyucand杨慎 5081f19c1f fix image storage IDs containing underscores (#918)
Co-authored-by: 杨慎 <117187635+cosarah@users.noreply.github.com>
2026-07-14 14:22:27 +08:00
jackefnandRowan_lxb baff0998bb Feat/document bundles milestone 3 (#844)
* feat(document): support document bundles

* fix(document): avoid server extractor imports in client bundle

* fix(pdf): default mineru backend to pipeline

* fix(document): address bundle review findings

* fix(document): harden extraction limits and storage errors

* fix(document): make analysis step format agnostic

---------

Co-authored-by: Rowan_lxb <Lxb_savior@163.com>
2026-07-13 23:01:40 +08:00
wyucand杨慎 c8a638a101 feat: support GPT-5.6 model family (#907)
* feat: support GPT-5.6 model family

* test: cover GPT-5.6 SDK validation

* fix: canonicalize GPT-5.6 Sol alias

* fix: complete GPT-5.6 alias lookups

---------

Co-authored-by: 杨慎 <117187635+cosarah@users.noreply.github.com>
2026-07-13 18:19:40 +08:00
f98a7e0b3e perf(editor): dedupe alignment snap-lines in O(n) instead of O(n^2) (#692)
uniqAlignLines used Array.findIndex inside a forEach (O(n^2)) to merge
alignment snap-lines. It runs on every drag/scale mousemove and the line
count scales with nearby element edges, so it janks on element-dense
canvases. Dedupe via a Map keyed on value (O(n)); a Map preserves
first-occurrence order, so output order and range-merge semantics are
unchanged.

Closes #691

Co-authored-by: ly-wang19 <ly-wang19@users.noreply.github.com>
Co-authored-by: wyuc <wang-yc24@mails.tsinghua.edu.cn>
2026-07-11 22:09:12 +08:00
wyucandClaude Fable 5 5627b99156 feat(pbl): runtime-event outbox + dual-write into the RuntimeStore (#869 Part C, step 2) (#893)
* feat(pbl): emit typed runtime events from every learner-state mutation (#869)

The outbox half of the runtime split: pure operations append typed
PBLRuntimeEvents to project.runtimeEvents (ring-capped at 500, dedup by
id) for every learner-state mutation — messages, submissions,
evaluations, status changes across project/milestone/microtask/uiPhase,
handover and task-completion gates, and proficiency updates. Emission
stays inside the pure mutators (no I/O), so events cross the stateless
server boundary inside the project; a client-side drainer (next commit)
copies them into the RuntimeStore. Zero read-side changes.

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

* feat(pbl): drain the runtime-event outbox into the RuntimeStore (#869)

Client-only drainer: after each persisted project change, copy new
runtimeEvents into the learner's active 'pbl' RuntimeSession (created
lazily), watermarked per stage through the device-scoped KV. Mid-drain
failures persist the last-success watermark and retry next tick; a
watermark evicted from the ring-capped ledger redrains visible events
(documented at-least-once — downstream folds dedup by event id). The
whole drain is bounded and never throws into the save path.

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

* feat(pbl): dual-write engagement events; end-to-end outbox coverage (#869)

The drainer now copies both in-project ledgers — runtimeEvents and
engagementEvents — into the learner's pbl RuntimeSession, with
independent watermarks that advance separately (a failure in one ledger
never loses the other's progress). End-to-end test drives real
operations through the reducers and drains into a real
BrowserRuntimeStore over fake-indexeddb, asserting the full ordered
event trail with correct anchors.

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

* fix(pbl): harden the outbox drain per cross-review (#869)

- Watermarks scope to (stageId, sceneId, learnerKey): multi-scene stages
  and multi-learner devices no longer cross-contaminate or redrain.
- First-session creation is race-free: deterministic session id
  (pbl-<stageId>-<learnerKey>) with already-exists fallback plus an
  in-flight memo, so concurrent drains cannot split one history.
- A corrupt watermark value degrades to a fresh redrain and is repaired
  on the next persist, instead of permanently blocking the drain.
- Stage deletion clears its drain watermarks alongside the runtime rows.
- resetProjectProgress emits a project_reset epoch marker so folds never
  resurrect pre-reset submissions/evaluations from the preserved ledger.
- Normalization repairs use deterministic event ids, collapsing echoes
  from cloned-copy re-normalization under id-dedup.
- Docs: ring-overflow latent-loss window (dual-write accepts it; the
  read-flip backfills from a projectV2 snapshot) and the fold-baseline
  contract (status 'active' / uiPhase 'hero' from design-time defaults).

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

* fix(pbl): carry server-minted events across the advance patch (#869)

Two holes on the server-patch path: applyAdvanceProjectPatch computed
status-change 'from' values after Object.assign had already replaced the
milestone snapshot (suppressing every real Instructor-advance event as
from===to), and events minted server-side during advanceMicrotask (e.g.
handover_staged) lived only on the transient server copy — the advance
patch carried no runtimeEvents, so the client ledger never drained them.
Patches now carry the operation's event delta; the client appends them
first, and both sides mint deterministic patch:* ids for the same
transition so echoes collapse under id-dedup. Server-side facts are
authoritative; client emissions remain as a compatibility fallback.

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

* fix(pbl): scope deterministic event ids to the reset epoch (#869)

Deterministic norm:/patch: status-event ids now lead with the count of
project_reset events visible in the ledger, so a learner repeating the
same transition after a reset is recorded instead of deduped against the
pre-reset event. The epoch is derived from the same visible window the
id-dedup scans, so ring-buffer eviction can never desynchronize the two.

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

* test(store): drain debounced saves before teardown (#869)

setStageAgents schedules debouncedSave + debouncedSaveAgents; tests that
finished without flushing left timers firing after environment teardown,
lazily importing the settings/providers chain into a torn-down runtime
(deterministic on CI once this branch grew the stage-storage import
graph). Store tests now run fake timers and assert zero pending timers
at teardown; the same latent race in the insert-scene-after and
generation-complete suites is fixed in the same style.

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

* fix(pbl): address review — durable epoch, id-set deltas, ordered drain, post-save signal (#869)

- runtimeResetEpoch is a monotonic project field incremented on reset,
  never derived from the ring buffer, so evicting an old reset marker
  can no longer collapse post-reset transitions into pre-reset ids.
- Advance patches capture their event delta by id-set difference, not
  array index, so cap eviction mid-operation cannot drop server events.
- The drainer merges both ledgers into one timestamp-ordered stream
  (stable within each ledger) before appending, so RuntimeStore seq
  preserves global chronology instead of ledger-grouped order.
- Draining now subscribes to a post-save signal emitted after
  saveToStorage commits, instead of firing on the in-memory update —
  the dual-write can no longer outrun the source-of-truth persistence.

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

* fix(pbl): drain the persisted snapshot, decoupled from scene mounting (#869)

The post-save signal now carries the PBL scenes exactly as persisted;
a module-level singleton subscription drains them, replacing the
component-ref hook. Navigating away before the debounced save fires no
longer strands the saved scene's outbox, and an in-flight save can no
longer drain a newer unsaved snapshot.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 00:12:53 +08:00
wyuc 667f3b0c51 feat(runtime): device-anonymous learner identity + RuntimeStore app bootstrap (#869 Part C, step 1) (#885)
* feat(runtime): device-anonymous learnerKey + app RuntimeStore singleton (#869)

* feat(runtime): cascade stage deletion into the runtime store (#869)

deleteStageWithRelatedData now cascades into the runtime layer after its
Dexie transaction completes. The runtime data lives in a separate
IndexedDB database (maic-runtime), so it cannot join the transaction, and
the cascade goes through deleteStageRuntimeSafely — a helper that never
throws (warns with context instead): a broken or hung runtime DB must not
brick stage deletion in the main app DB. deleteStageRuntime is idempotent,
so a failed cascade can simply be retried.

Covered at the helper seam with a stub RuntimeStore (success + throwing);
the repo has no Dexie-in-node harness for database.ts and this change
does not invent one.

* fix(runtime): wire the live deletion path, serialize learner-key minting (#869)

Cross-review fixes on the C1 bootstrap:

- The runtime cascade was wired into deleteStageWithRelatedData, which has
  zero callers; the UI classroom-deletion flow goes through deleteStageData
  in stage-storage. Wire deleteStageRuntimeSafely into that live path too
  (after its Dexie work, same isolation rationale), and cover the wiring by
  running the real deleteStageData with its module deps mocked — the
  repo's established pattern for database-touching code.
- getLearnerKey minted twice under concurrency. Same-bundle callers now
  share one in-flight promise on the default path (failures are not
  cached), and every path re-reads after writing and returns the PERSISTED
  key, so a cross-tab race converges on the stored winner instead of
  keeping an orphaned local mint.
- Guard crypto.randomUUID with the house fallback pattern.
- Honest docs: the cascade comment no longer claims a retry path exists
  (orphaned rows are inert today; a startup sweep is deferred to Part C2),
  and both lib/runtime modules note they are client-only.

* fix(runtime): cross-tab mint lock and bounded deletion cascade (#869)

- Read-after-write alone still let a tab keep an orphaned learner key
  when its re-read landed before the other tab's write. Minting now runs
  under the Web Locks API ('maic:learner-key') where available: grants
  are mutually exclusive across tabs, the loser re-reads the winner's key
  inside its grant, and an existing key is never overwritten (so the
  per-tab memo stays safe). Without navigator.locks (older browsers,
  non-window contexts) the memo + read-after-write behavior remains, with
  the residual race named in a comment and accepted — it merely splits
  one anonymous learner's local history.
- deleteStageRuntimeSafely awaited without a bound, so a hung runtime
  IndexedDB could block the live deletion path — the exact failure the
  helper exists to isolate. The cascade now races a 5s timeout: on
  timeout it warns and resolves (orphaned rows stay inert), and the still
  -pending cascade carries a swallow handler so a late rejection cannot
  become an unhandled rejection.

* fix(runtime): probe for the runtime DB before cascading a stage deletion (#869)

deleteStageRuntimeSafely reached openDb() unconditionally, and opening
CREATES the maic-runtime database — so deleting a classroom on a device
that never wrote runtime data paid an open-or-create of a second
IndexedDB DB, and in degraded environments burned the full 5s bound for
zero cleanup value.

Probe first without creating: where indexedDB.databases() is available,
a missing maic-runtime entry returns immediately; where the probe API is
unavailable (older Firefox), fall through to the bounded cascade —
skipping there would strand real cleanup once Part C2 adds writers. The
probe shares the existing try/catch + timeout envelope, so a hanging
databases() cannot brick deletion either. The DB name is a module const
passed explicitly to BrowserRuntimeStore so probe and store can never
drift.
2026-07-09 14:54:21 +08:00
danishsshaikh d00795094c feat(playback): add action-level navigation (#843)
Squashed from #843. Adds Notes click-to-jump action navigation and action-level playback resume/restore, with visible toolbar sentence controls removed after review.
2026-07-08 00:02:39 -04:00
wyucandClaude Opus 4.8 6538e718ff feat(editor): in-editor authoring of classroom agents (Stage-level roster) (#816)
* feat(editor): agent roster edit operations

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

* refactor(editor): dedupe agent.delete lookup + cover history cap/color/teacherCount

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

* feat(editor): materialize agent roster from presets

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

* refactor(editor): cycle prepended-teacher index + document branch-1 as-is

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

* feat(editor): stage store viewMode + agent roster persistence

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

* feat(editor): useAgentRoster controller hook

Implements the AgentRosterController hook that bridges the store, history
stack, and agent-ops layer for the agents authoring view.

- Materializes roster from stage on mount via materializeRoster + resolvePreset
- Holds AgentRosterHistory in React state; exposes add/update/remove/reorder
- Guards LAST_TEACHER errors (caught, no-op) in update + remove
- Exposes SurfaceHistory-shaped history object for undo/redo wiring
- Syncs history.present to setStageAgents via useEffect on every change

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

* feat(editor): Agents view UI components

Adds AgentsView, AgentRosterGrid, AgentCard, AgentInspector, AvatarPicker.

Layout: full-height panel with a scrollable grid of agent cards on the left
and a fixed inspector panel on the right (opens when a card is selected).

- AgentCard: avatar + name + role badge + persona excerpt; ◀/▶ reorder
  buttons; delete button disabled + titled when canRemove=false (last teacher)
- AgentRosterGrid: responsive grid with trailing [+ 添加角色] add tile
- AgentInspector: name input, role select (teacher/assistant/student),
  AvatarPicker over AGENT_DEFAULT_AVATARS, persona textarea capped 2000 chars
- AvatarPicker: grid of AGENT_DEFAULT_AVATARS with violet selection ring
- AgentsView: composes the hook + grid + inspector; renders its own
  CommandBar (title="Agents", history=hook.history) with leading/trailing slots

Styling follows quiz surface conventions (rounded-2xl cards, zinc palette,
violet accent on selection, same FOCUS ring treatment for inputs).

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

* feat(editor): [Slides]/[Agents] view toggle wired into Pro-mode chrome

- CommandBar: add optional `leading?: ReactNode` center slot for the toggle
- EditShell/Frame: thread new `commandLeading` prop down to CommandBar
- EditChromeRoot: read viewMode/setViewMode from useStageStore; build a
  ViewModeToggle ([幻灯片] / [角色] segmented control) passed as commandLeading
  to both EditShell (slides mode) and AgentsView (agents mode)
- When viewMode==='agents', render AgentsView instead of EditShell; leftRail
  and bottomRail are naturally absent since AgentsView owns its own layout
- When viewMode==='slides', existing EditShell path is unchanged
- CommandBar history in agents mode binds to useAgentRoster().history;
  title = "Agents"

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

* fix(editor): functional state updates in useAgentRoster + last-teacher role feedback

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

* fix(editor): break infinite render loop in Agents view persistence effect

Remove `stage` from the useEffect dep array in useAgentRoster — setStageAgents
mutates stage (new object ref), which was triggering re-render → effect → loop
(React error #185). setStageAgents already no-ops when stage is null
(lib/store/stage.ts:287), so the `if (stage)` guard is also removed.

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

* fix(editor): persist agent roster edits to registry + stage snapshot

- Add generatedAgentConfigs to StageRecord (database.ts) so Dexie can
  round-trip it through db.stages.
- Include generatedAgentConfigs in saveStageData whitelist (stage-storage.ts)
  so editor reloads restore the user's roster edits.
- In saveToStorage (stage.ts), call saveGeneratedAgents(stageId, configs)
  after the stage snapshot write; this is already debounced (500 ms) via
  debouncedSave, so db.generatedAgents + the in-memory registry are synced
  without writing on every keystroke. saveGeneratedAgents clears-then-
  bulk-inserts, so deleted agents are fully removed from the registry.
- Add 3 timer-driven tests that verify saveStageData carries
  generatedAgentConfigs and saveGeneratedAgents is called with the
  correct payload after the debounce fires.

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

* fix(editor): scope agent-registry sync to real roster edits (cross-review)

- FIX A (P1): remove saveGeneratedAgents from shared saveToStorage; add
  dedicated debouncedSaveAgents called only from setStageAgents — scene
  advances (setCurrentSceneId etc.) no longer churn db.generatedAgents
- FIX B (P2.1): add isDirtyRef guard in useAgentRoster so opening the
  Agents tab without editing does not persist/rewrite the roster; ref is
  set in applyOp, add, undo, redo before any setHistState mutation
- FIX C (P2.2): coalesce consecutive agent.update ops on the same
  agent+fields into one history entry so undo steps over a whole edit
  rather than each keystroke
- Tests: add regression guards asserting saveGeneratedAgents is NOT
  called on setCurrentSceneId or plain saveToStorage, IS called after
  setStageAgents (new dedicated debounce path)

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

* fix(editor): clone preset agents to fresh ids on materialization (cross-review P1)

* refactor(editor): move agent roster into right-rail tab beside Edit with AI

The [幻灯片][角色] top toggle + full-canvas AgentsView swap is replaced
by a tabbed right rail (RightRailTabs) with "Edit with AI" and "角色"
tabs. The slide canvas stays visible in all modes; agents are edited in
the 角色 tab of the right rail.

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

* feat(editor): restyle agent panel to Design B accordion (课堂阵容)

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

* fix(editor): cross-review — persona draft sync, scoped preset re-id, AI-tab gating, dead-code cleanup

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

* fix(editor): avatar click expands card, editable-name affordance, add-button copy

* fix(editor): cross-review — drop blur-level undo coalescing, sync playback selection to edited roster

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

* style(editor): prettier format agent panel files (CI fix)

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-01 02:04:28 -04:00
wyucandClaude Opus 4.8 7c19ab8f1e feat(dsl): JSON Schema artifacts + pure validators (#787 Part B-1) (#817)
* feat(dsl): add ACTION_TYPES set + isActionType guard

A frozen set of every valid ActionType plus a pure membership guard,
mirroring the PPT_ELEMENT_TYPES / isPPTElementType idiom in guards.ts.
Used by the new validators; useful standalone for narrowing untrusted
action.type values.

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

* feat(dsl): build-time JSON Schema codegen for stage/scene/action

TS types stay the single source of truth; ts-json-schema-generator (a
devDependency, build-only) emits dist/schema/{stage,scene,action}.schema.json,
shipped under the new ./schema/* export. Serves non-TS / bring-your-own-validator
consumers without adding a runtime dependency.

JSON Schema is not generic, so schema-roots.ts provides a concrete
Scene<Action, SceneContent> entry point (kept internal, not re-exported).
schema.test.ts compiles the generated schema with ajv (devDep) to prove the
artifact is real and self-contained on a clean checkout.

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

* feat(dsl): add pure structural validators (stage/scene/action)

validateStage / validateScene / validateAction: hand-written, zero-dependency,
fail-loud structural checks layered on the existing guards. They verify
discriminants, required fields, and known nested discriminants — a gate for
untrusted input (LLM/agent output) and persistence boundaries. Exhaustive
per-field validation is delegated to the shipped JSON Schema. Error-collecting
ValidationResult reports every issue with a JSON-pointer-ish path.

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

* docs(dsl): document the runtime schema + validator layer

Add a Runtime layer section covering the two complementary consumption modes
(schema-as-data via @openmaic/dsl/schema/* and the zero-dep validate* gate),
the validate.ts module row, the build:schema step, and tick the JSON Schema
roadmap box.

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

* fix(dsl): harden validators per cross-review (exhaustiveness, discriminant agreement)

Addresses cross-review findings on the validator layer:

- Add compile-time exhaustiveness assertions for ACTION_TYPES and SCENE_TYPES.
  `satisfies` only proved each entry is valid, not that the tuple covers the
  whole union — a newly-added Action/Scene variant could silently be rejected
  by the validators. The assertion now fails the build on drift.
- validateScene cross-checks that scene.type agrees with content.type; a
  slide-typed scene carrying quiz content (or vice versa) is now rejected.
- Move SCENE_TYPES + add an isSceneType guard to stage.ts, next to SceneType,
  matching the PPT_ELEMENT_TYPES / isPPTElementType idiom in guards.ts; validate.ts
  reuses them instead of re-encoding the union locally.
- Make the validators' scope honest in the doc comment + README: they check the
  structural envelope, known discriminants, and scene/content agreement; they do
  NOT exhaustively check per-variant fields, and app-side interactive/pbl content
  is validated only at the envelope level (exhaustive checks → the JSON Schema).
- Note the contract-owned content scope in schema-roots.ts.

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

* perf(dsl): reuse one schema generator + harden direct-invocation guard

Addresses cross-review findings on the codegen path:

- gen-schema.mjs built a fresh ts-json-schema-generator (full TS program parse)
  per root type; build one generator over the tsconfig program and reuse it for
  every createSchema call — parses once, identical per-type output.
- Harden the `invoked directly` check to compare realpaths, so a symlinked
  invocation (bin shim / pnpm link) still runs main() instead of silently
  emitting no schema.
- schema.test.ts generates all roots once at module scope, then only compiles
  per case — no repeated heavy codegen.

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

* fix(dsl): bind scene schema discriminants + pin node-20-compatible generator

Addresses round-2 cross-review (codex) findings:

- scene.schema.json now ties scene `type` to the matching `content.type`.
  SerializedScene became a discriminated union (SlideScene | QuizScene via
  `Omit<Scene<…>, 'type'> & { type }`), so the generator emits a const-narrowed
  discriminant per branch. Previously the schema accepted e.g.
  { type: 'quiz', content: { type: 'slide', … } } even though validateScene
  rejects it — the schema is now consistent with the validator. Locked with a test.
- Pin ts-json-schema-generator to ~2.4.0. ^2.3.0 resolved to 2.9.0, which
  declares `node >=22`, conflicting with the repo's `engines.node >=20.9.0`;
  2.4.x supports node >=18, keeping the build runnable on every supported Node.

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

* style(dsl): prettier formatting + drop unused ts-expect-error for green CI

- Apply repo Prettier (`pnpm check`) formatting to the new/edited files.
- Remove the `@ts-expect-error` on the gen-schema.mjs import: the root
  `tsc --noEmit` (which compiles test files; allowJs infers the .mjs exports)
  flagged it as an unused directive (TS2578). Plain comment retained.

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

* refactor(dsl): align validators + schema to the public Scene type (review)

Addresses cosarah's review: the validator, the JSON Schema, and the public TS
type had drifted into three different notions of a valid scene. Realign them so
the TS types stay the single source of truth, and position the two runtime
layers honestly.

- schema-roots.ts: revert SerializedScene to the plain default Scene<Action,
  SceneContent>. The schema no longer encodes a stricter type/content binding
  that the public Scene type doesn't express (the constraint had effectively
  moved into a hidden internal type).
- validate.ts: drop the scene.type<->content.type agreement check (the public
  Scene does not bind them, so neither does the validator); restrict content to
  the contract-owned kinds (slide/quiz), matching SceneContent and the schema,
  so validator and schema no longer disagree on interactive/pbl content.
- Reposition docs (validate.ts header + README): the JSON Schema is the
  authoritative, exhaustive per-field validator (it checks variant fields like
  an action's elementId) for trust boundaries; validate* are a cheap,
  zero-dep structural pre-check and a strict subset of the schema — not a
  separate or stricter gate.
- Tests updated to lock the realigned behavior.

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

* refactor(dsl): bind scene type<->content in the public Scene contract (review)

cosarah's review showed the scene type<->content agreement is a real, load-bearing
invariant: consumers branch on `scene.type` and then read `scene.content` as the
matching shape (e.g. `stage-api-canvas` casts `content as SlideContent` after a
`scene.type === 'slide'` check; `complete-summary` counts by `scene.type` and reads
`content as QuizContent`). A validator/schema that blesses mismatched pairs is unsafe.
So tighten the *exported* contract rather than loosening the runtime checks to match
a hole — and make the pure validators the authoritative, relied-upon boundary.

DSL contract:
- `Scene` becomes a distributive discriminated type binding `type` to `content['type']`
  (`SceneCore<TAction> & { type: TContent['type']; content: TContent }` distributed over
  TContent). TS itself now rejects mismatched scenes; generic widening still works.
  Extract `SceneCore` for the kind-independent fields.
- `validateScene` re-establishes the type<->content agreement check; scene `type` is
  the contract's slide/quiz; `scene.schema.json` (from the spelled-out discriminated
  `SerializedScene`) binds `type` to content per branch. Type, validator, and schema
  now describe the same thing.
- `validateAction` checks each variant's required fields (e.g. spotlight.elementId,
  discussion.topic) — closes the false-positive gap. A test pins the hand map to the
  generated schema (the TS-derived source of truth) so it cannot drift.
- Reposition docs: `validate*` is the zero-dep in-process boundary producers rely on;
  the JSON Schema is the cross-language mirror + exhaustive value checker.

App consumers (the sites the discriminated Scene exposes):
- Add `ScenePatch` (non-distributive partial) + `makeScene(core, content)` helper to
  lib/types/stage.ts — one isolated cast, type derived from content. Patch-typed
  signatures (`updateScene`, `applyScenePatchInSync`, regenerate plan) and scene
  rebuilds (`stage-api-scene`, store `updateScene`, `migrateScene`, stage-storage
  deserialize) route through these. Closes two latent desync foot-guns.

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

* fix(dsl): validate action field types + migrate scene.update; bidirectional drift-guard (review)

Round-2 review of the contract-tightening change (codex + Claude):

- validateAction now checks each variant-required field's TYPE, not just presence
  (`{ type:'spotlight', elementId: 123 }` is rejected — elementId must be a string).
  ACTION_REQUIRED_FIELDS carries a runtime kind per field.
- The schema lockstep test is now bidirectional and type-aware: it asserts a
  fully-typed action is accepted (catches the map OVER-listing an optional field),
  every required field flags when missing (under-listing), and flags when present
  but mis-typed — all keyed off the generated schema's field names + types.
- Migrate the public `stageApi.scene.update()` to ScenePatch + makeScene (the
  create() path was done last commit; update() still did a raw spread that could
  desync type<->content — masked from tsc only because StageStore.setState is typed
  `any`). Now both public scene-construction paths rebind type to content.

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

* fix(api): keep scene.create's params.type authoritative over content.type (review)

cosarah's review: `CreateSceneParams` exposes an independent `type` plus
`content?: Partial<SceneContent>`, and after switching create() to makeScene()
(which derives the scene kind from content.type), a call like
`create({ type: 'slide', content: { type: 'interactive', ... } })` would silently
become an interactive scene — params.type ignored — and mix slide defaults into it.

Reject a `content.type` that disagrees with `params.type`, and pin the merged
content's `type` to `params.type` so a partial content override can't flip the
scene's discriminant. params.type stays authoritative. Adds a test both ways.

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 01:04:34 +08:00
wyucandClaude Opus 4.8 b93434ae2c feat(agent-edit): multi-session conversation history for the AI editor (#801)
* feat(agent-edit): multi-session conversation history for the AI editor

The "Edit with AI" panel kept only one thread per stage in localStorage, and
"New conversation" deleted it, so previous conversations were unrecoverable.

Add a per-stage session list backed by IndexedDB (Dexie v12 `agentEditSessions`):

- "New conversation" now archives the current session and starts a fresh one
  instead of deleting it; a history popover in the panel header lists past
  sessions (auto-titled from the first user message) to switch back to or delete.
- One-time migration of the existing single-thread localStorage entry.
- The active session id is remembered per stage so a refresh restores it,
  including a just-cleared empty session (clean slate survives reload).
- saveSession preserves the original createdAt and tombstones deleted ids so an
  in-flight save can't resurrect a deleted session; switching stages drops the
  previous thread synchronously so it can't leak into the new stage.
- Sessions are pruned to a soft cap per stage and removed when a course is
  deleted.
- i18n for all 8 locales; unit tests for title derivation and the store.

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

* fix(types): drop redundant Window.SpeechRecognition augmentation conflicting with @assistant-ui/core

@assistant-ui/core's speech adapter declares Window.SpeechRecognition and
webkitSpeechRecognition globally as SpeechRecognitionConstructor. Three files
(prompt-input, use-audio-recorder, use-browser-asr) re-declared them as `any`,
which conflicts (TS2717) once that adapter's types are pulled into the program,
and the constructed instance then lacks the event handlers the code sets
(TS2339/2551). Rely on the global declaration and cast the instance to the local
rich shape. Takes `tsc --noEmit` back to zero.

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 23:08:23 -04:00
wyuc 7cb129183d chore(packages): publish the @openmaic/* SDK family to npm (#778) (#780)
* chore(packages): publish the @openmaic/* SDK family to npm (#778)

Prepares the @openmaic/{dsl,renderer,importer} family for its first npm
publish, and moves the SDK packages onto the @openmaic scope.

Why the scope move: the @maic org name is unavailable on npm (an unscoped
`maic` package already holds the name), so @maic/* is not claimable. @openmaic
matches the project name, the scope is free, and the repo already ships an
@openmaic/docs package — so the SDK family now lines up with that convention.

- rename @maic/{dsl,renderer,importer} -> @openmaic/* across packages, the
  workspace glob, the package dir, and all import sites; lockfile regenerated
- renderer: add publishConfig (public, registry.npmjs.org) — was missing, so
  a scoped publish would default to the wrong registry / restricted access
- importer: add a files allowlist (dist, README, LICENSE) and drop the
  fragile .npmignore blacklist that shipped src; add an exports map so ESM
  consumers resolve dist/index.js instead of falling back to the .cjs main
- all three: add a prepublishOnly build (+ test/typecheck) guard so a publish
  can never ship a stale or empty dist
- add a tag-triggered publish workflow with npm provenance, pinned by name to
  the three @openmaic packages so the vendored forks (mathml2omml, pptxgenjs)
  are never published

Refs #778, #720 (Phase 1).

* fix(packages): address cross-review on the @openmaic publish prep

Cross-review (Claude /code-review + codex) on this PR surfaced:

- renderer's advertised CJS entry was broken: it keeps @openmaic/dsl external
  and imports a runtime enum from it, but dsl is ESM-only (no `require`
  condition), so `require('@openmaic/renderer')` would throw
  ERR_PACKAGE_PATH_NOT_EXPORTED. Make renderer ESM-only: drop the `.cjs`
  rollup output, `main` now points at the ESM build, and the `require`
  conditions are removed from `exports`. (importer is unaffected — it bundles
  dsl, so its CJS build still works.)
- prepublishOnly re-ran the test suite during `pnpm -r publish`, so a flaky
  test after dsl had already published gave a non-atomic partial release.
  Reduce prepublishOnly to a build-only guard (never ship stale/empty dist)
  and move the real test/typecheck gate into the workflow, before any publish.
- document that an @openmaic/* tag publishes the whole family via `pnpm -r`
  (pnpm skips already-published versions); the tag is a release marker, not a
  per-package gate.

Verified: dsl + renderer + importer build; renderer emits ESM only (0 .cjs),
all exports entries resolve; `npm pack` ships dist + README + LICENSE with no
src leak; frozen-lockfile passes.

Refs #778.

* style: reflow @openmaic/dsl type imports past print-width after rename

The @maic -> @openmaic rename lengthened two single-line type imports past
prettier's 100-col width; prettier --check flagged them. Pure formatting.

Refs #778.

* docs(importer): mark @openmaic/importer browser-only (cr-loop accepted limitation)

codex cross-review flagged that the published @openmaic/importer throws
`XMLHttpRequest is not a constructor` when loaded in a pure Node process —
its rollup build is browser-targeted (`nodeResolve({browser:true})` + a
browser pdf.js build). The app only consumes it client-side ('use client'),
so this is by design. Document it as an accepted limitation: prominent
browser-only note in the README and a `browser` field in the manifest.

Refs #778.
2026-06-24 15:12:23 +08:00
wyuc 1d1ce80e04 feat(maic-agent): editor-agent line (v0) — Pro-mode "Edit with AI" (#777)
Promote the editor-agent line to main: read-then-act editor agent (read_scene_content, regenerate_scene, regenerate_scene_actions, edit_interactive_html), a routable maic-agent model stage, the agent sidebar UI, interactive-scene runtime-error capture, and the timeline/script editor. Cross-reviewed and smoke-verified; see #777.
2026-06-23 16:56:37 +08:00
wyucandClaude Opus 4.8 13b42206f6 fix(generation): don't regenerate a deleted slide on a finished deck (#769)
Deleting a slide in Pro/Edit mode removed it from `scenes` but left its
entry in the persisted `outlines` plan. On the next classroom mount the
resume-generation pass treats the now-missing `order` as a pending
outline and regenerates the slide, appending it as the last page.

Rather than prune outlines by `order` (which is rebalanced by Pro-mode
insert/duplicate/reorder, so it is not a stable key and would mis-target
or break delete-undo), gate resume on a persisted `generationComplete`
flag:

- The flag is recorded (alongside outlines, in the stageOutlines record)
  when generation finishes — both generateRemaining completion paths, the
  retry-to-completion path (markGenerationCompleteIfDone), and the
  already-materialized-at-mount path — and restored on load. It is written
  only after a verified scene flush, so it never outruns the deck's scenes.
- A finished deck is frozen for editing: loadFromStorage no longer derives
  pending placeholders from orphaned outlines, the classroom resume effect
  skips generateRemaining, media resume is filtered to materialized
  outlines, and isCourseComplete honours the flag — so a deleted slide
  neither regenerates nor leaves a phantom pending page.
- Decks generated before the flag existed self-heal on load (every outline
  has a scene ⇒ inferred complete). deleteScene additionally records
  completion when deleting from a deck that is complete right now, so the
  "Course complete" end page and resume-suppression survive even when the
  flag was never persisted (e.g. edited without a reload).

`outlines` are never pruned, so order-rebalancing and delete-undo are
unaffected. Genuinely interrupted generations (not complete) still resume.

Fixes #768

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 12:23:17 +08:00
wyucandClaude Opus 4.8 4dce1f7414 refactor(types): import the slide DSL directly from @maic/dsl; drop the shim (#738)
#707 left lib/types/slides.ts as a thin `export * from '@maic/dsl'` shim so the
~100 existing `@/lib/types/slides` import sites kept working unchanged. Point
them at `@maic/dsl` directly and delete the shim, so the app consumes the
package contract with no indirection and the legacy module path is gone.

Pure module-specifier migration — every symbol imported from
`@/lib/types/slides` was already a re-export of the same `@maic/dsl` symbol, so
behavior is identical. Also redirects the two `lib/types` relative importers
(`./slides`), the e2e fixture's relative import, and the dynamic
`import('../types/slides').Slide` type alias in stage-storage; merges the now
same-module import pair in slide-edit-elements.

Verified: tsc --noEmit clean, eslint clean, vitest 804/804, next build OK,
e2e (Playwright) 19/19.

Part of #720 (Phase 1).

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 15:04:06 +08:00
RongHuiQiang 170b7df98f merge upstream/main into vocational task engine branch 2026-06-09 16:20:35 +08:00
RongHuiQiang e6debb093b fix(vocational): address review feedback 2026-06-09 11:30:38 +08:00
3ccd5da6da feat(generation): opt-in parallel scene-content generation (#660)
* feat(generation): opt-in parallel scene-content generation

Scene generation was strictly serial — content -> actions -> TTS, one
outline at a time — so an N-scene classroom paid N x (content + actions
+ TTS) of wall-clock latency, which dominates the post-outline wait.

Add an opt-in hybrid two-phase path (#572):

- Phase 1 fetches scene *content* concurrently (bounded). Content is the
  only per-scene step independent of cross-scene state, so it is safe to
  parallelise; a content failure marks just that outline and does not
  pause the batch.
- Phase 2 keeps the existing in-order actions + TTS loop, so
  previousSpeeches threading, ordered addScene, the abort/epoch guards,
  and the pause-on-failure UX are all unchanged.

Gated by a server-side PARALLEL_SCENE_CONCURRENCY (default 0 = off,
clamped to 10), surfaced to the client through the existing
/api/server-providers response. With it unset the content map is null
and the loop is byte-for-byte the original serial path, so out-of-box
behaviour is unchanged.

The bounded worker pool is extracted to lib/utils/concurrency.ts
(mapWithConcurrency, with an early-stop hook for abort/epoch) and
unit-tested; getParallelSceneConcurrency env parsing/clamping is tested
too.

Closes #572

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

* refactor(generation): pipeline content fetches instead of a barrier

Address @wyuc's review on #660. The previous version awaited the whole
bounded content pool before Phase 2, so for N > C scenes the user saw the
first scene, then a stall on a single page for the entire content phase,
then a burst — perceived as worse than serial despite the wall-clock win
(the gap scales with ceil((N-1)/C)).

Pipeline instead: lazyBoundedMap starts the content fetches (<= C in
flight) and returns one promise per outline immediately; the serial loop
awaits them in order, so each resolves as soon as its own content is ready
(usually already done, hidden behind the previous scene's actions/TTS). The
first scene now paints after content(1)+actions(1)+TTS(1) — same as serial
— with the full concurrency benefit and no stall.

Also from the review:
- a content fetch can no longer take its siblings down: an unexpected throw
  is caught and returned as a failure result, routed through the same
  mark-failed path as the serial loop (symmetric);
- drop the cosmetic Phase-1 setCurrentGeneratingOrder — the loop already
  sets it per outline;
- note the intentional belt-and-suspenders clamp;
- concurrency.test.ts asserts <= limit (not == limit), and gains
  lazyBoundedMap tests including the no-barrier property.

mapWithConcurrency is kept as a thin await-all wrapper over lazyBoundedMap.

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

---------

Co-authored-by: ly-wang19 <ly-wang19@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: wyuc <wang-yc24@mails.tsinghua.edu.cn>
2026-06-08 07:44:33 -04:00
d7c0d80afe feat(tts): per-agent auto-voice quality + register-once timbre stability (#670) (#672)
* feat(tts): voxcpm voice-design types + deterministic voice id helpers (#670)

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

* feat(tts): emit + persist per-agent voiceDesign descriptor (#670)

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

* feat(tts): voxcpm voice registration backend client (#670)

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

* feat(tts): voxcpm-voice ensure/register endpoint (#670)

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

* feat(tts): client auto-voice registration + reference-clip cache (#670)

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

* feat(tts): reference registered voice id in vLLM-Omni speech (#670)

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

* feat(tts): thread voiceDesign + backend through tts call sites (#670)

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

* fix(tts): match vLLM-Omni voice registration contract (#670)

Live e2e against the real backend revealed the assumed multipart contract was
wrong: POST /v1/audio/voices needs name + consent + audio_sample (not
voice_id/file), and there is no per-name GET (405) — existence must list
/v1/audio/voices and check membership.

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

* refactor(tts): make auto-voice register-once pattern provider-neutral (#670)

The descriptor + register-once/reference-by-id pattern is not VoxCPM-specific.
De-couple it into a provider-neutral seam so other registration-capable
providers (ElevenLabs/MiniMax/Doubao voice cloning, …) can plug in:

- lib/audio/voice-design.ts: VoiceDesign + buildVoiceDesignPrompt /
  normalizeVoiceDesign / getDeterministicVoiceId (neutral 'auto-<hash>' id,
  namespaced by providerId).
- lib/audio/voice-registration.ts: VoiceRegistrationAdapter interface +
  providerId->adapter registry + supportsVoiceRegistration.
- lib/audio/voice-registration-client.ts: neutral ensureRegisteredVoice() +
  IndexedDB clip cache.
- app/api/generate/voice (replaces .../voxcpm-voice): dispatches by providerId.
- voxcpm-registration.ts becomes the VoxCPM adapter (sole registered provider).
- AgentConfig.voiceDesign + DB cache table renamed neutral.

Behavior-preserving; VoxCPM-specific bits (inline (prompt)text, backend kinds,
capability gate) stay in the voxcpm modules. Full suite green (674).

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

* fix(tts): build the voice clip Blob from an inline Uint8Array (#670)

A factored-out helper returning Uint8Array widened to Uint8Array<ArrayBufferLike>,
which next build (stricter than bare tsc) rejects as a BlobPart. Inline the
buffer like the rest of the repo does.

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

* feat(tts): managed TTS providers resolve model server-side (#670)

A server-managed TTS provider's model was still client-driven, but the
managed-provider settings UI hides the model field — so a backend whose model
id isn't the client default (e.g. VoxCPM/vLLM-Omni serving the full model path)
always 500'd. resolveTTSModel() makes the model authoritative from server
config (${PREFIX}_MODELS, first entry) for managed providers, like key/baseUrl;
unmanaged/unconfigured providers keep the client model unchanged. Used by the
tts and voice routes.

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

* fix(tts): single agent-voice resolver + stable teacher narration voice (#670)

Route all TTS voice resolution (narration, discussion, preview) through one
resolveAgentVoiceOptions(agent, ...) that reads the agent profile and registers
+ references the voice by id. Eagerly warm up generated agents' voices on save.

Critically fixes the teacher narration drifting (male/female jumps): the registry
is always seeded with DEFAULT_AGENTS, so the old narration lookup
find(role==='teacher') returned the default teacher (no voiceDesign) instead of
the generated one — so narration never registered a voice and fell back to the
inline prompt. pickNarratorAgent() now prefers the teacher carrying a voiceDesign.

Also: drop language from the deterministic voice id (descriptor already encodes
it) so narration (directive) and discussion (locale) resolve the same id; log
the effective registeredVoiceId. Regression tests for pickNarratorAgent.

Verified e2e: agent-profiles -> 1 voice registration -> 16 narration TTS all
referencing the same registeredVoiceId; voice present on the backend.

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

* feat(tts): fall back to persona as the voice seed for agents without a voiceDesign (#670)

Preset/default agents carry no LLM voiceDesign. Rather than add a static field,
derive the bootstrap descriptor from the agent's persona when voiceDesign is
absent, so they still register one stable reference voice (stable-but-generic;
persona is not a vocal spec). Generated agents keep their LLM voiceDesign.
Also hardens replay: stage snapshots drop voiceDesign but keep persona.

Unit-tested: resolveAgentVoiceOptions uses real voiceDesign when present, persona
otherwise.

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

* fix(tts): match Auto Voice by its localized label in the agent voice picker (#670)

The picker filtered on voice.name ('Auto Voice'), but Auto Voice is shown via
its localized label (自动音色). Searching '自动' returned '没有匹配音色'. Match
the localized label for the Auto Voice option so it's findable in any language.

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

* fix(tts): address code-review round 1 (#670)

- strip parentheses in the voice-design prompt so a paren in the descriptor/
  persona can't corrupt the (prompt)text bootstrap delimiter
- gate POST /api/generate/voice on isServerTTSProviderDisabled (#665): a
  force-disabled provider was off for the tts route but not this sibling
- check voiceExists before re-registering a client-cached clip, so a cached
  voice that's still live isn't needlessly re-uploaded every session
- dedup concurrent ensureRegisteredVoice calls via an in-flight promise map
  (eager warm-up + first utterance no longer double bootstrap/register)
- warm up only the narrator (teacher), not every generated agent, to avoid
  synthesizing voices at save time for agents that may never speak
- collapse the duplicated vLLM-Omni speech payload into one object

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

* chore(tts): prettier formatting (#670)

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

* fix(tts): key auto-voice memo by (voiceId, backend) so a base-URL change re-registers (#670)

Addresses review (cosarah): registeredThisSession/inFlight were keyed by voiceId
alone, so switching the VoxCPM base URL mid-session made ensureRegisteredVoice
short-circuit and return an id registered only on the old backend — TTS then sent
that stale registeredVoiceId and skipped the inline fallback, failing with
voice-not-found on the new backend. Memo key now includes the base URL; the
IndexedDB clip cache stays keyed by voiceId (the reference clip is
backend-independent and reused to re-register elsewhere). Regression test added.

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

* fix(tts): include API key in the auto-voice memo key (#670)

Addresses review (cosarah, round 2): memoKeyFor was keyed by (voiceId, baseUrl)
but not the API key. Registration/existence checks and speech calls are
auth-scoped, so switching account/key on the same base URL could reuse a
registeredVoiceId from the old credentials and skip re-validation. Memo key now
includes the API key (in-memory only, never persisted/logged). Regression test
covers the key-switch case.

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

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: 杨慎 <117187635+cosarah@users.noreply.github.com>
2026-06-08 14:37:49 +08:00
9248c385f4 fix(audio): revoke blob URL when audio.play() rejects (#652)
In the IndexedDB playback branch, the object URL created via
URL.createObjectURL is revoked only inside the 'ended' listener. When
audio.play() rejects (autoplay policy, decode error, interrupted load)
that listener never fires, so the blob URL — and its backing audio blob —
leaked for the lifetime of the document. During lecture playback play() is
called once per speech action, so repeated failures (e.g. autoplay blocked
before the first user gesture, or rapid stop/skip) accumulate.

Revoke the URL when play() rejects, then re-throw. Adds a regression test.

Co-authored-by: ly-wang19 <ly-wang19@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: wyuc <wang-yc24@mails.tsinghua.edu.cn>
2026-06-07 11:00:02 -04:00
wyuc 22c637c0b6 Fix generated video thumbnails (#546)
* fix video thumbnails for generated media

* add video affordance to thumbnails

* address video thumbnail review findings
2026-05-10 16:11:41 +08:00
wyuc 2dff6d10be feat: add generated video manifest refs (#540)
* feat: add generated video manifest refs

* style: format video manifest changes

* fix: address video manifest review feedback
2026-05-09 20:56:31 +08:00
wyuc 5e12ad892c [codex] add VoxCPM2 TTS provider (#496)
* add voxcpm2 tts provider

* address voxcpm review feedback

* translate voxcpm locale strings

* narrow toolbar model pill

* refine toolbar media controls

* address voxcpm review followups
2026-04-26 23:45:23 +08:00
wyucand杨慎 e613b7578f [codex] add per-model thinking config (#494)
* add per-model thinking config

* Refine thinking model controls

---------

Co-authored-by: 杨慎 <117187635+cosarah@users.noreply.github.com>
2026-04-26 19:41:10 +08:00
f86c0482dc feat: end-of-course completion page + persistent quiz state (#484)
* refactor(quiz): extract grading helpers into lib/quiz/grading

* feat(classroom): add summarizeScenes aggregation helper

* feat(i18n): add classroomComplete translations

* feat(classroom): add ClassroomCompletePage component

* feat(stage): render ClassroomCompletePage when course finishes

* feat(stage): show 'Course complete' slot in sidebar

* chore: apply prettier formatting

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

* feat(classroom): richer completion visuals — confetti, gold trophy, stats cards

- Replace lucide Trophy with custom gradient SVG (3x larger, base + stem + star)
- Add one-shot confetti burst (55 particles, 7-color palette, no new dep)
- Add looping sparkles + pulsing halo around trophy
- Redesign stats from thin icon row into metric cards with count-up numbers
- Upgrade quiz block to gradient card with animated gold progress ring
- Add gradient ribbon badge above title, bg-clip gradient title
- Sidebar thumbnail: amber gradient + sparkles + larger filled trophy
- Respect prefers-reduced-motion (skips confetti/sparkles/count-up)

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

* fix(stage): show course-complete title in header; tone down sidebar thumbnail

- Header top-left was showing loading fallback on pending slot; now renders
  t('stage.courseComplete') when isCourseComplete && isPendingScene
- Sidebar thumbnail was too saturated next to neutral slide thumbnails; back
  to soft amber-50 base with a subtle radial glow, smaller muted sparkles,
  and colored amber-500 trophy (no more filled white on gradient)

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

* fix(classroom): persist submitted quiz answers for completion page

Previously handleSubmit cleared the quizDraft cache, leaving nothing for
the completion page to read — score always showed 0/N. Now submit also
writes to a separate quizAnswers:<sceneId> key that is not cleared by
the normal in-progress draft lifecycle.

- quiz-view: on submit, mirror answers into quizAnswers:<sceneId>
- quiz-view: on retry, remove that persistent key too
- classroom-complete: read quizAnswers first; fall back to quizDraft so
  an unsubmitted attempt still shows partial progress rather than 0

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

* feat(quiz): persist submitted quiz state across unmounts

Before: remounting a quiz scene after submit restarted it from 'not_started'
because the draft cache was cleared at submit time. Reopening meant redoing
everything.

Now on mount, read quizAnswers:<sceneId> + quizResults:<sceneId> and restore:
- Both present → jump to 'reviewing' with graded answers and feedback
- Only answers (mid-grading unmount) → 'answering' with answers pre-filled
- Neither → fall through to existing draft-restore path

Results are persisted when the grading useEffect transitions to 'reviewing',
and both keys are cleared together by handleRetry.

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

* fix(home): prevent horizontal shift when recent classrooms expand

Expanding recent classrooms could grow the page past the viewport height,
making a vertical scrollbar appear. Because the scrollbar takes layout
width, the centered header and trigger row jumped horizontally. Reserve
the scrollbar gutter on <html> so the gutter is always present and layout
stays stable across expand/collapse.

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

* fix(home): force always-visible scrollbar to eliminate expand/collapse shift

scrollbar-gutter: stable alone wasn't enough (browser/config dependent).
Adding overflow-y: scroll on html forces the vertical scrollbar to always
occupy layout width, so expanding recent classrooms past the viewport
no longer shifts the centered trigger row.

Trade-off: home page now always shows a scrollbar track, even when
content fits viewport. Acceptable cost for stable layout.

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

* refactor(quiz): extract persistence module + sweep localStorage on delete

Addresses code review on #484:

1. New lib/quiz/persistence.ts centralizes the three per-scene localStorage
   keys (draft / answers / results) behind readSubmittedState,
   readAnswersForSummary, writeSubmittedAnswers, writeSubmittedResults,
   clearSubmitted, clearAllForScene. quiz-view and classroom-complete now
   both consume this module instead of hand-rolling localStorage calls,
   removing the schema-coupling between the two components.

2. deleteStageData now collects scene ids before deletion and sweeps the
   matching quiz persistence keys, fixing the storage leak when a
   classroom is deleted.

3. ClassroomCompletePage gets role=status / aria-live=polite /
   aria-label for screen readers to announce the completion moment.

4. Tests: 11 new unit tests covering the persistence module
   (read/write/clear roundtrips, draft fallback, corrupt-JSON
   tolerance, scene-isolation).

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

* fix(classroom): address review on PR #484 (a11y, motion, coupling)

Addresses cosarah's review:

1. prefers-reduced-motion now fully honored — wrap ClassroomCompletePage
   in MotionConfig with reducedMotion='always' when the OS preference is
   on. This neutralizes the previously-uncovered animations (trophy
   float repeat:Infinity, halo pulse repeat:Infinity, page/cards/trophy
   entrance fade+scale+spring).

2. Move role='status' off the noisy outer wrapper; replace with a single
   sr-only span carrying the title text so screen readers get one clean
   announcement instead of every count-up tick. Outer is now a plain
   <section aria-label=...>.

3. Wrap ClassroomCompletePageConnected in a motion.div inside
   AnimatePresence so leaving the pending slot fades instead of snapping
   (matches the loading-overlay branch).

4. quiz-view's draft cache key now uses the draftKey() helper exported
   from lib/quiz/persistence so the prefix has one source of truth (no
   silent desync if the schema evolves).

5. isCourseComplete now requires scenes.length === outlines.length, so
   a partial generation with failed outlines doesn't falsely trigger.

6. aria-hidden on decorative motion wrappers (Confetti, Sparkles,
   trophy entrance) for cleaner SR experience.

7. Comment on summarizeScenes useMemo explaining the mount-fresh
   assumption.

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

* chore: prettier-format review-fix changes

CI's format check (pnpm check) failed because the previous commit's
changes weren't run through prettier locally before push.

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: 杨慎 <117187635+cosarah@users.noreply.github.com>
2026-04-25 16:17:41 +08:00
wyucandClaude Opus 4.7 e76d510009 feat(home): badge Deep-Interactive classrooms on card thumbnails (#478)
* feat(stage): add optional Stage.interactiveMode flag

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

* feat(db): add optional interactiveMode column on StageRecord

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

* feat(stage-storage): pass interactiveMode through save/list

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

* feat(generation): persist interactiveMode when creating a classroom

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

* i18n: add classroom.deepInteractive across all locales

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

* feat(home): show Deep Interactive badge on classroom cards

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

* feat(home): move Deep Interactive badge into thumbnail corner

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

* refactor(home): slim Deep Interactive badge to icon-only chip

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

* fix(home): use Tooltip component for Deep Interactive badge

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

* refactor(home): move Deep Interactive badge to bottom-left corner

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

* fix(home): tighten Deep Interactive tooltip placement

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

* fix(home): close gap between Deep Interactive tooltip and badge

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

* fix(home): edge-align Deep Interactive tooltip without overlap

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

* refactor: address code review

- Reuse toolbar.interactiveModeLabel instead of adding a duplicate
  classroom.deepInteractive i18n key (was identical to the existing
  label in zh-CN and semantically the same in the other 4 locales).
- Drop the v0.2.0 version tag from comments (will go stale at release).
- Document on ManifestStage that omitting interactiveMode is intentional
  — imports can't faithfully reproduce the original generation branch.
- Add a brief comment on the Tooltip's negative sideOffset explaining
  why the standard positive offset does not visually close the gap here.

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 22:27:21 +08:00
Shangqing TuandClaude Opus 4.7 c02a607377 feat: interactive mode clean (#461)
* feat: add interactive mode

* feat: add TeacherActions for interactive mode

* fix: better prompt for ultra mode

* feat: upgrade normal mode's interactive generation with 5 widget type and actions

* feat: make interactive mode button clear

* fix: remove unused TeacherOverlay

* fix: resolve PR #458 review feedback

- Revert unrelated infra changes (Dockerfile, docker-compose.yml)
- Fix placeholder strings in generateWidgetContent
- Rename ultraMode to interactiveMode consistently across codebase
- Extract duplicated patchHtmlForIframe to shared utility
- Fix widget store race condition by keying callbacks by sceneId
- Fix teacher-action ID collisions by using descriptive IDs in prompt
- Relax outline prompt constraints for flexibility
- Optimize sidebar thumbnail with IntersectionObserver lazy loading
- Add trailing newlines to modified files

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

* fix: update generateSceneActions call signatures

The function signature changed to individual parameters instead of
options object pattern after cherry-pick.

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

* style: fix prettier formatting

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

* fix: removed extra language arg

* style: fix prettier formatting v2

* fix:  add missing i18n keys

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-19 21:35:30 +08:00
杨慎andClaude Opus 4.6 c75cf6e602 feat: add code element support for whiteboard (#385)
* feat: add CODE element type and wb_draw_code/wb_edit_code action types

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

* feat: implement wb_draw_code and wb_edit_code in ActionEngine

* feat: add BaseCodeElement with Shiki syntax highlighting and animations

* feat: register CodeElement in renderer and add inline action tags

* feat: add wb_draw_code and wb_edit_code to agent action schemas

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

* feat: add code element context to director prompt

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

* feat: add code element guidelines and context to prompt builder

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

* feat: add i18n keys for code element

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

* fix: prevent whiteboard zoom from intercepting code element scroll

* chore: remove unused code element i18n keys

The code.untitled and code.lines keys were added but never referenced
by the BaseCodeElement component. Remove to reduce i18n maintenance
burden and merge conflict surface.

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

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-16 15:57:51 +08:00
杨慎andClaude Opus 4.6 470aa5e42e feat: inline language inference for outline and PBL generation (#412)
* feat: inline language inference in outline generation

Replace manual language selection with automatic inference from user
requirement text. The outline generator now produces a languageDirective
that propagates through the entire generation pipeline.

Key changes:
- Rewrite Language Inference section in outline prompt with decision
  rules for foreign language learning, cross-language PDF, proxy
  requests, and terminology handling
- Reorder pipeline: outlines before agents, so agent profiles can
  use the inferred languageDirective
- Pass languageDirective through scene content/actions generation
- Add SSE streaming of languageDirective from outline generation
- Remove manual language selection UI

Eval test suite:
- 50 test cases covering 9 scenario types: single-language, language
  learning, immersive, explicit instruction, code-switching, minimal
  input, user profiles, cross-language PDF, locale mismatch
- LLM-as-judge evaluation with configurable inference/judge models
- 50/50 pass rate with gemini-3-flash-preview + gpt-4o judge
- Excluded from CI (requires LLM API keys)

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

* chore: remove useless attributes in test case

* feat: pass languageDirective to PBL, remove hardcoded zh-CN/en-US

- Remove pblConfig.language field, use languageDirective from outline
  inference instead
- Delete all Chinese prompt duplicates in pbl-system-prompt.ts,
  agent-templates.ts; keep English templates with languageDirective
  injection
- Remove zh-CN/en-US branches in generate-pbl.ts (initial prompt,
  post-process context, welcome message)
- Pipe languageDirective through scene-generator → generatePBLContent
  → IssueboardMCP → agent templates
- Remove i18n template wrapping from PBL welcome messages in
  pbl-renderer.tsx and use-pbl-chat.ts; use LLM-generated content
  directly to avoid language mismatch when UI locale ≠ course language
- Update pblConfig schema in outline prompt (remove language field)

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

* refactor: clean up legacy language fields, pass languageDirective through buildSceneFromOutline

- Add languageDirective param to buildSceneFromOutline, combining with
  outline.languageNote via buildLanguageText
- Remove dead types: AudienceProfile, StylePreferences, LegacyUserRequirements,
  SceneOutline.language
- Remove Stage.language and all its propagation (storage, stageInfo, prompt-builder fallback)
- Remove GenerateClassroomInput.language and job store inputSummary.language
- Remove unused pdfLanguageSample from outline SSE route

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

* fix: persist languageDirective to IndexedDB, fix TTS preview and dead refs

- Add languageDirective to StageRecord and stage-storage write path
- Add DB v9 migration: convert legacy language locale codes to directives
- Use voice.language for TTS preview text instead of dead localStorage key
- Omit empty ## Language section in PBL agent prompts
- Use JSON.parse for extractLanguageDirective unescape (\uXXXX support)
- Remove dead i18n keys: toolbar.languageHint, pbl.chat.welcomeMessage

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

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 21:49:56 +08:00
Yizuki_Ame e8eba95bd7 feat: add course rename (partial #34) (#58)
* feat: add course rename (closes #34 partially)

- Add `renameStage()` to stage-storage.ts for lightweight IndexedDB update
- Add inline rename UI to ClassroomCard: double-click name or click pencil
  icon on hover to edit, Enter/blur to save, Escape to cancel
- Add i18n keys for rename (zh-CN + en-US)

* feat: add inline rename UI to ClassroomCard in page.tsx

* style: format course rename handler

* fix: improve rename button hover visibility (bg-white/30  bg-black/50)

* fix: address review feedback on course rename

- Move Tooltip to only wrap non-editing <p>, preventing tooltip overlay on input
- Replace hardcoded English toast with i18n key (classroom.renameFailed)
- Replace setTimeout focus with useEffect for reliable input autofocus
- Add blur double-fire guard in commitRename
2026-03-31 21:32:55 +08:00
杨慎andClaude Opus 4.6 6a3bd06dec fix: preserve preset agent selection when switching classrooms (#241)
* fix: preserve preset agent selection when switching classrooms

When switching from an auto-agent classroom to a preset classroom,
the stale generated agent IDs remained in selectedAgentIds because
the load path skipped setSelectedAgentIds when no generated agents
were found. Add a dedicated presetSelectedAgentIds field to persist
the user's preset preference separately from the runtime selection,
so auto classrooms never corrupt the user's saved preset choice.

Closes #239

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

* style: format settings.ts with prettier

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

* refactor: persist agent IDs in stage instead of global settings

Replace the global presetSelectedAgentIds approach with per-stage
agent storage. Each classroom now saves its selected agent IDs into
stage.agentIds at creation time, and restores them when the classroom
is loaded. This makes agent selection a per-classroom property rather
than a global setting that can be corrupted by auto classrooms.

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

* ci: retrigger CodeQL analysis

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

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 22:08:36 +08:00
fbe8399678 feat(generate): server-side media & TTS generation (#75)
* feat(generate): server-side media & TTS generation in classroom pipeline

When `enableImageGeneration`, `enableVideoGeneration`, or `enableTTS` flags
are passed to /api/generate-classroom, the server now actually generates
media files and TTS audio, persists them to disk, and replaces placeholders
in the classroom JSON with serving URLs.

- Add media serving API route (GET /api/classroom-media/[id]/[...path])
- Add server-side media generation utility (image, video, TTS)
- Wire generation phases into classroom pipeline with progress steps
- Update AudioPlayer to support URL-based playback (server-generated TTS)
- Add audioUrl field to SpeechAction type
- Extend /api/health with capabilities detection
- Pass through feature flags in generate-classroom route
- Update skill docs for optional feature configuration

Closes #49

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

* style: fix prettier formatting

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

* feat(generate): add agentMode support for server-side classroom generation

Allow callers to choose between built-in default agents ('default') or
LLM-generated custom agent profiles ('generate') via a new agentMode
field on GenerateClassroomInput. The resolved agents are injected into
outline, scene content, and scene action prompts via teacherContext.

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

* fix(media): harden download, parallelize media gen, fix TTS concatenation

- Add timeout (2min) and max size (100MB) guard to downloadToBuffer
- Run image and video generation in parallel via Promise.all
- Only split TTS text for MP3 (frame-based); skip for WAV/OGG/AAC
  whose container headers break on naive byte concatenation
- Remove unnecessary type assertion for speechAction.audioUrl

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

* style: fix prettier formatting

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

* fix(media): harden media serving and TTS generation

- Add realpath check to prevent symlink-based path traversal in media route
- Stream files via createReadStream instead of readFile to avoid OOM on large videos
- Truncate TTS text for non-concatenable formats (WAV/OGG/AAC) when exceeding provider limit
- Add 'researching' step to distinguish web search from outline generation progress

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

* fix(tts): split long speech actions instead of concatenating audio bytes

Adopt the same strategy as client-side: split a long speech action into
multiple shorter actions before TTS generation, so each sub-action gets
its own audio file. This avoids the broken byte-concatenation approach
that corrupted non-MP3 formats (WAV/OGG/AAC container headers).

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

* refactor(tts): extract shared TTS splitting logic into lib/audio/tts-utils

Both client-side (use-scene-generator) and server-side (classroom-media-generation)
had duplicated TTS_MAX_TEXT_LENGTH, text splitting, and action splitting logic.
Extract into a shared module to keep them in sync.

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

* chore: remove unused TTS_MAX_TEXT_LENGTH import

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

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: wyuc <wang-yc24@mails.tsinghua.edu.cn>
2026-03-19 14:40:04 +08:00
Yizuki_Ame 43a0c8e39e feat: whiteboard history and auto-save (#40)
* feat: add whiteboard history and auto-save (closes #32)

- New whiteboard-history.tsx: browseable history panel UI
- New whiteboard-history.ts: Zustand store with snapshot stack,
  fingerprint-based deduplication, and restoredKey one-shot guard
- New element-fingerprint.ts: shared fingerprint utility (id+position+size)
- whiteboard-canvas.tsx: auto-snapshot effect that saves the current stable
  state after 2s debounce; unified fingerprint import; useMemo for elements
- index.tsx: pushSnapshot before UI clear; history panel trigger button
- stage.ts: i18n keys for history UI (zh/en)

* fix: address audit findings for whiteboard history

- engine.ts: AI wb_clear now saves snapshot before clearing (audit #3)
- page.tsx: clear whiteboard history on classroom switch (audit #4)

* fix: address review round 2 (P1/P2a/P2b/P3)

- P1: Block restore during clear animation (race condition)
- P2a: Skip no-op restores to avoid stale restoredKey
- P2b: clearHistory() now also resets restoredKey
- P3: Dedicated restoreError i18n key

* fix: fingerprint includes semantic content per element type (P2c)

* fix: address reviewer feedback (fingerprint cache, AI clear label, exhaustive check)
2026-03-18 23:56:11 +08:00
wyucandClaude Opus 4.6 843d33e5cc chore: enforce Prettier formatting and fix lint issues
- Add .prettierignore to exclude vendor packages, lock files, markdown, and YAML
- Update .prettierrc: printWidth 100, singleQuote, trailingComma "all"
- Run Prettier across all source files for consistent formatting
- Fix unused imports (UserRequirements, setTTSProvider)
- Fix eslint-disable comment placement after Prettier reformat

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 16:09:45 +08:00