mirror of
https://github.com/nexu-io/open-design.git
synced 2026-08-03 06:05:05 +08:00
codex/amr-client-structured-errors
6 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
f4fe5ad757 |
feat(ci): implement the plugin-preview bake pipeline (spec 4548) (#4700)
* feat(ci): bake pipeline slice 1 — previews-diff guard + single rolling PR Per specs/change/20260618-plugin-preview-bake-pipeline/spec.md rollout step 1 (the smallest change that stops the bleeding on the stacked bake-PR backlog). - scripts/plugin-previews-diff.mjs: decide whether a manifest's `previews` subtree changed, ignoring the per-run `generatedAt` timestamp. node:test coverage in plugin-previews-diff.test.mjs (the #4261 timestamp-only noise case, entry change, add/remove, key-order stability). - bake-plugin-previews.yml: open a review PR only when `previews` actually changed (was: whole-file `git diff` that fired every run because of generatedAt), and reuse ONE rolling branch (chore/plugin-previews) / force-update the open PR in place instead of stacking chore/plugin-previews-<run_id> per run. - guard.ts: allowlist the two new CI-only .mjs scripts. Deferred to later slices (same spec): pre-merge same-repo coupling job, release-cut full bake, tag-union GC, directory-layered artifact keys. * feat(ci): bake pipeline slices 2-4 — pre-merge coupling, release full bake, GC, directory keys Completes the spec (specs/change/20260618-plugin-preview-bake-pipeline) on top of slice 1, all in one PR (GC ships dry-run so staged ENABLEMENT still holds): - .github/actions/bake-previews: composite action for the shared render + R2 upload core, so the three bake workflows stop duplicating it. - bake-plugin-previews.yml (post-merge): refactored onto the composite; role is now uploader + fork path + nightly backstop (rolling PR unchanged). - bake-plugin-previews-pr.yml (slice 2): pre-merge bake for SAME-REPO PRs — renders, uploads, and commits the manifest INTO the author's branch so it rides with the code change. Loop guard checks the head COMMIT author (git log -1 %ae of head.sha, fetched via full checkout), not head.user.login, plus a no-op previews-diff guard. Forks fall through to post-merge. - bake-plugin-previews-release.yml (slice 3): release-cut full bake committing the authoritative manifest onto release/**, with paths-ignore + bot-author loop guards. - scripts/bake-plugin-previews.mjs (slice 4a): directory-layered, content- addressed keys <id>/<fingerprint>/preview.mp4 (+ poster.jpg), prefix-relative in the manifest. Daemon consumer already resolves base+key / path.join(dir,key) so no consumer change; reused flat entries are left untouched (additive). - scripts/plugin-previews-gc.mjs (slice 4b) + .github/workflows/ bake-plugin-previews-gc.yml: weekly R2 GC. Protected set = keys referenced by every tag + live release/** HEAD + main; deletes orphans older than a 90d grace window. DRY-RUN by default (needs --delete AND GC_ENABLE_DELETE=1). Pure protected-set/orphan logic covered by node:test. - guard.ts: allowlist the new CI-only .mjs files. * fix(ci): capture diff-guard result on its own line so a helper error fails the step Per review: `if [ "$(node plugin-previews-diff.mjs ...)" != changed ]` swallows the helper's exit 2 (bad args / unreadable manifest) inside command substitution, so an error reads as empty string → 'unchanged' branch → the manifest PR/commit is silently skipped despite a successful bake. Capture into diff_result on its own line (so `set -e` aborts on a helper error) and `case` on the value, treating unexpected output as a workflow failure. Applied to all three bake workflows. * fix(ci): satisfy actionlint — quote gc description colon + route PR/dispatch context through env - bake-plugin-previews-gc.yml: quote the `delete` input description (the '(default: ...)' colon broke YAML parsing) and pass dispatch inputs via env (GC_DELETE/GC_GRACE_DAYS) instead of interpolating into the run body. - bake-plugin-previews-pr.yml: route head.sha/head.ref through HEAD_SHA/HEAD_REF env vars to avoid the script-injection lint on untrusted PR context. * fix(ci): hard-gate the release bake job to release/** branches workflow_dispatch can fire from any ref; the commit step pushes the authoritative manifest to the triggering ref with contents:write, so a dispatch on main would write straight to main and bypass the release back-merge. Add a job-level `if: startsWith(github.ref, 'refs/heads/release/')` guard. * fix(ci): GC fails closed on partial protected-ref data Per review (non-blocking but real once deletion is armed): the GC workflow's protected-ref fetches ended with '|| true', so a transient fetch failure could leave the tag/release/main protected set incomplete and, with GC_ENABLE_DELETE=1, prune clips a live release/main still references. Drop the '|| true' (fail the job if the protected refs can't be fetched), and add a script-side guard that refuses to delete when the protected set is empty or origin/main's manifest is unreadable. |
||
|
|
6869b1208b |
fix(plugins-home): correct deck/scroll preview capture + smoother gallery playback (#4044)
* fix(bake): classify deck-vs-scroll by viewport, real-wheel pan, motion config
Systematic audit of all 126 baked previews surfaced three capture bugs:
- 2 vertical pages misread as decks (the input probe wheel-scrolled them and the
scroll-driven animation looked like a slide change), so they got walked
sideways. Classify by viewport height instead: a fixed-viewport page is a deck,
a vertically-scrollable page is a landing page (pan it) even with a horizontal
marquee/carousel sub-component.
- 9 scroll-hijack landing pages (custom/transform scroll) that window.scrollTo
can't move, so the pan was static. Pan those with REAL wheel events
(page.mouse.wheel), which drive the page's own scroll handler.
- single-screen pages now hold (static) instead of being forced down a deck path.
Plus an opt-in override: authors can declare od.preview.motion ('scroll' | 'deck'
| 'static') and the bake honors it, auto-detecting only when it's absent. Schema
+ plugins-spec document the field. (Also strips a stray NUL byte from the hash
line that made the file read as binary.) BAKE_VERSION -> 4 re-bakes everything.
* perf(plugins-home): only decode visible gallery clips + stream first frame
Two cheap wins for the baked gallery videos:
- Decouple mount from play. The tile mounts the <video> across the wider inView
margin (so scroll-in/hover never remounts + reloads), but only PLAYS while
truly visible — off-screen tiles in the mount margin hold their poster frame
paused instead of all running a simultaneous decode. Adds a 0-margin visible
observer in PreviewSurface alongside the existing near one.
- preload=metadata instead of auto: paints the first frame off the +faststart
header instead of eagerly buffering the whole clip up front, so tiles show fast
and don't saturate the network. The idle hold buffers the pan before hover.
* perf(plugins-home): keep baked clips mounted across a scroll window
Scrolling a tile out of view and back re-showed a load even though the clip
bytes are HTTP-cached (R2 immutable): the <video> unmounted at the tight 120px
margin, so scroll-back remounted a fresh element that re-fetches metadata and
re-decodes the first frame. Add a wide keep-mounted observer (~1500/1800px) so a
clip stays mounted for a few screens — instant scroll-back — while iframes keep
the tight margin and play stays gated to the truly-visible zone (paused, not
unmounted, off screen).
* fix(bake,contracts): probe scroll mechanism before recording; validate motion
Address review:
- Move the window.scrollTo probe before Page.startScreencast so its scrollTo
160 -> 0 jump isn't baked into the head of the pan as a visible lurch.
- Type od.preview.motion in the Zod PluginManifestSchema (enum scroll|deck|
static) so an invalid value fails doctor/install instead of silently parsing
via passthrough and being ignored by the bake; add contract test coverage.
* fix(bake): auto-detect single-screen fixed pages as static, not deck
A fixed-viewport page is only a deck if an input actually advances it; probe the
driver during auto-detect and fall back to 'static' (default viewport + a hold)
when nothing moves it, instead of routing every non-scrollable page through the
deck path where walkSlides(null) just held at the deck-sized capture. Extracted
the arrow/wheel probe into probeDeckDriver(). Verified: a waitlist page now bakes
a 2.5s static hold, guizang still walks, acreage still pans.
---------
Co-authored-by: audit <a@b.c>
|
||
|
|
9b06b6df78 |
perf(bake): bound the deck slide-walk so huge-DOM decks don't run long (#4029)
A deck that renders every slide side-by-side in one giant rail
(#deck{width:10000vw}) has thousands of DOM nodes. deckSignal called
getBoundingClientRect + getComputedStyle on ALL of them to fingerprint the
current slide, which is O(n) layout reads — fast locally but ~3s/scan in CI,
dragging guizang's walk (and its clip) out to 21.7s while every other deck
stayed <=10s.
Cap deckSignal's scan to the first 600 elements (the slide rail/track is a
structural node near the top of the DOM, so this still detects slide changes —
verified guizang still walks to its later slides), and add an 8s wall-time
backstop on the walk so a clip can never run long even if signal reads are slow.
BAKE_VERSION -> 3 re-bakes everything.
Co-authored-by: audit <a@b.c>
|
||
|
|
31cf0e0f0a |
fix(plugins-home): deck slide-tour bakes, CJK font fix, framing + reveal height (#4020)
* fix(plugins-home): anchor baked preset previews to the hero top The example-prompt preset tiles render wider than the gallery's 1.31:1 baked clip, so object-fit:cover was cropping the hero headline out of the vertical middle. Anchor the preset video/poster to the top so the hero stays in frame. Scoped to the preset cards; the Community grid (which matches the clip aspect) is untouched. * fix(plugins-home): bake decks as slide tours, fix CJK font tofu, unclip reveal Three gallery-preview fixes found reviewing the baked Community gallery: - Decks (fixed-viewport PPT/slideshow pages driven by arrow/wheel, not scroll) were vertically panned like tall landing pages — capturing ~20s of a dead WebGL background at the wrong 1.31 aspect (the page's compact responsive variant, hero headline gone). The bake now detects them (page no taller than the viewport), re-renders at 16:9, and walks their slides (arrow keys, falling back to wheel then a "next" control, detected by a structural slide signal) for a slide-tour the gallery loops on hover. BAKE_VERSION -> 2 re-bakes all. - CJK-heavy templates baked tofu boxes: they pull Noto Serif/Sans SC from Google Fonts with display=swap and the CI runner had no CJK fallback. The bake job now installs fonts-noto-cjk + emoji, and the bake double-awaits fonts.ready around a force-load so a late-registered display face isn't captured as its fallback. - The Home templates reveal clipped its last rows: a fixed max-height:6000px ceiling + overflow:hidden, but the gallery is ~7300px and grows. Switched to the repo's canonical grid-template-rows 0fr->1fr auto-height pattern (inner wrapper owns the overflow) so it expands to the gallery's natural height. * fix(bake): probe-detect deck nav + capture at the 1.31 tile aspect Refines the deck path after live review: - Detection was scrollHeight-based, so a deck whose DOM stacks slides vertically got misread as a scroll page and vertically panned. Now PROBE the navigation: press the arrow key, then nudge the wheel, and use whichever actually moves a slide (deckSignal ignores the WebGL background); only a page that neither navigates nor fits the viewport keeps the linear pan. - Captured decks at 16:9, which object-fit:cover then side-cropped in the 1.31 tile (hero edges cut). Decks collapse to a compact variant at the normal 1440 width, NOT at the 1.31 aspect — so capture at 1.31 (1760x1344, wide enough to clear the breakpoint): full layout AND a clip that fills the tile, no crop. --------- Co-authored-by: audit <a@b.c> |
||
|
|
c2d16727cd |
feat(plugins-home): content-hashed preview filenames + immutable CDN caching (#4007)
Baked clips are served from R2 behind Cloudflare's CDN (cf-cache-status: HIT). Previously a re-bake overwrote the same key (example-x.mp4), so the edge kept serving the stale clip until its TTL expired. Name each clip example-x.<contentHash>.mp4 instead: a content change ships a NEW URL the manifest points at, the old edge entry is simply no longer referenced, and the upload sets Cache-Control: public, max-age=31536000, immutable so the CDN can cache each clip forever. Co-authored-by: audit <a@b.c> |
||
|
|
d0f350e825 |
feat(plugins-home): pre-baked hover-pan preview clips for the gallery (#3994)
* feat(plugins-home): pre-baked hover-pan preview clips for the gallery
The Community gallery renders every html plugin as a live, scaled
example.html iframe that animates + auto-pans on hover. That is
GPU-expensive at scale (each tile is its own out-of-process document
re-compositing a tall page) and renders inconsistently for tricky pages
(WebGL noise, video backgrounds, lazy content).
Pre-render each preview to a tiny H.264 clip + first-frame poster:
- scripts/bake-plugin-previews.mjs: headless-Chrome screencast of a
[hold@top in-place animation][linear pan top->bottom] capture, waiting
on fonts + <img> + CSS background-images + <video> backgrounds first,
then ffmpeg -> CFR H.264 mp4 + poster.jpg + manifest.json. Velocity is
pre-computed from page height so the pan always finishes within ~10s.
Runtime deps (puppeteer-core / Chrome / ffmpeg) stay out of package.json
and are provided by the CI environment.
- daemon: serves <out> at /api/plugin-previews and attaches the clip to a
plugin record under od.bakedPreview (a SEPARATE field — the detail modal
still reads od.preview and opens the live, interactive page).
- web: inferPluginPreview(record, { preferBaked: true }) lets gallery tiles
opt into the clip; MediaSurface loops the in-place [0, holdMs] span while
idle and plays the pan on hover, one always-mounted <video> (no black
flash), looped frame-accurately via requestVideoFrameCallback, with no
native controls. Plugins without a bake keep the live-iframe fallback.
CI upload to R2 + the post-merge/nightly bake workflow (with content-hash
skip) and the daemon on-demand path land in follow-ups.
* feat(plugins-home): content-hash skip so unchanged plugins reuse their baked clip
The bake now hashes each plugin's preview HTML + a BAKE_VERSION and stores it
in the manifest. Re-running skips any plugin whose hash is unchanged (no render,
no re-encode, and the CI step re-uploads nothing) — editing the page or bumping
BAKE_VERSION invalidates it. Verified: a second pass over already-baked plugins
reuses all of them in ~1s instead of re-rendering.
* feat(plugins-home): point baked-preview URLs at R2 in production
bakedPreviewBlock now builds its poster/video URLs from
OD_PLUGIN_PREVIEWS_BASE_URL (the R2 public origin) when set, falling back to
the daemon's own /api/plugin-previews static route for local dev. The CI bake
uploads the clips to R2 and the deployed daemon points at them there.
* feat(plugins-home): CI workflow to bake + publish plugin previews
Adds .github/workflows/bake-plugin-previews.yml: post-merge (paths:
plugins/_official) + nightly + manual. Each run starts the daemon, bakes ALL
plugins (the content-hash skip makes that cheap — only changed pages re-render,
PREVIEW_REMOTE trusts the manifest hash since CI clips live on R2 not on disk),
`aws s3 cp`s the new clips to R2 (no --delete, so untouched clips stay), and
commits the refreshed manifest back to main.
- The daemon now reads the checked-in manifest from data/plugin-previews/ by
default (binaries stay on R2; OD_PLUGIN_PREVIEWS_DIR still overrides locally),
seeded here with an empty manifest so every plugin starts on the live-iframe
fallback until the first bake lands.
Verification needs a real CI run (R2 secrets + a daemon in CI); the bake script,
hash skip, daemon injection, and web display are all already verified locally.
* ci(plugin-previews): open a reviewed PR for the manifest instead of pushing to main
Protected main can't take a direct push, and the manifest is version-pinned
(ships with the build), so the bake now opens a PR with the refreshed
data/plugin-previews/manifest.json and requests review from @lefarcen rather
than committing straight to main. Only fires when a plugin actually changed.
* ci(plugin-previews): fix invalid YAML — single-line PR body (@ in body broke the literal block)
* ci(plugin-previews): TEMP branch trigger + debug guards (limit 3, skip publish off-main)
* ci(plugin-previews): install puppeteer-core via pnpm (npm chokes on workspace:*)
* ci(plugin-previews): correct R2 secrets (repository-assets bucket) + TEMP branch publish test
* ci(plugin-previews): revert temp branch-debug toggles (workflow verified in CI)
* fix(plugins-home): gate bakedPreview on a fetchable source + fix workflow shellcheck
Review feedback (nettee):
- bakedPreviewBlock now only attaches a baked preview when a remote origin
(OD_PLUGIN_PREVIEWS_BASE_URL) is set OR the clip files exist on disk. A
deployment reading the checked-in manifest without the base URL set would
otherwise emit /api/plugin-previews URLs that 404 (binaries live on R2),
breaking tiles instead of falling back to the live iframe.
- workflow: `for _` instead of unused `for i` (SC2034) and split the CHROME
declare/export (SC2155) so the actionlint gate passes.
* fix(plugins-home): log manifest load failures instead of swallowing them
Review feedback (nettee, non-blocking): loadManifest() caught every read/parse
error and returned {}, so a malformed manifest would silently disable all baked
previews with no trace. Warn so it's diagnosable.
* feat(plugins-home): use baked previews on the example-prompt preset tiles too
The HomeHero '示例提示词' preset tiles render the same plugin previews via
PreviewSurface; pass preferBaked so they get the cheap poster + hover-pan clip
instead of a live iframe, matching the gallery.
* ci(plugin-previews): grant pull-requests: write so the manifest PR step can open its PR
Review feedback (nettee): the permissions block only set contents: write, so
pull-requests defaulted to none and gh pr create would 403 on the first run that
changes the manifest.
---------
Co-authored-by: audit <a@b.c>
|