* 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.5.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.
* test(providers): reconcile the neutrality guard with current main and this fix
Two changes that were each green alone broke together on main: the
provider audit removed the lib/storage barrel and types entry points
and reshaped provider references while the guard still pinned the old
file list and counts. Point the file list at the surviving
lib/storage/client.ts, drop the satisfied sora debt, and update the
extract-document mineru count. The asset-byte-store pg count grows
because the wrapper now forwards the transactional byte methods of the
concrete store, which necessarily names it.
* fix(extract): fail loudly instead of silently falling back to MinerU Cloud
A request that selects the self-hosted MinerU extractor without a
configured base URL used to fall back to MinerU Cloud whenever a cloud
API key was available, sending documents to a third party without the
operator's knowledge. The cloud fallback now requires an explicit
operator opt-in (ALLOW_MINERU_CLOUD_FALLBACK, default off, documented
in .env.example); otherwise the route answers a 422 that names what was
configured (self-hosted MinerU) and what was unavailable (its base
URL), and points at both remedies.
* fix(media): remove the selectable video provider that has no adapter
The Sora entry in the video provider catalog could be selected in the
settings UI and server config, but had no connectivity or generation
dispatch case, so choosing it failed only at execution time. A catalog
entry that cannot execute is worse than an absent one: the entry, its
type-union member, UI names/icons, store defaults, and the VIDEO_SORA
env mapping are removed. A new catalog test pins that every selectable
video provider dispatches to its own adapter in both switches.
* fix(storage): delete the obsolete no-op storage provider abstraction
getStorageProvider() unconditionally returned a NoopStorageProvider and
the module swallowed every operation into silence, so any caller
believing it had storage got neither bytes nor an error. No real caller
exists anywhere in the repo (only lib/storage/client.ts, a separate
module with a documented null contract and a live caller, remains). The
dead entry point, its type file, and the no-op provider are deleted; a
test pins the removal and that the real client upload helper is kept.
Cleanup runs after a job reaches its terminal status, so asserting
access(dir) rejects immediately after waitForJob raced the removal and
failed intermittently in CI.
* feat(server): validate model routing config at startup and deprecate bare model ids
* fix(server): keep the bare-id deprecation warning out of request paths
parseModelString is reachable with request-controlled strings, so warning
there lets clients drive log volume and grow the dedupe set without bound;
the warning now fires only from boot-time validation of config-derived
sites, whose surface is finite. DEFAULT_MODEL missing-key wording no longer
claims requests will fail (unrouted resolution honors client keys), and a
keyless provider with a default base URL is not flagged as unconfigured.
* feat(media): resolve image, video, and ASR models from server config
The image generation chain never consulted the server-side
IMAGE_<PREFIX>_MODELS config: the route only forwarded the client
x-image-model header and each adapter silently fell back to a hardcoded
vendor default, so a managed provider could generate with a model the
operator never chose. The provider itself also defaulted to a hardcoded
vendor id when the client sent no preference.
- Add resolveImageModel / resolveVideoModel / resolveASRModel and
resolveServerImageProviderId / resolveServerVideoProviderId to the
server provider config, mirroring how TTS already resolves
server-managed settings.
- Routes resolve provider and model server-side when the client sends
no preference and fail loud with a clear 400 when nothing resolves;
no more hardcoded vendor defaults.
- Adapters require an explicit model via requireModel; connectivity
probes keep a fixed probe payload since they are auth handshakes,
not user-facing generation.
- Tests cover the resolver precedence and the fail-loud paths.
* fix(media): align model resolution semantics across capabilities
resolveImageModel and resolveASRModel let the server pin win
unconditionally, while resolveVideoModel implements allowlist semantics
(client choice wins when it is in the pinned list, otherwise the first
pinned entry). Align image and ASR to the video allowlist semantics so a
client picking any server-pinned model is honored, and update the
resolver docstrings and tests to pin the aligned behavior for all three
capabilities.
The classroom media path has no client model and no HTTP response to
fail loud with: it now falls back to the first catalog model when the
operator pins no _MODELS list, so key-only deployments keep generating
media instead of silently skipping every element via the adapter's
requireModel backstop.
* fix(media): normalize configured model lists and client model ids
* feat(media): convert inline base64 images to pool assets on load (#1153 part 4)
* fix(media): parse data URLs per the fetch spec and harden inline conversion (#1153 part 4)
* fix(media): true byte-bound cap and classroom retry for inline conversion (#1153 part 4)
State and enforce the per-payload cap in estimated DECODED bytes (matching
the extract route's byte bound) with one aggregate oversized warn per pass;
re-run the inline pass on already-committed classroom documents so kept
slots retry on the next open (same save/rollback machinery as the birth
path); move the pool write inside the memo's guarded region so a transient
failure clears the entry and sibling slots retry; correct the conservative-
posture rationale in the docs; pin the safe-direction marker shapes.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* feat(materials): material library manifest and id-addressed generation images (#1153 part 2)
* fix(materials): library-owned references and failure-path parity (#1153 part 2)
* fix(generation): resolve-then-slice vision images and close allocation leaks (#1153 part 2)
* fix(generation): bound vision resolution and make library writes leak-safe (#1153 part 2)
Bound the resolve-with-refill phase with an aggregate 15s budget raced
against every probe plus a 3-consecutive-failure fuse — either stop strips
unresolved and unprobed ids from the mapping and proceeds (text-only at
worst), never failing the request. Restore the original interleaved text
ordering for non-vision runs with a mapping present. Guard the library's
failed-entry-write release with an ambiguous-commit re-read: release only
when the entry provably does not point at the new id, keep it when the
write actually landed, and prefer a recoverable leak over a dangling entry
when the re-read fails. Drop the unused package export.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* chore(generation): bump @openmaic/generation to 0.3.1 (#1153 part 2)
The package's inputs changed (partitionImagesForVision, resolvedVisionImages
option, non-vision ordering fix); the merge-time gate requires a version bump.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* feat(document): cache extraction as derived pool assets with lineage (#1153 part 1)
* fix(document): harden the extraction cache keys and write path (#1153 part 1)
* fix(document): bound alias trust and unblock the extract path from cache writes (#1153 part 1)
* fix(document): keep every extraction-cache failure inside the degrade-to-miss net (#1153 part 1)
Move the lookup's key construction (config fingerprinting included) inside
its guarded region so a fingerprint failure degrades to a cache miss instead
of rejecting the caller's extraction; guard the page-side dedupe fingerprint
the same way (falls back to un-deduped extraction). Pin the remaining review
coverage: malformed/missing createdAt treated stale, the documented
future-dated residual, fingerprint-throw degradation on lookup/write/dedupe,
and supersede leaving the superseded record's assets untouched.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(document): keep server-only extractor implementations out of the client bundle (#1153 part 1)
* fix(document): fall back to the localized message for non-JSON extraction responses (#1153 part 1)
A proxy error page (or any non-JSON body) on either the error or the success
read now surfaces parseFailedMessage instead of a raw SyntaxError, making the
docblock's fallback promise universal. Review nit from #1164.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* refactor(editor): extract enterEditMode from the Pro toggle
* fix(editor): drop the cross-tab edit lock
* chore(i18n): remove the multi-tab conflict strings
* docs(store): attribute order-rewrite safety to the regen lease
* feat(document): declare a version on every extractor provider (#1153 part 0)
* feat(api): accept asset-id input in the extract-document route (#1153 part 0)
* feat(upload): ingest selected course materials into the asset pool (#1153 part 0)
* fix(document): harden asset-id extraction inputs and close upload ingest leaks (#1153 part 0)
* fix(document): scope extraction fallback and harden the asset-id path (#1153 part 0)
- G1: byte fallback only on pre-extraction failures; PARSE_FAILED
(422/500) is surfaced without re-running the paid extractor
- G2: freeze the course-material set for the duration of generate-prep
(guarded add/remove + disabled toolbar affordances, session built
from the click-time snapshot, belt-and-braces drain)
- G3: bound the pre-generation ingest await (~15s); timed-out ingests
go byte-path and their late-resolving ids are released
- G4: reject non-object JSON bodies (null/array/string/number) as 400
- G5: JSON-path provider pre-validation with generic messages, generic
JSON-path PARSE_FAILED/422 responses, control-char-free log values
- G6: size cap enforced via identify() before resolve() materializes
bytes; too_large maps to the existing 413
* fix(document): mirror provider-hint semantics on the asset-id form and freeze prep-time controls (#1153 part 0)
* fix(document): keep the asset-id form's responses free of caller input (#1153 part 0)
Address review findings: answer the extractor-selection failure with a
generic static message on the asset-id form (the registry's interpolated
message carries the caller's MIME type; multipart keeps it verbatim, both
sides now pinned by tests), dedupe same-batch material additions by content
fingerprint inside the pure updater, document the network-error retry
exception in shouldRetryWithByteUpload's docstring, and fix a test name
asserting 500 but named 400.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* feat(export): download narration script as Markdown or Word-compatible .doc
Adds two export-menu entries that download the classroom's TTS narration
text (SpeechAction.text per scene) as a local document for lesson prep,
closing #413. The .doc is a minimal HTML document with the Word MIME type
so no new runtime dependency is introduced; .md is the portable plain-text
sibling. Scenes without speech are omitted, HTML is escaped in the .doc
path, and file names are sanitized.
Co-authored-by: CommandCodeBot <noreply@commandcode.ai>
* refactor(export): simplify narration script export hook
Reads stage store state at click time instead of subscribing per render,
drops unobservable exporting state (the handler is synchronous and the
menu-close is the reentrancy guard), removes a redundant empty-scenes
pre-check, and drops a type cast the discriminated union makes
unnecessary.
Co-authored-by: CommandCodeBot <noreply@commandcode.ai>
* fix(export): apply code review findings for narration script
- Skip whitespace-only speech text when collecting scene scripts, so a
blank course cannot download a heading-only document with a success toast
- Trim and filter empty paragraphs in the .doc serializer to match the
markdown path
- Strip only dangerous control ranges in the filename sanitizer so emoji
and ZWJ sequences survive (the broad \p{C} class was splitting surrogate
pairs)
- Add unit tests for whitespace-only speech, HTML paragraph normalization,
the <br> branch, emoji-preserving filenames, control-char stripping, and
markdown whitespace filtering
Co-authored-by: CommandCodeBot <noreply@commandcode.ai>
* fix(export): address review feedback on narration script export
- Decouple the export menu trigger from full media-readiness gating:
script export (.doc/.md) now stays usable while media generation is
still pending. PPTX/Resource Pack/ZIP/Video items keep the existing
media-dependent gate, now with visible disabled state and a tooltip
explaining why.
- Document the HTML-as-.doc format as a deliberate zero-dependency
tradeoff (not an oversight) directly above buildDocHtml.
- Sanitize scene/stage titles before they're interpolated into
Markdown headings, so embedded newlines or leading # runs can't
inject extra headings or corrupt document structure.
- Localize the "Slide N" fallback label via the i18n system instead of
hardcoding English, adding export.slideFallback (plus export.textOnly
and export.mediaPending for the new UI states) across all 12 locales.
Addresses review from @wyuc on PR #1144.
* refactor(export): dedupe export-readiness derivation in header controls
canExport already implies canExportText once it ANDs the media-readiness
check on top, so the trigger's title/aria-label/readiness ternaries had two
independent copies of the same 3-way branch drifting apart. Derive canExport
from canExportText, and share exportReady/exportLabel between the button's
disabled, className, title, and aria-label props.
* fix(export): escape leading # in markdown headings instead of deleting it
Two issues found in code review: (1) a leading newline could shield a
leading # from the strip regex, then trim() would re-expose it unescaped
-- a real order-of-operations bug; (2) unconditionally deleting the
leading # run silently mangled legitimate titles like "#1 Introduction".
Escaping (\#) instead of stripping fixes both: content is preserved and
the character can no longer be mistaken for heading syntax, regardless
of what precedes it.
* fix(review): apply review findings
* fix(export): remove legacy doc script export
---------
Co-authored-by: CommandCodeBot <noreply@commandcode.ai>
* Fix Traditional Chinese translations in importer index.html
Convert Simplified Chinese characters to Traditional Chinese:
- 文件 → 檔案
- 数据 → 資料
- 支持 → 支援
- 依赖 → 依賴
- 复制 → 複製
- 下载 → 下載
- 点击 → 點擊
- 页面 → 頁
- 服务器端 → 伺服器端
- 浏览器 → 瀏覽器
- 执行 → 執行
Also fixes layout資訊 → 布局資訊
This ensures the web interface displays correct Traditional Chinese
for users in Hong Kong and Taiwan.
* fix: convert remaining Simplified Chinese to Traditional Chinese (zh-HK)
---------
Co-authored-by: wyuc <wang-yc24@mails.tsinghua.edu.cn>
* feat(dsl): standardize the document asset manifest
Add asset-manifest.ts to @openmaic/dsl: the canonical AssetManifestEntry
shape (ref, kind, and byteSize/mimeType/duration/voice/prompt metadata
where available) plus enumerateAssetManifest, the pure document-to-manifest
enumeration. An entry's ref is the reference exactly as the document holds
it -- the manifest is the id-based reference enumeration with metadata,
not a content hash and not a resolution result. The traversal walks the
stage whiteboard, each scene's canvas/whiteboards/speech actions, and the
stage video-manifest keys in document order, with logical-owner reference
counts that match the accounting duplication-safe replacement uses.
This settles the media-ref + asset-manifest schema question (#779 open
question 4) on the side the asset-pool RFC already implied: the schema is
a function of the id semantics decided there. The type lives in the dsl
rather than a new @openmaic/exporter package because the enumeration is
pure over document types the dsl already owns (Stage/Scene/Slide/Action),
so a separate package would add a published artifact and release-workflow
surface without adding a capability; the storage contract comment now
points at the module.
Refs #1007
* refactor(export): drive the classroom ZIP from the asset manifest
collectMediaFiles used to scan the whole mediaFiles table for the stage,
so any row the document no longer references -- an orphan left by an edit
or a superseded regeneration -- rode along into the archive. Both ZIP
collectors now take their reference sets from the standardized asset
manifest (buildStageAssetManifest wraps the dsl enumeration with the
compatibility rows' metadata): only referenced assets are archived, and a
referenced asset whose bytes exist only in the pool is still collected via
a synthesized record. Byte resolution is unchanged: pool first through
resolveStoredBytes / resolveAudioBlob, with the compatibility row kept as
the legacy byte fallback and as the metadata source.
mediaIndex is now a serialized view of the manifest, and the missing-audio
report derives from the manifest's audio entries instead of a second
action walk. The audioRef mapping and the legacy audioUrl fetch path
(collectLegacyAudioForExport) are untouched.
Refs #1007
* refactor(video-export): take the timeline's reference sets from the manifest
createVideoTimelineDeps scanned the whole mediaFiles table for the stage
and derived its audio id set from its own action walk -- a third,
independent answer to "which media does this course use?". Both record
loads now key off the standardized asset manifest: media rows are read per
manifest ref by compound key instead of by table scan, and the audio id
set is the manifest's audio entries. Orphan rows were never reachable
through the scene-scoped elementId-to-mediaRef bridge; now they are not
even read.
The bridge itself is untouched: element ids recur across scenes, so the
elementId-to-mediaRef mapping stays scoped per scene, and the legacy
audioUrl fallback keeps its own action walk because a URL is not a
manifest ref. AssetPlan remains the video IR's view of the same
references.
Refs #1007
* refactor(export): resolve PPTX media through the shared resolver only
Each PPTX element branch carried its own resolution chain: a task-state
renderable-URL lookup first, then -- gated on the legacy placeholder
predicate -- a stored-bytes override, with the poster block repeating the
pattern. One helper now owns resolution for backgrounds, images, video /
audio sources, and posters: opaque refs (allocated ids and legacy
placeholders alike, no placeholder-pattern gate) resolve pool-first
through resolveStoredBytes and embed as data URLs, concrete addresses
resolve through the media state machine and keep the caller's fetch path.
exportMediaResolution and the resolveStoredMediaBlob wrapper fold into
the helper; resolvePptxMediaBinding stays as the state-machine entry the
resolution-surface test matrix drives.
Refs #1007
* refactor(export): retire the export-side Dexie byte fallbacks
Export call sites no longer read bytes off compatibility rows directly.
The ZIP collectors and the video timeline's audio load resolve bytes only
through the shared resolvers (resolveStoredBytes / resolveAudioBlob),
which answer pool-first and keep the compatibility row as their internal
legacy fallback level; the row reads that remain at the call sites supply
metadata (format/duration/voice/mime/size/prompt) only. The rows
themselves stay for legacy and regeneration readers -- what goes is the
export paths' own fallback logic.
One observable tightening: a failed media row (error set, empty
placeholder blob) no longer ships a 0-byte file into the classroom ZIP,
and an evicted row no longer ships its empty local blob; referenced-but-
byteless assets are simply absent from the archive, as they already were
when no row existed.
Refs #1007
* test(media): cover the enriched stage asset manifest builder
Pins the join between the pure dsl enumeration and the compatibility
rows: metadata attaches by ref, rows no document reference names never
appear, and a referenced asset with no row keeps a metadata-free entry.
Refs #1007
* fix(video-export): widen the deps stage input for the manifest enumeration
enumerateAssetManifest reads the stage's whiteboard and videoManifest, so
createVideoTimelineDeps declares them on its input instead of the bare id;
callers pass only the id today and the optional fields stay absent. Also
applies the repo prettier formatting to the files this branch touched.
Refs #1007
* fix(dsl): enumerate slide audio elements in the asset manifest
Slide audio elements carry their own src, and the manifest skipped
them, so a manifest-driven collector could never archive their bytes.
The audio slot maps to kind 'audio' alongside narration ids.
Refs #1007
* fix(media): harden ref-keyed lookups against prototype-named asset refs
AssetRef is an unconstrained string alias, so a media reference can legitimately be "__proto__", "constructor", or any other Object.prototype member. Plain objects keyed by such refs silently drop assignments or answer lookups with the prototype object, which rewrite paths then accept as a mapped id.
Convert the remaining ref-keyed lookup tables introduced by the export convergence to prototype-safe structures: the classroom import media/poster alias maps and the legacy-conversion video-manifest reconstruction now use Map / null-prototype containers with explicit membership checks, and every consumed value is validated as a string before it is written into a src / mediaRef / audioId slot. The shared media-task lookup receives the same treatment: one centralized own-property-checked lookupMediaTask now serves the stored-bytes resolver, the PPTX embeddable-src path, the video collection path, and the element/background task resolution, so a prototype-named placeholderRef can no longer hide a re-keyed task from the fallback chain.
Adversarial tests drive "__proto__" and "constructor" refs through the import round trip, the PPTX fallback path, the legacy conversion commit path, and the media-task fallback end to end, including a buildPptxBlob regression with a task re-keyed to an allocated id while retaining a prototype-named placeholderRef.
* fix(export): use safe archive asset paths
* refactor(dsl): centralize slide media slot roles
* refactor(export): derive consumer refs from manifest
* fix(export): sanitize classroom archive extensions
* fix(video-export): preserve narration speech order
* fix(export): enforce kind-coherent archive media
* fix(export): define media coherence boundary
* fix(export): carry task-owned poster binding for PPTX export
A video element with no explicit poster falls back to its media task's
generated poster URL, but resolveVideoMediaForElement left posterTask
undefined for that case, so the PPTX manifest guard saw a foreign URL
with no task-ownership exemption and dropped the video element instead
of using the established runtime poster fallback.
Carry the poster task binding whenever the task poster is the effective
poster: the task-owned URL then satisfies the guard's objectUrl
exemption end to end. A concrete explicit element poster still stays
element-owned and never borrows the binding, and the guard's foreign-ref
rejection is preserved (and exported as a directly testable predicate).
Coverage: an element with no poster plus a task-provided poster embeds
the task poster as the PPTX cover (red at the pre-fix head, green now),
and a genuinely unrelated URL with no task ownership is still rejected
by the guard.
* fix(export): preserve legacy narration source refs in the media index
The explicit sourceRef contract was partial: primary audio and generated
media entries carried it, but legacy URL narration serialized no source
ref. The legacy URL itself is the natural source ref — it is known at
fetch time — so wire it through the collected blob into the mediaIndex
entry. Import already registers serialized sourceRefs as aliases, so the
URL now round-trips as an explicit mapping instead of being reconstructed
only from the action's audioRef.
Poster siblings are deliberately NOT given their own mediaIndex entry:
a sibling poster (media/asset-<n>.poster.<ext>) is a legacy byte copy
written from the video record and is not an independently referenced
document asset — when the poster is a real document asset it already has
its own indexed entry with a sourceRef, and import reconstructs the
sibling by path derivation from its parent video entry, reusing the
poster's own indexed allocation when one exists. The PR description is
narrowed to match; corrected paragraph:
"Archive names never interpolate refs — sequential safe paths
(media/asset-<n>.<ext>, audio/audio-<n>.<ext>) with the original ref
preserved through an explicit sourceRef mapping on every independently
indexed media entry: generated media assets, poster assets, primary
narration, and legacy URL narration (the legacy URL itself is the
entry's sourceRef). Extensions are allowlisted per kind. The one
exception is the legacy sibling poster byte copy
(media/asset-<n>.poster.<ext>, written next to its video when the video
record still carries the pre-pool poster bytes): it is not an
independently referenced document asset, so it has no mediaIndex entry
or sourceRef of its own — its identity is derivable from its parent
video entry (same index), and import reconstructs it by sibling-path
derivation from that video entry, reusing the poster's own indexed
allocation when one exists."
---------
Co-authored-by: 杨慎 <117187635+cosarah@users.noreply.github.com>
Cache Playwright browsers and Next compile artifacts, retry a timed-out
Chromium download, build the production bundle before Playwright starts,
and run Prettier/ESLint/tsc/i18n in parallel. Unit tests stay sequential.
Skip Playwright apt deps on ubuntu-latest.
Closes#1145
* fix(editor): align editable shape labels with text
* fix(editor): preserve plain-text line breaks on edit
* fix(editor): preserve entities in plain text editing
* style(editor): format shape editing changes
* test(editor): update manifest version expectation
* refactor(media): share one stored-bytes resolver across export paths
The classroom ZIP, PPTX, and video exports each re-implemented the same
pool-first byte resolution with a Dexie compatibility-row fallback and a
task-URL fallback. resolveStoredBytes in lib/media/resolve-stored-bytes.ts
now owns the chain, with the per-path differences (which levels run, fetch
validation strictness, state-machine gating) as explicit options. The
record-id to ref mapping moves alongside it as mediaRefFromRecordId.
Intended as a behavior-preserving extraction: same bytes, same fallback
order, same error handling. Cross-review found two places where this commit
did not achieve that, both repaired later on this branch -- it moved a
supplied row's `error` verdict past the pool await (restored by the snapshot
fix) and dereferenced `record.blob` unguarded (guarded by the option-surface
fix). See the PR description for the resulting divergence from main, which
is stated rather than claimed away.
Refs #1007
* fix(media): make the resolver's option surface mean what it documents
Cross-review round 1 surfaced the same class of defect from both reviewers:
options and docstrings that promise more than the code does.
- `taskUrlFallback` was inert without `resolutionGating`. The task lookup was
derived from the gating flag alone, so the final level resolved with no task
and always returned null. The lookup now runs whenever either concern needs
it, while the gated levels still read it only when gating is on -- so no
current caller's behavior changes, and the pre-refactor callers' own
unconditional lookup is restored.
- `compatRowBytes` dereferenced `record.blob.size` unguarded, which broke the
module's "never throws" contract and lost the guard the pre-refactor video
path had (`blob && blob.size > 0`). A row that lost its blob now falls
through to the next byte source.
- `loadCompatRow` (requires `stageId`) and `compatRowCdnFallback` (needs a row)
now state their dependencies instead of silently doing nothing.
Six tests pin the behavior; each fix was mutation-checked (reverting the task
decoupling reds one, reverting the blob guard reds two).
* test(media): pin the resolver behavior the round-2 review found unpinned
Round 2 produced no behavior findings from either reviewer; every finding was
a test that would still pass with the behavior broken. Six cases close them.
Mutation-verified -- reverting the code each covers reddens it:
- Gating suppresses stale POOL and row bytes, not only the final level
(removing either gate reds it).
- With gating off the compatibility row is still accepted while a task is
generating. This is the discriminator for keeping `gate` separate from
`task`: leaking the task into the gated levels reds it.
- A zero-length row blob falls through to the CDN source when enabled, and
never reaches it when not (breaking `compatRowCdnFallback` in either
direction reds one of the two).
- The effective ref comes from a supplied row's compound id, not the ref the
caller passed (dropping the derivation reds it).
- The classroom ZIP still ships a non-OK pool response body -- the rebase
conflict resolution that was correct but untested (tightening `requireOk`
reds it).
Not mutation-verified, and marked as such in the file: "accepts pool bytes
with gating off" documents the pool level's unconditional branch but survives
every single-edit revert of the task/gate separation, because that level
branches on the option rather than on the gate. It is kept as documentation,
not as a sentinel.
* fix(media): snapshot a supplied row's error verdict before the pool await
Round 4 of cross-review found a divergence three rounds of level-by-level
comparison had missed. The pre-refactor video path computed
`usableRecord = record && !record.error` BEFORE awaiting the pool; the
extraction moved that test after the await. The supplied row is a live
object, so an `error` set (or cleared) while the pool lookup was pending
changed which bytes the export shipped.
The verdict is now taken where the caller took it, and -- the part the first
attempt at this fix got wrong -- it is not re-derived afterwards:
snapshotting only the reference still re-read `error` off the live object at
the later check. A row this function loads itself is still judged when it is
read, after the pool miss, because that read did not exist any earlier.
This was the last value read at a different point in the SEQUENCE than the
code it replaced. What remains is scheduling, not sequence: the row's `blob`
and `ossKey` are still read after the pool as they were on main, only one
microtask later. The PR description states that, and no writer in the repo
mutates a row in place across that window.
Two tests pin both directions (an error landing mid-lookup, and one
clearing); judging the supplied row after the await reds both.
* test(media): commit the frozen-base differential harness (F-1116-001)
The PR description claimed a differential comparison against the verbatim
pre-refactor implementations, but that comparison was run out of tree and
never committed, so no reviewer could re-run it. A claim that cannot be
reproduced is not evidence.
The three implementations are now reproduced verbatim as they stand at the
merge base -- still identical to main, since nothing between the two touched
them -- and driven side by side with the current helper under each call
site's real option set, over a declared matrix of ref shapes, task states,
pool outcomes, fetch outcomes, record shapes, and stage scoping. Every
combination compares the outcome (bytes, null, or the thrown type), blob
identity by reference, and the ordered lease / Dexie / fetch sequence
including arguments.
Absolute microtask offsets are not compared, for the reason the description
gives: the extraction moves later operations one or two ticks while leaving
the sequence, the arguments, and the results alone.
The blob-less PPTX row is the only permitted divergence, and the harness
pins where it may occur rather than merely tolerating it: every divergent
combination must be that row with a stage, and the count must equal the
combinations that actually reach the row -- all of them except the ones
where the frozen path returns pooled bytes first.
The harness bites: dropping the zero-byte rejection reds ZIP and video,
dropping the response.ok check reds PPTX and video, and dropping the
compound-id ref derivation reds video. Its scope is documented in the file --
option combinations no call site uses, and state that mutates mid-await, are
covered by the targeted tests instead.
* test(media): extend equivalence harness for colon refs and PPTX divergence (F-1116-001)
* fix(renderer): preserve literal text line breaks
* fix(renderer): preserve empty text paragraphs
* fix(renderer): scope line-break preservation to plain text
* fix(renderer): detect non-tag HTML markup
* style(renderer): format rich text tests
Add a non-invasive 3rd top-level branch to the openmaic skill for users who want to extend / build on / customize OpenMAIC or consume the @openmaic/* SDK — previously no trigger and no SOP path.
- SKILL.md: add 二开 trigger words to description; Phase 0 option 3 + short-circuit; accessCode auto-shortcut now yields to 二开 intent so returning Live Demo users are not mis-routed.
- references/extend.md (new): 二开 SOP router — charter, read-before-modify rules, dev environment (= local-deploy env, reused not duplicated), conditional fork-vs-clone + CI, route to cookbook/sdk, general gotchas.
- references/extend-cookbook.md (new): product-layer tasks with precise file:line entry points (provider / storage / branding / routes / embed renderer-editor).
- references/extend-sdk.md (new): consuming @openmaic/* in a standalone app — 6-package quickref, exact import paths, storage subpath table (incl. Runtime-barrel asymmetry), version pinning.
Existing 5 references and Core Rules are untouched; the use/generate flow is unchanged.
Co-authored-by: Percy <percy@PercydeMacBook-Pro.local>
Co-authored-by: wyuc <wang-yc24@mails.tsinghua.edu.cn>
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
* feat(storage): add opt-in indirect asset byte egress
A deployment can now opt an asset byte GET into a 302 to a short-lived
signed URL when the byte layer can sign. It is off by default, and
byte-for-byte unchanged when off. The byte layer gains an optional
signReadUrl capability; S3AssetByteStore implements it through the new
optional @aws-sdk/s3-request-presigner peer, resolved lazily exactly
like the client SDK, with a signer seam mirroring the commands seam so
tests can bind doubles. The signed URL pins the contract's response
headers -- the media type after the renderable allowlist, the fixed
disposition, and the no-store cache posture -- via S3 response-header
overrides. PgAssetStore mints the URL from the same ownership-checked
transactional read a direct resolve performs, so authorization still
runs per read before any URL exists, and a store whose byte layer
cannot sign falls back to direct bytes. The contract documents the
shape and the disclosure tradeoff: a Location into a hash-keyed byte
layer names the content hash, so deployments that need the
no-disclosure property keep direct egress.
Refs #1007
* feat(app): wire ASSET_BYTE_EGRESS into the persistence route
ASSET_BYTE_EGRESS=redirect opts asset byte GETs into the storage
package's indirect egress; unset, direct, or an unrecognized value
keeps the default byte-for-byte behavior, with a warning for the
unrecognized case. The lazy byte-store wrapper now forwards
signReadUrl and answers undefined when the resolved layer has no
signer, so the PostgreSQL byte column degrades to direct bytes and
the S3 layer signs without the wrapper knowing which it holds. The
collector path is untouched: it builds its byte store through
createAssetByteStore as before and only ever calls delete.
Refs #1007
* fix(storage): let the packaged client survive redirect egress
Cross-review round 1 on the indirect byte egress change found two
problems.
A byte response that followed a redirect carries the pinned content
type but no X-Asset-Revision, and HttpAssetStore required both from
the same response, so every cold resolve under redirect egress failed
MALFORMED_RESPONSE. The client now detects the shape once -- a
redirected byte response with no revision -- latches it, and probes
HEAD before each cold GET from then on, taking the label from the
probe. The probe predates the download on purpose: a replacement in
between labels newer bytes with an older revision, which the next
revalidation detects and corrects, while the reverse order could pin
stale bytes under a fresh revision with no signal to repair it. The
probe doubles as the miss check, so a redirect-mode miss costs no
download. The direct path is byte-for-byte unchanged; the one double
download per client lifetime on first contact is documented in the
contract, which gains the client-side paragraph this behavior
implements.
signedUrlTtlSeconds also accepted lifetimes beyond the seven-day
SigV4 presigning maximum, minting redirects the object store would
reject. The option is now capped at construction.
Refs #1007
* fix(storage): cap signed URL lifetime below the reclamation grace
A signed URL whose lifetime exceeds the collector grace period can
outlive its object: the last reference goes, the grace elapses, the
collector deletes the object, and the still-valid URL errors at the
object store. The previous ceiling -- the seven-day SigV4 maximum --
made that window days wide against the one-hour default grace. The
cap is now fifteen minutes, and the contract text states both the
ceiling and the rule for deployments that shorten their grace.
Refs #1007
* fix(storage): decline signing when the optional presigner is absent
An unresolvable @aws-sdk/s3-request-presigner made signReadUrl throw,
which the registry surfaced as a failed asset read -- a 500 for a
deployment whose only fault is a missing optional peer. The contract's
answer for a byte layer that cannot sign is the capability fallback:
return undefined and let the caller serve the bytes directly. Signing
errors from a resolved signer still fail loud; only the missing
capability declines.
Refs #1007
* fix(storage): answer redirect egress as a descriptor to asking clients
Following a 302 is not header-neutral: the platform fetch forwards the
original request's headers to the object store's origin, stripping only
Authorization, so a deployment whose credential travels in a custom
header would hand it to the object store -- and the preflight the
forwarded custom headers provoke commonly fails there besides. This
replaces the latch-and-probe client shape with an explicit one: the
client sends X-Asset-Egress: descriptor on every byte GET, a
redirect-egress server answers 200 with a JSON { url, revision } body
instead of a Location, and the client fetches the signed URL with no
deployment headers at all. The revision comes from the descriptor, so
the probe-first ordering and its one wasted download are gone too. The
302 remains the answer for consumers that did not ask; the descriptor
response is marked by its own header so a JSON-media asset can never
parse as one.
Refs #1007
* fix(storage): negotiate the descriptor through Accept, not a custom header
A custom X-Asset-Egress request header is not CORS-safelisted, so every
byte GET to a cross-origin server became a preflighted request -- a
regression for direct-egress deployments that never opted into
anything, against servers with no reason to allow the header. The
negotiation now rides Accept with a vendor media type, which is
safelisted and costs no preflight, and the descriptor answer is
identified by that media type as its Content-Type rather than by a
marker header.
Refs #1007
* fix(storage): sign outside the registry transaction and reserve the descriptor type
Two review findings on the indirect egress path.
resolveIndirect awaited the signer inside the coordinated read, but a
signer on refreshable credentials can wait on the network, and a
database connection plus the blob row's FOR SHARE lock would be held
across that delay. The read now closes first -- the lock still puts
the hash, label and revision in one snapshot -- and signing runs
afterwards, which cannot observe anything the read did not.
The descriptor media type is also reserved from renderableTypes: the
client identifies a descriptor answer by that exact Content-Type, and
an allowlisted asset served with it would be misread as a descriptor
and fail MALFORMED_RESPONSE. The handler now rejects the configuration
at construction, and the contract states the reservation.
Refs #1007
* fix(storage): ship the signing deps and negotiate without ambiguity
Three review findings on the indirect egress path.
The app now declares @aws-sdk/client-s3 and
@aws-sdk/s3-request-presigner as runtime dependencies, and the
standalone build force-includes them for the persistence route: both
are reached through deliberately untraced dynamic imports, so the
shipped image could not resolve them, and a deployment opting into S3
or redirect egress would have found the capability silently absent.
The descriptor negotiation matches exactly on both sides: the client
compares the Content-Type essence, so a longer media type that merely
begins with the reserved value still serves as bytes, and the server
parses Accept media ranges, so a descriptor range sent with q=0
selects the redirect instead of the descriptor it explicitly rejects.
Refs #1007
* fix(app): refuse redirect egress when the collection grace is shorter
A signed URL must never outlive its object: with the handler's
60-second default lifetime, a deployment that sets
ASSET_COLLECTION_GRACE_MS below ten minutes lets the collector delete
an object while a URL minted against it is still valid. The route now
refuses the combination at handler initialization, naming both
variables -- a deterministic misconfiguration, loud at startup rather
than silent at read time.
Refs #1007
* fix(app): ship the signing SDKs without loading them
Two findings on the standalone deployment of redirect egress.
The outputFileTracingIncludes globs did not follow pnpm's scoped
symlinks, so the standalone image carried neither AWS package and both
S3 mode and redirect egress could not resolve their SDK at runtime.
The packages are now server-external and referenced from literal --
but never called -- dynamic import thunks in the persistence byte-store
wiring, which gets them traced into the image while module resolution
still happens only on first S3 use. Verified against a local
standalone build: node_modules/@aws-sdk now contains both packages,
and the route tests that pin the never-resolve-unless-configured
discipline still pass.
Media type comparisons are also case-insensitive now, as HTTP
requires, on both the Accept parse and the descriptor recognition.
Refs #1007
* fix(app): treat an empty collection grace as unset
Number('') is 0, so a present-but-empty ASSET_COLLECTION_GRACE_MS
failed the redirect-egress coordination check and took persistence
down with it, while the collector's own parsing reads the same value
as the one-hour default. The check now trims and treats empty as
unset, matching durationEnv.
Refs #1007
* fix(storage): accept bytes too, omit signing on the known-PG wrapper, state the full tradeoff
Three review findings.
The client's descriptor request now advertises both representations --
the vendor descriptor type preferred, */* accepted -- so a strict
content-negotiating layer can never answer 406 to a client that does
in fact consume plain bytes.
The lazy byte-store wrapper no longer advertises signReadUrl when no
bucket is configured: the layer is known to be PostgreSQL at
construction, and carrying the method made resolveIndirect take its
ownership query and blob-row lock just to decline, then repeat them
in resolve, on every cold GET. With a bucket configured the lazy
validation is preserved and the wrapper still declines when the
resolved layer cannot sign.
And the contract's disclosure tradeoff now states the second edge:
object-store GETs carry ETag and Last-Modified, response overrides
cannot strip them, and because deduplicated PUTs rewrite the
hash-keyed object, Last-Modified moves when another principal
re-uploads the same bytes -- shared-object write timing, beyond byte
equality. Deployments for whom that signal is sensitive must keep
direct egress.
Refs #1007
* fix(app): degrade on bad grace, share the ttl invariant, cover the real boundary
The latest review round, four findings.
A misconfigured ASSET_COLLECTION_GRACE_MS now degrades redirect
egress to direct with a warning instead of failing the shared
handler's initialization -- the asset backend is optional, and its
misconfiguration must never take document and runtime traffic down.
The package exports assertSignedUrlTtlWithinGrace so a deployment
wiring the handler and the collector separately validates the
invariant once at its own boundary: the handler caps the lifetime,
but only the deployment knows both numbers.
The contract states that nosniff does not survive the redirect --
response-header overrides cannot set it -- and why the client-minted
blob makes that safe for conforming clients.
And the route gains a real-boundary test: the Fetch<->Node adapter,
the real createStorageHttpHandler, and the egress wiring, exercised
with an actual byte GET through the composed stack instead of mocks.
Refs #1007
* fix(storage): make the unsafe egress configuration unrepresentable
Three findings on the indirect egress path, all resolved by moving a
rule into a place where it cannot be bypassed rather than by adding a
check a consumer has to remember.
The grace/TTL invariant now lives in the option shape. byteEgress takes
'direct' or { mode: 'redirect', collectionGraceMs, signedUrlTtlSeconds? },
so enabling indirect egress without declaring the reclamation window it
lives inside no longer type-checks, and the handler validates both
numbers at construction. The flat signedUrlTtlSeconds option, its
cross-field "requires byteEgress redirect" check, and the standalone
900-second cap as the only guard all go away; the ceiling stays, but
alongside the ratio rather than in place of it. This is what the earlier
exported assertSignedUrlTtlWithinGrace could not do -- it protected only
consumers who called it -- and it costs nothing in compatibility because
the option is new in this branch. The app resolves the grace through one
shared parser with the collector, so the number the handler checks is the
number the collector runs with.
A signed URL whose object is gone is now a miss. The client maps a 404
from the byte fetch to ASSET_NOT_FOUND, matching the direct path: the
entry was owned and readable when the URL was minted, so a reclaimed
object is the same physical state the direct read reports as a miss, and
what a read means must not depend on the deployment's egress setting.
Signing still does not probe for the object -- that would restore the
round trip this feature removes and make the mint's price vary with prior
presence -- so the contract instead requires the byte layer to answer 404
for an absent object, and states that S3 needs s3:ListBucket to do so.
Route coverage now spans the whole egress matrix through the real
composed handler rather than only the direct case: 302 for a consumer
that did not ask, descriptor for one that did, direct bytes when the
byte layer declines to sign, and direct bytes when a short grace degraded
the configured mode.
Refs #1007
* feat(storage): forward byte egress options through reference server
* fix(storage): fail closed on redirects and unconfirmed 404s in indirect byte egress
A signed-fetch 404 now maps to a miss only when the object store's XML
body declares NoSuchKey; every other 404 from the signed fetch fails loud,
so a wrong bucket, access point, or endpoint can no longer make a live
asset read as absent. The descriptor byte GET is sent with redirect:
'manual' and any 3xx answer is treated as an error, so a server that
ignores the descriptor negotiation can never forward the deployment's
custom credential headers to a redirect target.
The ASSET_BYTE_EGRESS switch is now documented in .env.example and the
server-persistence deployment section, together with the object-store CORS
and s3:ListBucket prerequisites it requires, and the asset descriptor
media type is exported from the package root and the asset/http subpath.
---------
Co-authored-by: 杨慎 <117187635+cosarah@users.noreply.github.com>
* feat(ai): add Grok 4.6 to the model registry
The Grok picker still topped out at 4.5, so the new flagship ID was not selectable with its documented xhigh reasoning effort.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(docs): stop the docs Next app from loading app instrumentation
Next 16 discovers the repo-root instrumentation.ts from packages/docs, then fails because that file imports app persistence modules. Give the standalone docs app a no-op register() so the Grok catalog docs can build.
Co-authored-by: Cursor <cursoragent@cursor.com>
---------
Co-authored-by: Cursor <cursoragent@cursor.com>
Translate all 1,619 leaf keys with exact parity against en-US.json and
register the locale in supportedLocales.
Rebased onto current main (4ac70cce). Upstream drift resolved here:
- #1023 added 14 edit.agent.* / edit.roster.* keys and #995 added three
export.video*Cta* keys; all are translated.
- #1060 retired the pbl.* v1 UI, so its 38 keys are dropped from vi-VN.json
as well. check:i18n-keys compares both directions, so leaving them would
fail on extras.
- #1068 (fr-FR) registered a locale in the same spot in locales.ts; both
entries are kept.
- 120 keys landed since the last rebase and are translated in this pass:
the canvas editor toolbars (edit.line.*, edit.insert.linePresets.*,
edit.asset.*, edit.latex.*, edit.video.*, edit.audio.*, edit.image.*,
edit.table.*, edit.contextMenu.*) and classroom folder management
(classroom.folder*, classroom.deleteFolder*, classroom.moveTo*).
Vietnamese has no plural inflection, so deleteFolderAndCourses and its
_other form carry the same text; both keys are present because
check:i18n-keys requires them.
Two extra files are required and could not be split out: LOCALE_RESOURCES
in lib/video-export-app/cover-config.ts and the expected-labels map in its
test are both typed Record<Locale, ...>. Without these entries tsc --noEmit
fails with TS2741 on '"vi-VN"', exactly as it did for fr-FR in #1068.
Seven added lines outside lib/i18n, no logic change.
Also translates the strings flagged in review that were still English:
the OpenAI/GLM/Qwen voice descriptions, browser-native TTS and ASR labels,
providerUnpdf, voiceNamePlaceholder, lang_auto, the VoxCPM voice-pool
labels, and the protocol/provider names. "(Local)" renders as "(Cục bộ)"
throughout. Left verbatim on purpose: settings.lang_* stay in their native
scripts (matching every existing locale), "Video" is the ordinary
Vietnamese word, and AccessKey ID / AccessKey Secret are the literal field
names in the Alibaba Cloud console.
README language counts updated to 11 and now include French, which #1068
had not backfilled.
pnpm run check:i18n-keys passes across all 11 locale files; prettier
reports the touched files clean; the i18n suite (39 tests) and the
video-export cover-config suite (92 tests) pass; tsc --noEmit reports no
error in any touched file.
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
ServerProvidersInit fetches /api/server-providers on mount, which on an
ACCESS_CODE-gated deployment happens before any access cookie exists. The
middleware answers 401 and fetchServerProviders returns early, leaving the
settings store on its blank defaults. AccessCodeModal calls onSuccess without
reloading and nothing else calls fetchServerProviders, so every
server-configured provider reads as unconfigured for the whole session until
the user manually reloads.
Re-run the fetch from AccessCodeGuard's onSuccess, once the cookie makes the
request authorizable.
Co-authored-by: thanos-openclaw <thanos@openclaw.local>
Co-authored-by: wyuc <wang-yc24@mails.tsinghua.edu.cn>