mirror of
https://github.com/nexu-io/open-design.git
synced 2026-10-27 09:51:12 +01:00
fix/misc-bugs
301
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
41027b6ccf | Merge main into share-comment integration preserving motion and share lifetimes | ||
|
|
93d7fb811b |
fix(comments): synchronize file lifetimes across member and public shares (#8764)
* fix sharing to keep file publication independent of team visibility (OPEND-3855) * fix(comments): synchronize current file lifetimes across members and shares OPEND-3870 / OPEND-3871. Stack on published OPEND-3855 dependency; preserve reviewed source tree exactly. Local validation does not establish PostgreSQL or native acceptance. * refactor sharing to one explicit policy path (OPEND-3855) * fix(collab): adapt comment sync to converged project lifecycle * test: isolate metadata snapshots from the trailing identity directive * fix(collab): preserve nested publication and pulled file authority * fix: align collaboration consumers with explicit project visibility Carry trusted catalog roots through content receivers and suppress stale Team rows after explicit privacy changes. Read shared design-system brand assets from their resource snapshots. Align stopped-publication ordering and protocol/browser fixtures with the existing contracts. * fix(collab): preserve relay identity and retire captured file batches atomically * test: await explicit comment preparation and independent UI states Complete the existing source-version consent and real frame-readiness flow before opening the comment sidebar. Bind the response waiter to the UI actions so failures retain their real cause. Await the independently loaded account callout and assert exact catalog-bound materialization wiring. * test: expect private policy when unsharing a team project Await the explicit visibility-private command instead of deleting the retained root registration. Assert that remove was not invoked and preserve all member quarantine, directory and file-read privacy checks. * fix(collab): retain the actual native root during mirror materialization * fix(collab): fail closed on unbound active share identity * fix(collab): retire comment lifetimes after local mutation commits * fix(collab): commit local artifact invalidation with working-root changes * fix(collab): reconcile committed root retirement without stopping new links * test(collab): align CI fixtures with admitted file identities and receipts * test(collab): model receipt-backed root file admission in the shared hub * test(collab): distinguish bound and unbound publication recovery |
||
|
|
615c1a72bc |
fix sharing to keep file publication independent of team visibility (OPEND-3855) (#8759)
* fix sharing to keep file publication independent of team visibility (OPEND-3855) * refactor sharing to one explicit policy path (OPEND-3855) * test: isolate metadata snapshots from the trailing identity directive * fix: align collaboration consumers with explicit project visibility Carry trusted catalog roots through content receivers and suppress stale Team rows after explicit privacy changes. Read shared design-system brand assets from their resource snapshots. Align stopped-publication ordering and protocol/browser fixtures with the existing contracts. * test: await explicit comment preparation and independent UI states Complete the existing source-version consent and real frame-readiness flow before opening the comment sidebar. Bind the response waiter to the UI actions so failures retain their real cause. Await the independently loaded account callout and assert exact catalog-bound materialization wiring. * test: expect private policy when unsharing a team project Await the explicit visibility-private command instead of deleting the retained root registration. Assert that remove was not invoked and preserve all member quarantine, directory and file-read privacy checks. |
||
|
|
c27fca2723 |
fix(comments): persist new static comment identities (OPEND-3516) (#8768)
* fix(comments): preserve authored identities through manual and AI edits * fix(comment): prepare durable static source identities (OPEND-3516) * fix(comments): integrate durable identity lifecycle (OPEND-3516) * fix(comment): validate authored HTML identity tokens (OPEND-3516) * fix(comment): mask raw text before authored tokenization (OPEND-3516) * fix(comments): enforce prepared document and publication barriers (OPEND-3516) * fix(comments): revalidate pending documents and guard updates (OPEND-3516) * fix(comments): require app-owned source preparation consent (OPEND-3516) * fix(comments): contain preparation consent focus (OPEND-3516) * fix(sharing): diagnose project bootstrap failures safely (OPEND-3516) * fix(comments): retire stale publication guard alerts on explicit retry (OPEND-3516) * fix(comments): locate Personal identities in the live preview (OPEND-3516) * fix(comments): surface lost Owner identity anchors (OPEND-3516) * fix(comments): unblock publication after strict target loss |
||
|
|
ae321ab2e6 |
fix(cms): fence offline replay across revocation and process death (#8676)
* fix(cms): fence offline replay across revocation and process death * feat(cms): define paginated Test catalog and immutable runtime context * fix(cms): synchronize Test and Production host presentation lifecycles * test(cms): retain cross-host lifecycle and browser acceptance regressions * fix(cms): retain request tickets through body classification --------- Co-authored-by: heggria <bshengtao@gmail.com> Co-authored-by: ivy-tinggg <986810707@qq.com> |
||
|
|
535fa8eab7 |
fix(amr): restore Home funding checks and decouple run feedback (#8688)
* fix(web): surface live AMR funding failures without delaying sends * fix(amr): check funding independently of agent startup * chore(amr): pin Vela funding feedback test package * fix(amr): route early funding hints through isolated CLI command * fix(amr): restore scoped Home funding cache gate * fix(amr): restore Home wallet and plan memory gate * test(amr): align send audits with independent funding checks * chore(amr): bundle Vela CLI 0.1.5 for funding checks --------- Co-authored-by: lefarcen <935902669@qq.com> |
||
|
|
14fee234e6 |
feat: add motion design workflow and curated motion skills (#8619)
* feat: add Motion Design skill and OD Next task route
* fix(web): rename motion design entry and place it second
* fix(web): reconcile motion design catalog consumers
* feat(web): add motion design templates to Home and Community
Five motion design example templates (3D procedural island, explainer,
product launch, showreel, data story), chosen by community reach, under the
Home motion-design chip and a new Community "Motion design" type.
Each template follows the bundled example layout: an OD-generated
example.html with its runtime libraries under assets/, an HTML preview baked
by the existing preview pipeline, and a SKILL.md that carries example.html
as the reference when the card is picked on the OD Next motion-design route.
Home cards ship curated copy for every UI locale. Motion templates stay off
the Video and HyperFrames chips.
* fix(skills): keep motion-design procedural layers visible in video export
The MP4 exporter seeks timelines with callbacks suppressed
(renderSeek(t, { suppressEvents: true })), so content drawn only from a GSAP
onUpdate callback is missing from exported video. Recommend a per-frame draw
loop that reads the timeline clock, and state that visible content must not
depend on timeline callbacks. The OD Next task asset is re-synced with
scripts/sync-motion-design-skill.ts.
* fix(motion): preserve reference assets and exported animation frames
* feat(skills): integrate curated motion helpers with complete runtime files
* feat(skills): add Huashu, Oil Motion and Motion.Lab adapters
* test: classify animate design guidance in narration guard
* feat(motion): refine five community templates and portable previews
* fix(motion): refine launch transitions and center gallery previews
* fix: allow free-form briefs on automatic motion task route
* feat(motion): add template soundtracks and audio export
* fix(motion): require rendered MP4 alongside editable video source
* test(motion): align strategy activation expectation with package version
* fix: scope motion delivery and make editable sources playable
* fix(motion): bundle source dependencies and valid closing protocol
* feat(motion): rebuild the five templates on the most-viewed community posts
Re-select topics by single-post views in the latest awesome-opus-5.5-video
snapshot and anchor each template to the top eligible post of its genre:
camera-focus explainer, showreel, product launch, procedural island and a
new wordless animated short that replaces the data story.
All five example.html files are hand-authored canvas/Three.js films drawn by
one render(t) from the GSAP master timeline, so preview, scrubbing and MP4
export produce identical frames. Each ships an original procedural
soundtrack, updated SKILL.md and 19-locale copy; Home order follows reach.
* fix(plugins): load bundled fonts in plugin previews
Plugin previews blocked every bundled font: the preview CSP had no font-src,
and relative url() references inside inline <style> blocks kept pointing at
the preview URL instead of the plugin folder. Allow same-origin fonts, map
inline-style url() references through the existing asset route (escapes,
absolute and data: URLs stay untouched), and serve font files with font
MIME types.
* feat(community): make motion template Use match the Home example pick
Community 「做同款」 on a motion design template now binds it as that type's
own example card, carrying the type's project metadata, so the run takes the
same OD Next motion-design route as a pick from Home's motion design list and
delivers the MP4 together with the editable source. Other task types keep
the existing plugin-pinning hand-off.
Community use clicks now record use_source (card | detail_modal) and
use_action (use | use_with_query); the details modal Use is tracked for the
first time. The Home hand-off no longer records a second community_gallery
click for uses Community already tracked.
* fix(plugins): count the five motion templates in the official registry metadata
* fix(media): keep HyperFrames audio filters compatible with FFmpeg 4.1
CI's bundled Linux FFmpeg 4.1 rejects adelay all=1 and amix normalize=0.
Make each track stereo and delay both channels explicitly, skip amix for a
single track, and undo amix averaging with a gain for several tracks.
* fix(motion): keep template previews bakeable on slow CI renderers
CI's software-GL baker skipped the 3D island (preview readiness timeout):
under slow frames GSAP lag smoothing slowed the timeline, so the 8 s
poster frame never arrived within the baker's 15 s wait. Play every
template on the wall clock and move the island poster to 6 s. With
simulated 300 ms frames all five now report ready within 10 s.
* feat(motion): add the kinetic typography template
Template selection now follows the motion design spec's four content types
(brand and product films, kinetic typography, explainer animation, procedural
art). Kinetic typography takes the fifth slot, replacing the animated short.
The new template is a 15-second, 120 BPM type film with an original
soundtrack and 19-locale copy.
* fix(motion): let the project motion player fit template stages
Inside a project the motion source player wraps the composition, fits it and
adds its own controls. The templates also scaled their stage, so editable
sources from Remix and Community use rendered at a fraction of the preview.
Templates now keep their own fit and controls only outside that player.
* feat(motion): restage the 3D island as a midday lagoon
The island moves from golden hour to a bright tropical midday closer to its
anchor post: a shader lagoon coloured by a baked depth map with ripples,
shallow-water shimmer and shore foam, a wide white-sand beach, palms and
instanced bushes, a village with a pier, soft sun shadows and a lower
camera path. The CPU wave mesh is gone, so frames cost no more than before.
Copy is updated in all 19 locales.
* fix(motion): keep each soundtrack inside the composition root
A Community use regenerated the island source tail and dropped the root
<audio> element, so its MP4 had no sound. Templates now place the element
inside the composition root next to the stage.
* docs(motion): state the template selection rule as complete works
* fix(motion): enforce video clocks and bound content verification
* feat(motion): replace the motion templates with remakes of viral posts
The five Motion design templates are now frame-faithful remakes of viral
motion posts, so each preview shows the look users came for: the 15-second
motion designer showreel, a high-energy motion reel (copy set in English), a
one-shape UI motion piece, a pixel-art wizard and a 20-second electric-blue
tech reel. Each ships open-licensed fonts, an original soundtrack, the
creator's published prompt where one exists, and credits its source post.
The explainer, product launch, 3D island and kinetic type templates are
removed. Tests follow the new set; a backward-seek check now covers hidden
WebGL layers.
* feat(motion): feature The Future Is Already Here first and start preview sound with the film
* chore: ensure PR CI
* fix(motion): match The Future Is Already Here controls and reorder the motion templates
* fix(motion): make the one-shape UI preview controls legible
* fix(motion): hide The Future Is Already Here controls inside the project player
* fix(motion): plan suitable sound and synchronize source audio playback
* feat(motion): keep remake soundtracks by default and fade exported audio where it stops
* test(motion): cover audible source controls in Chromium
* fix(test): use explicit ESM imports in audio player regression
* fix(motion): align preview track discovery with video export
* feat(motion): autoplay looping HTML without a player bar
* fix(test): consume the built motion playback contract
* docs(motion): state explicit soundtrack rules for make-same and the music line in replies
* chore(motion): version the audible looping delivery profile
* docs(motion): let template sound rules lead the general policy and freeze the audio contract
* fix(motion): scope autoplay to declared video motion sources
* fix(motion): reject audible MP4s under an explicit silent contract
* docs(motion): drop the routine music note from make-same replies
* test(motion): align delivery regressions with looping source playback
* fix(motion): isolate preview and audio export from legacy HyperFrames
* test(motion): retain the existing desktop legacy audio limitation
* fix(motion): suspend audio when previews leave the active workspace
* feat(motion): replace two motion templates with a product promo and a visual lesson; sign the showreel as Open Design
* feat(motion): give the showreel the Open Design palette and a full-size stacked wordmark
* feat(motion): give the product promo a livelier mascot girl
* refactor(motion): rebuild The Future Is Already Here as readable, editable scene code
* fix(motion): pick gallery cover frames that survive the baker's capture delay
* feat(motion): sharpen the showreel opener, depth, truchet and end card
* feat(motion): make the product promo mascot talk, add a speaking score and the flow pop
* feat(motion): give the visual lesson the original's paper-collage texture
* fix(motion): bundle gallery covers for fresh branch checkouts
* feat(motion): feature and refine Bring Ideas to Life
Adapt the paper story around OpenDesign capabilities, with short rounded hands, clearer timing, continuous paper actions, and a larger audience invitation. Feature it first in Motion and synchronize localized names, metadata, cover, guidance, and regressions.
Keep product promo audio music-only with sparse percussion.
* fix(motion): stabilize artifact cards and capture declared covers
* fix(web): share creation type order with community
* test(motion): exercise audio controls through project UI lifecycle
* fix(motion): scope live cover behavior to actual video sources
* test(motion): use real HEAD responses in cover regressions
---------
Co-authored-by: bone3deep1962-collab <bone3deep1962@gmail.com>
Co-authored-by: William <zhouhaoran@refly.ai>
Co-authored-by: open-design-crew[bot] <4197780+open-design-crew[bot]@users.noreply.github.com>
Co-authored-by: NJUHua <113895241+NJUHua@users.noreply.github.com>
Co-authored-by: Amy <1184569493@qq.com>
|
||
|
|
5a0ec91e16 | Merge main into share-comment branch and resolve conflicts | ||
|
|
a1e475d75c |
fix(share): 修复超限评论恢复与资源读取失败,补验四项验收问题 (#8721)
* fix(share): recover oversized comments and reject unreadable dependencies * fix(share): close real export completion and cloud failure gaps * test(share): align export and delete relay regression assertions * fix(export): handle interrupted ZIP saves in preview actions --------- Co-authored-by: lefarcen <935902669@qq.com> Co-authored-by: a1chzt <chizblank@gmail.com> |
||
|
|
014bffbf77 |
fix(collab): synchronize owner devices with overwrite consent (#8683)
* fix(collab): synchronize owner devices with overwrite consent * docs(collab): add owner device synchronization QA handoff * chore(collab): move delivery reports and screenshots to Plane * fix(collab): preserve owner pull fallback and ghost heal Generated-By: looper 0.16.1 (runner=fixer, agent=codex) * fix(collab): probe null owner heads for retractions Generated-By: looper 0.16.1 (runner=fixer, agent=codex) * fix(collab): preserve manual checks across coalescing Generated-By: looper 0.16.1 (runner=fixer, agent=codex) * feat(collab): hide update check behind a dismissed-version badge Show the header entry only when the owner skipped a cloud version, as a "Cloud update available" badge that re-opens the overwrite dialog. Rewrite the dialog description in plain product language. * fix(collab): clarify device sync copy and place entry before present * fix(collab): silence update reminders without automatic overwrite --------- Co-authored-by: Looper <looper@noreply.github.com> Co-authored-by: elifive555555 <296440099+elifive555555@users.noreply.github.com> |
||
|
|
424f5c68cc |
fix(od-next): remove output-contract gates and unblock continuation (#8356)
* fix(od-next): continue plans with keyed markers and honor explicit deliverables * fix(od-next): retire output contracts and hand off continued tasks * fix(od-next): record round settlement and recover production cold * fix(od-next): resume marker sessions with context deltas * fix(od-next): separate completion facts and honor plan-only intent * fix(od-next): preserve physical failures and legacy chat compatibility * test(web): align run recovery fixtures and await campaign mount * fix(od-next): tolerate terminal production marker formatting * fix(od-next): unblock follow-ups after a stop or unreadable history (#8423) Treat a previous task this build cannot read as absent so the follow-up starts a new task, and stop counting a Run with a pending cancel as holding its conversation. Add end-to-end coverage for the marker continuation flow and restore the remote Workspace directory gate cases. * docs: clarify focused local validation Keep local daemon and web validation scoped to affected files and integration boundaries. Reserve full suites for PR CI unless the user explicitly requests them. * fix(web): scope folded task completion to its final run Keep the final physical Run key and conclusion together when OD Next history is folded or accumulated live. Reuse the existing predecessor offsets while retaining the full display history and remaining Todo snapshot. Add regression coverage for completed and unfinished production, live continuation, multiple Runs, legacy history, wrong and quoted markers, question handoff, and failed or canceled Runs. Validated the parent regression before the fix, 165 focused Web tests, workspace typecheck, and guard. * fix(od-next): compact historical context without restricting clarification Reuse confirmed artifact summaries for old assistant history, preserve cross-agent plans and original requests, and represent earlier questionnaires as neutral historical information without inferring answers. Keep user replies, skips, changed answers, unpersisted bodies, examples, and unfamiliar content intact. Do not add question repetition checks, output gates, repair rounds, or limits on clarification. Validated red regressions, shared context and Web provider tests, focused handoff/resume/cold-production coverage, real server and fake CLI input, workspace typecheck, and guard. The repeated-question server fixture remains successful. --------- Co-authored-by: bone3deep1962-collab <bone3deep1962@gmail.com> Co-authored-by: Cheems <hihicheems@gmail.com> Co-authored-by: Cheems <94773058+itscheems@users.noreply.github.com> |
||
|
|
531327bdef |
fix(share): preserve safe publish errors and actionable messages (OPEND-3796) (#8709)
* fix(share): preserve safe publish failure details and actionable messages * test(share): align failure copy expectations and cover classified messages * fix(share): retain Button binding required by CI merge |
||
|
|
dc8b240c8e |
Merge pull request #8652 from nexu-io/fix/share-pin-snapshot-contract-3646
fix(contracts): define atomic share pin snapshot metadata |
||
|
|
f103d882d9 |
Merge pull request #8650 from nexu-io/fix/share-anchor-limits-3646
fix(contracts): bound share anchors at original selector lengths |
||
|
|
8bdc7606af | fix(contracts): define atomic share pin snapshot metadata | ||
|
|
fb75543ded | fix(contracts): bound share anchors at original selector lengths | ||
|
|
76bac57bd9 | fix(share): expose owner-scoped persisted stop tasks | ||
|
|
df2ebd6b57 |
fix(web): keep late memory cards in their source conversation (#8065)
* fix(web): keep late memory cards in their source conversation * fix(web): preserve memory polling window ownership across retries * fix(web): preserve late memory attribution across consecutive turns --------- Co-authored-by: lefarcen <935902669@qq.com> |
||
|
|
db5316b020 |
fix(daemon): walk nested HTML in share/deploy plans and refuse root index.html conflicts
Referenced .html/.htm files (iframes, frames) are now walked like the entry: their references resolve against their own directory at any depth, so their stylesheets, images and child pages ship and missing/invalid ones are reported. Share mode rewrites their root-absolute references relative to their own directory and maps references to the entry to the published index.html; standalone deploy keeps their bytes. Parsing is deduped on each file's real path, which bounds the walk even with in-project symlink loops (x -> .) and also fixes the existing unbounded CSS @import recursion through such links. A reference to an existing root index.html while sharing another page no longer overwrites the entry: the plan records the conflict (referrer file, attribute, line, suggested free name) and deploy refuses it with 409 ENTRY_INDEX_CONFLICT carrying a copy-to-agent fix prompt. |
||
|
|
190750efd3 |
Merge remote-tracking branch 'origin/feat/share-comment-p0' into feat/share-sync-seq-od
# Conflicts: # apps/daemon/src/collab/collab-cloud-service.ts # apps/daemon/src/collab/comment-relay-outbox.ts # apps/daemon/src/collab/vela-cli-collab-client.ts # apps/daemon/src/collab/vela-share-binding.ts # apps/daemon/src/collab/vela-share-publish.ts # apps/daemon/src/integrations/collab-cloud.ts # apps/daemon/src/routes/collab-sync.ts # apps/daemon/tests/public-share-origin-http.test.ts # apps/daemon/tests/public-share-publishing-fixture.ts # apps/web/src/components/FileViewer.tsx # apps/web/src/components/share/ShareTab.tsx # apps/web/src/providers/registry.ts # apps/web/tests/components/FileViewer.share-tab-extraction.test.tsx # apps/web/tests/components/FileViewer.test.tsx # apps/web/tests/fixtures/share-tab/share-panel.team.html # packages/contracts/src/api/share.ts |
||
|
|
2e5903cc9e |
feat(share): take the canonical share link from AMR
The daemon no longer builds the public share address from a hardcoded or
per-channel Viewer origin. AMR reports it: `vela share publish|bind|resume|
project-status --json` carry `url` (https://<share origin>/artifact/{projectId}/{slug})
for an active binding, or `link: {status: "unavailable", code}`.
Link rule, one place (resolvePublicShareLink): an explicit
OD_SHARE_VIEWER_URL(S) override wins (a present invalid override is
unavailable, never a fallback); otherwise the AMR address, accepted only when
it is byte-for-byte the canonical HTTPS root-origin address of this project
and slug; otherwise unavailable, passing AMR's code
(PUBLIC_SHARE_WEB_URL_UNAVAILABLE / PUBLIC_SHARE_IDENTITY_INVALID) through.
A rejected address is logged without echoing it.
The verified AMR address is persisted with the publication and refreshed on
publish, foreground bind/resume, the background binding retry and every
publish-public status read, without rotating the publication witness. When
the status read fails, GET falls back to the persisted link marked
`stale: true` (not for a queued stop); a stored legacy console URL fails
re-verification and stays hidden until the next online read replaces it.
Packaged daemons no longer get a per-channel Viewer origin and tools-dev has
no default; an explicit launch override is still forwarded. `od project share`
prints the same link, a stale notice on stderr, and says when an alias has no
Viewer address.
|
||
|
|
0facf3904b |
fix(daemon): order synced comment revisions by cloud seq so replays never undo newer state
A share-page comment resolved at cloud seq 10 reverted to open after a daemon restart: the legacy pull cursor is in memory, so the restarted daemon replays from seq 0, and the merge stamped Date.now() on the share-page create (which carries no updatedAt), making the replayed create look newest. Each preview_comments row now records the highest cloud revision seq it reflects (cloud_revision_seq) and the seq space it came from (cloud_revision_stream: relay team + member stream epoch). An incoming revision at or below that seq in the same stream is a replay and never overwrites the row. Unseen revisions keep updatedAt last-writer-wins when they carry an updatedAt; one without updatedAt applies in seq order and is stored with its createdAt, never the local clock. pin_seq is untouched. |
||
|
|
8e2c355a1c |
fix(daemon): make a published private team project team-visible before its comment backfill
Publishing a public link for a private project in a team workspace registers it in the team catalog, but only the later background reconcile marked the local row team-visible. Until then the row was personal in a team workspace, which is neither Team- nor personal-relayable: the publish backfill was cancelled (BACKFILL_DELIVERY_DISCARDED) and comments written right after publishing were never queued. - publish-public marks the creator's private row team-visible right after catalog registration, before the publication transaction enqueues the backfill (decision 67 #11); the response carries madeTeamVisible. - The relay re-queues a personal record as Team relay once its creator's project turned team-visible, and defers (bounded) a personal record in a team workspace whose publication is still current instead of cancelling it. - A cloud seq is never adopted as pin_seq when another confirmed row of the file already holds it, which produced two pins numbered 1. |
||
|
|
acc655fc13 |
feat(daemon): resolve share links only from the Viewer origin
Public share links come from OD_SHARE_VIEWER_URL(S) (HTTPS root origin)
as https://<viewer>/artifact/{projectId}/{slug}; the console origin is
no longer a fallback. GET publish-public derives the URL from the
resolver instead of the stored row, so pre-upgrade console URLs are
never echoed, and reports link unavailable plus the slug when no Viewer
origin is configured. Remotely stopped rows are not live. Manual-revoke
recovery data carries projectId and a nullable url. od project share
prints the slug in the unavailable state. Test fixtures move from
OD_VELA_WEB_URL to OD_SHARE_VIEWER_URL.
|
||
|
|
30361fd670 |
fix(share): align remaining owner share copy and K1 sync count with final design
- S0: signed-out description uses the design copy via its own key; the publish-failure message keeps its original text. - S5: update notice reads 「文件有新改动,访问者看到的还是上一版。」. - K2/K5: the failed-sync notice drops the 「分享已发布」 title and uses the design copy; terminal variants keep their own wording. - K1: first-publication sync shows 「同步已有评论 · N 条」. The backfill state carries optional total/synced from the daemon's backfill members table, driving a determinate bar when progress is known; older daemons fall back to the previous copy and the indeterminate sweep. - Refresh the PR screenshot of the Share popup comment-sync state. |
||
|
|
c2873e03cf |
fix(share): apply second-round final design review to owner share UI
- Share panel: remove every yellow warning; show the public link row above the copy button in published states; oversized shares stay blocked and explain why in the red error row. - Global toast for update success/failure and residual stop failure, with final design icons and copy; share panel buttons match the design. - Comment sync: K1 turns the primary action into the sync progress bar, K4 shows a busy "正在同步评论…" button, K5 retry is red. - Deployment menu layers above the share popover; export-complete guide rebuilt to the final design. - Share bridge pin label accepts exactly one letter so a CJK author's initial can label their pin. - Update ui-audit capture specs for the intended copy/structure changes. |
||
|
|
0753dc23c7 | fix(share): align owner sharing flows with final UI audit | ||
|
|
eb27acd322 |
feat(billing): support Coding Plan usage and wallet fallback (#8339)
* feat(billing): support Coding Plan usage and wallet fallback * fix(web): keep AMR send off billing preflight Workspace send no longer waits on advisory vela billing preflight; recovery still requests it. Realign zero-wallet P0 oracles and the credits-card wallet label. Generated-By: looper 0.15.0 (runner=fixer, agent=omp) * fix(billing): serialize recovery preflight and bound hung vela Skip overlapping AmrBalanceDialog recovery ticks and stop the watch after ten minutes even if a preflight is still pending. Give vela billing preflight a 60s runVelaCommand timeout so a hung CLI cannot pin recovery. Generated-By: looper 0.15.0 (runner=fixer, agent=omp) * fix(web): restore coding plan text contrast in billing card * feat(web): render Coding Plan windows and Go tier in the credits panel - CodingPlanUsage renders one row per preflight window (name, used %, remaining USD, reset countdown + absolute time) with loading/unavailable states; refreshes once on mount and once at resetsAt (no 30s polling, setTimeout delay clamped to the 32-bit limit). - Pure model in coding-plan-usage-model.ts; creditsPerUsd comes from `vela billing summary` through WorkspaceBillingSummary and falls back to 10,000 only when the backend omits it. - PlanWordmark/EntryNavRail accept the `go` tier (placeholder wordmark, design asset still pending). - i18n: 7 new billing keys + entry.billingTierGo across all 19 locales; real translations for the #8339 keys; drop unused codingPlanNone / codingPlanRemaining. - CLI wording: unstarted window reads "not started yet". * build(pack): pin vela-cli 0.1.4-test.0 and gate on billing preflight The packaged client needs `vela billing preflight` for the Coding Plan window rows. Bump the bundled CLI to the npm `test` prerelease that ships it and add a third capability gate in tools-pack: a CLI without the subcommand prints the parent `billing` help and exits 0, so the gate checks the help markers (preflight, --workspace-id, --model, --format) instead of the exit code. TEMPORARY: replace 0.1.4-test.0 with the stable 0.1.4 once it is released; do not merge with a `test` prerelease pinned. * fix(web): make Coding Plan rows legible on the inverted credits card The credits card is a hard-coded #202020/#fafafa inverted surface, but CodingPlanUsage.module.css read page-level --text-* / --bg-fill-* tokens, so on the card the window names rendered #202020 on #202020 (1.00:1) and the stat/reset lines sat below WCAG AA. Derive every colour from currentColor instead (name 100%, stat 72%, reset/note 60%, separator ~50%, track 36%, exhausted = --red mixed toward currentColor); measured in Chrome: name 15.6:1, stat 8.7:1, reset 6.4:1, track 3.2:1, exhausted 6.5:1. Keep the stat on one line (nowrap) and let the name ellipsize instead of wrapping "60% / used" onto two lines; drop the block's 12px side padding so it aligns with the card's own rows. Also add a minutes tier to the reset countdown: the last hour before a window resets used to show no countdown at all. New key billing.codingPlanResetsInMinutes in all 19 locales. * feat(web): rebuild the plan panel 1:1 from design PR #8364 Replace the improvised Coding Plan block with the design's panel, measured against docs/ui-previews/plan-panels/index.html in Chrome (computed styles and a 4x pixel diff of the Plus specimen): - Header: tier name + 16px wordmark, 升级 pill (管理 on the top tier). - One row 「7天额度免费用 · 已用 N% ›」 (7-day window only; rounded percent; 100% when spent, no red state) over a 5px #b1f38b track, then the separator and the 「钱包余额 · US$x.xx ›」 row. No headings, window names, remaining USD, reset time, note or unavailable line. - Team workspaces keep the pre-existing card unchanged (all team_* tiers); the quota row renders only for personal workspaces. - Free personal workspace: no quota row, but the header sells 升级 like the design (a personal workspace needs no billing permission; an account B reports without a plan upgrades as free). - Top-right pill shows the wordmark only, no balance. - Wallet always reads US$ regardless of locale (product ruling 2026-09-23). - zh-CN tier names follow the design: 免费版 / 标准版 / 专业版 / 旗舰版. - Drop the dead billing.codingPlan* keys and model helpers; add billing.codingPlanWeeklyAllowance and entry.creditsManage in 19 locales. * feat(web): show 管理 on personal Max per design PR #8364 The design's Max card sells 管理 (console auto-recharge settings) where every other tier sells 升级; product ruled on 2026-09-23 to follow the design for personal Max too, superseding the earlier "personal tiers are always upgradeable" ruling. The gate now asks isMaxPlanTier and 管理 wins over 升级 when both would show. * feat(web): route the plan card's three entries per product ruling 2026-09-23 ruling on the card's landings: 升级 opens the console's pricing dialog (billing=plan, unchanged), the allowance row opens the console dashboard (unchanged), the wallet row asks for the manual top-up dialog (new billing=recharge intent; B does not honor it yet and degrades to the dashboard), and 管理 on Max opens the plain dashboard instead of the auto-recharge dialog. * fix(web): align personal plan cards with v2 design and scoped loading * fix(web): center plan wordmarks inside the top-right pill * fix(web): reuse shared billing cache for plan hover cards * test(web): match personal billing preflight in avatar fixture * fix(billing): require usable quota evidence for funding recovery * test(billing): cover scoped preflight and quota event refresh end to end * feat(daemon): relay scoped coding plan usage events and health * fix(billing): refresh quota caches from scoped realtime events * test(daemon): accept capability query on mock event stream --------- Co-authored-by: lefarcen <935902669@qq.com> |
||
|
|
7d4a90b7b4 |
Merge branch 'feat/share-lane1-publish' into feat/share-comment-p0
# Conflicts: # apps/web/src/providers/registry.ts |
||
|
|
0e05dc07e6 | refactor(share): name the unpublish response contract | ||
|
|
01d0e8e0e0 | Merge branch 'feat/share-lane4-inbound' into feat/share-comment-p0 | ||
|
|
a70aa4b931 | feat(comments): expose explicit visitor comment pull to UI and CLI | ||
|
|
8cc78bec90 |
Merge remote-tracking branch 'origin/main' into feat/share-comment-p0
Brings in #8402 (publish/deploy failure classification). Conflicts were resolved so the share-based publish flow keeps its behavior and gains the additive telemetry: - collab-sync.ts: publish/unpublish keep publicFileMutationHandler and the share publish/stop pipeline; they gain startedAt/requestId, stage tracking (push -> snapshot [share publish] -> persist), classified `failure` on the 502 responses and the structured failure log line. The compensated persistence failure also carries { stage: 'persist', reason: 'internal' }. The unused readVelaControlApiContext import stays removed. - FileViewer.tsx: publish progress timer and per-attempt request id both kept. - Tests: the upstream failure-detail tests are ported to the share flow (store stubs gain getRevision/deleteIfRevisionMatches; unpublish failure is driven through `share stop`; the empty `resource snapshot` case has no equivalent on this flow and is dropped). The deploy failure test reaches the provider through Share -> More sharing options, as the share panel requires. |
||
|
|
742a85b3d8 | Merge remote-tracking branch 'origin/main' into feat/share-comment-p0 | ||
|
|
7bad1a45f2 |
feat(telemetry): classify publish and deploy failures additively (#8402)
Publish-public and deploy failures were only visible as one opaque bucket
each (`publish_failed`, generic HTTP_400 / Error), so neither PostHog nor the
automatic diagnostics bundles could tell why they failed.
Purely additive — no existing status, error code, event value, trigger or
copy changes:
- daemon: failed publish/unpublish/deploy responses gain an optional
closed-token `failure` { stage, reason, upstreamStatus?, upstreamCode? }.
Vela CLI failures are classified from the CLI's stable stderr contract
(vela-cli 0.0.33–0.1.3); deploy failures separate provider rejection,
unreachable provider, rejected Vercel token, local file and internal
errors. Each failure also writes one structured, text-free log line.
- web: artifact_publish_result / artifact_deploy_result gain optional
failed_stage, failure_reason, upstream_status, upstream_error_code, and
(publish) daemon_error_code. `error_code` keeps its values.
- correlation: each attempt's analytics request id is sent as
x-od-request-id, logged by the daemon, and reported as request_id.
|
||
|
|
06e0fd7259 |
fix(cms): keep a campaign on screen through network, sleep and clock noise (#8292)
* fix(web): page recovery no longer withdraws a valid touchpoint lease (OPEND-3363)
`online`, `pageshow` and `visibilitychange` were all bound to the destructive
`wake()`, which calls `revoke()` synchronously — before the revalidation it
starts has produced any evidence. Switching Wi-Fi, waking from sleep or tabbing
back therefore unmounted a campaign the server had authorized, and the user saw
it vanish and (sometimes) reappear.
The loss could also be permanent. An emptied lease makes `abandonAttempt`'s
`!lease.current` branch true, so the first failed revalidation dropped the saved
`revalidationLease` too; the retry that succeeded came back `{kind:"retain"}`
with nothing left to restore. The impression was already recorded, so the
activity never showed again for that session.
The three recovery events now take the non-destructive shape `focus` already
had, named `resume`: a lease still inside the window the server granted keeps
its mount and revalidates in the background, a lapsed lease falls through to
`wake`, and a hidden page cancels only the request in flight. `armExpiry` still
retires every lease on the server's own deadline, and a 410 withdrawal still
closes at once.
Deliberate behavior-contract change. Three existing cases pinned the old
destructive semantics and are rewritten here with the opposite outcome:
- `withdraws old authority synchronously on wake and rejects a timed-out
revalidation` → `keeps an unexpired visible decision mounted while an online
revalidation is pending`
- `still withdraws display and authority on actual page hiding` → `fences a
hidden page without withdrawing the lease it was granted`
- `fences the old modal action until recovery mounts a fresh decision` → `keeps
the mounted modal action authorized across focus and online recovery`
The badge case additionally drops from three refreshes to two: `online` now
joins the revalidation `focus` already has in flight instead of tearing it down
and re-requesting.
Every new case asserts inside the pending revalidation window, not on the
settled state — the blind spot the four network edge cases this week shared.
* feat(web): let a production touchpoint hold the authority the server grants (OPEND-3366)
`resolveAuthorizationDeadline` takes the minimum of the server's
authorization, the activity's `endsAt` and a local cap. With A3 removing the
sixty-second clamp on `authorizationExpiresAt`, that local cap became the
binding term: a five-minute `MAX_LEASE_MS` truncated every long authorization
back to five minutes, so a client that lost the network went blank in the
middle of an activity that was still running.
The cap now lives once, as `PRODUCTION_MAX_LEASE_MS`, at the longest interval a
timer can name — a backstop against a server clock that grants past the
activity, not a policy. `endsAt` governs, which is what the server means.
`armExpiry` already segments the wait with `Math.min(remaining, MAX_TIMER_MS)`
and is unchanged.
All three production placements (Modal, Badge, Hover) had their own copy of the
five-minute constant, which is exactly how two of them could keep a short lease
after the third was fixed. They now share the one exported value, and a test
reads all three sources to keep it that way.
Depends on OPEND-3363: before it, page recovery emptied the lease before any
failure was evaluated, so a long authorization had nothing to protect.
* feat(daemon): assemble touchpoint content locally instead of re-downloading it (OPEND-3371)
A production decision refresh re-downloaded the whole content package every 30
seconds, although the bytes almost never change. B4 lets a caller say which
content it already holds and get a `contentOmitted` reply, but taking that
benefit in the browser would need a new desktop package — every already-shipped
prerelease would keep paying full price. Putting it in the daemon means the
client does not change at all and existing installs benefit immediately.
The daemon now carries `heldContentId` / `heldContentLocale` on its own behalf,
and puts the omitted `content` back from a local store before answering the
browser. Two layers: blobs addressed by their own digest, so the four
placements of one activity share `shared.js` / `theme.css` once; and one
assembly record per (placementKey, locale) naming the manifest, the entry and
the path → digest map. The root is derived from `RUNTIME_DATA_DIR`, so two
namespaces get two stores and neither can see the other's.
The proxy could stay a pure `source.pipe(dest)` only as long as nobody had to
read the body; this route now buffers and rebuilds the JSON. Everything else —
the Test runtime, the events endpoint, any production request the daemon holds
no content for — keeps the verbatim streaming path.
Two properties carry the design, and each has a named case:
- A daemon holding nothing sends today's request and returns today's response
byte for byte, with no `contentOmitted` anywhere. Published clients guard on
`if (!next.content?.id) return clear`, so breaking this makes campaigns
disappear.
- Nothing behind the daemon may cost the campaign. A miss, a corrupted blob, a
digest that stopped describing its bytes, an unwritable data directory, a
Vela that ignores the parameters — each falls back to exactly today's full
request. A trimmed reply that cannot be rebuilt is re-asked once, without
held content, and that retry can never trim again.
The cache holds no opinion about WHICH activity to show: the server re-decides
that every request, so a stale cache cannot pin a withdrawn activity, and a new
one is still visible within one poll.
Measured on a realistic package (real minified JS/CSS as content bytes):
638.8 KB raw / 190.1 KB gzip for the full response against 0.7 KB raw /
0.4 KB gzip for the trimmed one — the steady-state WAN body is under a
kilobyte per refresh regardless of how large the activity is.
* test(web): pin what a rotating touchpoint decision id costs the client (OPEND-3369)
The server-side measurement can only show that `touchpointDecisionId` differs
per request (120 distinct ids in an hour of polling). Whether that costs
anything is decided on the client, in the lease key: `refresh` bumps
`generation` whenever the key changes, and the mount effects are keyed on
`generation`, so a rotating id is a full teardown and re-mount per poll.
Every existing lifecycle case uses `key: "same"`, so this path had never been
observed. Two pairs of cases observe it now, at both seams:
- `useTouchpointLifecycle`: a rotating key over 120 polls produces 120
generation bumps and a new decision object each time; the same hour with a
stable key produces zero.
- `ProductionCampaignModal`: the same hour produces 121 distinct
`opend-touchpoint` elements and 121 `mount` calls with a rotating id, against
one element and one mount with a stable one.
Tests only; no behavior change. This confirms the client-side benefit claimed
for B3 — 120 remounts an hour become none once the id is stable.
* fix(web): take the one-shot credential out of the touchpoint lease key (OPEND-3374)
All three production placements put `touchpointDecisionId` in their lease key. A
changed key is `++generation`, which is a full teardown: shadow DOM rebuilt,
Blob URLs re-created, entry animation replayed, scroll lock released and
re-taken. But that id is not content identity — it is a credential the server
re-issues whenever its own sixty-second row lapses.
OPEND-3369 stabilised the id and removed the every-thirty-seconds version of
this churn. It did not remove the cause. The credential lives 60s and the client
polls every 30s, so missing two polls — a Wi-Fi switch, a tunnel, a closed lid —
expires the row; on recovery the server INSERTs a new one and the campaign
flickers. That moved the remount from "on a timer, for everyone" to "whenever
the network wobbles", which is the same population the recovery-lifecycle P1 was
about.
The key now answers only the question it exists to answer — same content, same
person, same deployment — through one shared `touchpointContentIdentity`, so the
three placements cannot drift apart.
Hover needed arguing rather than deleting, and the argument came out differently
than expected. Its key carried the ENTRY's content id plus BOTH credentials; the
layer's content id was never in it, so the layer's identity rode on its
credential. Whether a deployment always gives both placements the same
`content.id` is a server-side property this side cannot verify, and this repo's
own hover fixtures assume it does not (each placement gets its own version). So
the layer's content version is now named explicitly. The red test for it —
`still remounts the pair when only the layer's content version changes` — fails
on the previous key by showing the layer still rendering the replaced content.
Retaining the previous decision object means the client presents a credential
the server has expired. Both uses of it are asserted here rather than assumed:
`still clears on a 410 whose receipt echoes the stale decision id the client
kept` covers OPEND-3372, and `reports the stale decision id on a click after the
credential rotated` covers OPEND-3364. Without those two, this change would
become a revocation failure or lost attribution.
Deliberate contract change. The three OPEND-3369 observation cases measured the
cost of a rotating id and are rewritten with the opposite outcome:
- `rebuilds the mounted host on every poll when only the decision id changes` →
`keeps one mounted host for a whole hour of rotating decision ids` (121 hosts
and 121 mounts become 1 and 1)
- `keeps one mounted host for a whole hour when the decision id is stable` keeps
its result and loses its role as a contrast — the two now agree, which is the
point
- the hook-level `remounts once per poll when the decision id rotates ...` is
re-framed as `... when the lease key rotates ...`: the hook never knew about
decision ids, and which inputs change a key is now decided in the placements
OPEND-3369 is not undone — its write-side saving stands on its own and this
returns it to being purely that.
* fix(web): judge a set-aside lease by its own window, not by an empty screen (OPEND-3376)
**The ticket asked for `revalidationLease` to be deleted as unreachable. It is
not unreachable, so this takes the alternative the ticket names instead: fix
`abandonAttempt`'s criterion. See below — this needs a decision.**
The reachability argument was: `wake()` has one call site, `resume`'s `else`
branch, which runs only when the lease is missing or lapsed; so `wake()` can
only ever set aside a lease that is already expired; and the restore branch
requires the set-aside lease to still be valid; so the two are mutually
exclusive.
Every step is true except the last. It needs "expired" to be a property that,
once true, stays true. It is not. `elapsed()` is `max(monotonic, wall)`, so a
wall clock that steps FORWARD makes a live lease read as expired — `resume`
then takes the `wake` branch — and a correction BACK makes it read as live
again. NTP steps, VM resume and dual-boot clocks all do this, and this module
already takes wall-clock skew seriously enough to have a case for it.
Verified with a throwaway probe against this branch's HEAD: step the clock an
hour forward, dispatch `online`, step it back, resolve `{kind:"retain"}` — the
restore branch fires and the campaign comes back. Deleting the mechanism would
therefore have been a silent behavior change, not a no-op.
So the P1 hazard is repaired where it actually lives. `abandonAttempt` decided
whether a failure may end display by asking whether there is an ACTIVE lease,
treating "none" as "expired". `wake` empties `lease.current` before it
revalidates, so the next failure met that branch, spent the set-aside lease too,
and the retry that finally succeeded came back `{kind:"retain"}` with nothing
left to restore — gone for the session. The criterion now judges whichever lease
is still recoverable by its own window.
This is stronger than the deletion would have been. The deletion's safety rested
on `wake()` keeping exactly one call site, which nothing enforced; adding one
back would have reopened the loss. After this, a new `wake()` call site cannot
cause permanent loss at all, because the short-circuit that caused it is gone.
Red first: `no single failure can discard display authority that is still
recoverable` drives the clock excursion, fails one revalidation inside the
set-aside lease's own window, and asserts the retry restores display. It fails
on HEAD and passes here. All 42 pre-existing lifecycle assertions are untouched
and still pass, so nothing was bent to fit.
What is NOT delivered: `revalidationLease` still exists, and so does the
three-state lease machine the ticket wanted reduced to two. Whether to keep the
restore branch (it un-does a spurious withdrawal caused by a bad clock) or drop
it and accept one remount plus up to a 30s gap in that case is a product call,
not one to make inside a "delete dead code" ticket.
* refactor(web): a lease carries content identity, not authorization timing (OPEND-3377)
Since OPEND-3374 a matching key retains the previous decision object, which is
the point — consumers depend on `decision`, so a new reference would re-mount.
But the retained object is the whole response DTO, and three of its fields
describe something that does not survive being retained: `serverTime`, `endsAt`
and `authorizationExpiresAt` are whatever some earlier response happened to
carry. How long display may last comes from the lease's own `validForMs`, taken
fresh every time.
Nothing reads the stale copies today. That is a fact about who has written the
consumers so far, not about the code: the day someone adds `decision.endsAt` to
a countdown they read an end time the operator has already moved, and nothing
fails to tell them. Same species as OPEND-3376 — correct, but resting on an
invariant nothing enforces.
Option (a) from the ticket. `touchpointLeaseValue` drops the three fields and
`TouchpointLeaseValue<T>` says so in the type, and the value really does not
carry them, so a consumer that casts past the type still finds nothing. One
named helper, three placements, matching `touchpointContentIdentity` and
`PRODUCTION_MAX_LEASE_MS`. (b) was rejected because `never`-typed fields still
read as present and still invite `as`.
Chosen for the reason the ticket gives: it states the boundary rather than
patching a symptom. A lease carries a STABLE content identity, which decides
whether to re-mount, and a FRESH authorization window, which decides how long
display may last; these are different things and no longer share a DTO.
The type system then found the rest by itself. `dispatchProductionCampaignAction`
declared it took a whole `Decision` while reading only static actions and
identity — it already receives the window separately as `expiresAt` — and
Hover's `matches` did the same. Both are narrowed; no call site changed.
Evidence for the acceptance bar: adding
`console.log("activity ends at", decision.endsAt)` to the Modal's mount effect
produces
src/components/ProductionCampaignModal.tsx(375,46): error TS2339:
Property 'endsAt' does not exist on type 'AuthorizedDecision'.
Three type-level assertions in the suite keep that true without needing a human
to re-run the experiment: each `@ts-expect-error` is itself checked, so if a
field came back TypeScript would report the directive as unused and `pnpm
typecheck` would fail.
Pure narrowing. Every pre-existing assertion in the lifecycle suite and in all
three placement suites is untouched and still passes, including OPEND-3374's
retained-credential cases, and the web suite is green.
* test(web): pin that a receiptless 410 withdraws display (OPEND-3375)
Vela now answers a request carrying an `activeDecisionId` for a deployment that
has been taken down with `410 production_runtime_withdrawn` and no receipt,
shaped byte for byte like a rollout withdrawal that cannot name one. The client
half of that was explicitly outside its authorization.
The client already handles it correctly, and this locks that in rather than
changing it: the receipt parse is what decides, so any 410 that does not yield
all four fields becomes `http_410`, and `http_410` is the one detail that sets
`touchpointWithdrawal` — the single flag `withdrawsDisplay` reads, and the only
failure permitted to end a lease the server already granted. Every other failure
is transport noise the lease rides out, which since OPEND-3363 it really does
ride out, so a 410 quietly reclassified as noise would now mean a withdrawn
campaign stays on screen for the rest of its lease.
Two cases, and neither goes red first — they are regression locks, not fixes:
- the loader, over the withdrawal body, the rollout body it mimics, a null
receipt and an empty body, asserting `http_410` + `touchpointWithdrawal`
- the Modal end to end through the real loader and lifecycle: a withdrawn
deployment closes the dialog on the next poll, and a later 200 does not bring
it back
* fix(web): a clock step must not re-mount a campaign that never left the screen (OPEND-3378)
Audit of every consumer of `elapsed()` under the non-monotonicity found in
OPEND-3376: `max(monotonic, wall)` rises and falls again when a wall clock steps
FORWARD and is corrected BACK. Ten call sites; nine acceptable, one not. The
verdicts are in the report; the reasoning that survives in code is on `elapsed`
itself, whose old one-line comment ("a forward jump can only shorten it") is
what made the shape sound monotonic in the first place.
The one that had to change is `same`, which decides renewal against re-mount.
It asked whether the previous lease was still inside its window, and a forward
step makes a perfectly live lease answer no. The response arriving during the
step is then a new presentation: `++generation`, rebuilt host, rebuilt Blob
URLs, replayed entry animation — the flicker OPEND-3374 removed, re-entering
through the clock. A3 made leases run for the whole activity, so the window a
step can land in went from a minute to days.
It is also not defensible on its own terms. Nothing evaluates `elapsed` until
something asks, so a step alone tears nothing down (verified by probe): the
campaign is still mounted and still authorized when the poll lands, and there is
no withdrawal for that re-mount to correspond to.
So the question is asked only where it means something: a lease being RESUMED
from `revalidationLease` must still prove it is inside its window, because its
display really was withdrawn. A lease that is still `lease.current` renews. A
mounted lease that has genuinely lapsed is not reachable here — `armExpiry`
retires it, which empties `lease.current`.
Red first: `a clock step forward does not re-mount a campaign that is still on
screen`. It fails on HEAD with `expected 3 to be 2` — one generation too many.
Deliberately NOT changed: `elapsed` itself. The monotonic term stops a clock set
back from granting time; the wall term stops a sleeping device from freezing the
lease, because `performance.now()` pauses across sleep on some platforms. Each
closes a cheat the other does not, so making it purely monotonic or purely
wall-clock would be a regression, and the fix belongs at the call sites that
care about the direction of the error.
Every pre-existing assertion is untouched, including OPEND-3376's
`no single failure can discard display authority that is still recoverable`.
* fix(web): drop the visibility fence whose premise OPEND-3363 removed
#8269 and OPEND-3363 are each correct and together reproduce the P1 they were
both written to remove: a campaign that vanishes on a tab switch.
#8269 added a `visibilitychange` fence releasing the open presentation whenever
the page goes hidden. Its own comment states the premise — "A hidden page ...
withdraws the lease and takes this modal down with it" — which was true when it
was written. OPEND-3363 removed exactly that: hiding now cancels only the
request in flight and leaves the lease, and the modal, alone.
So the fence released a presentation that had not ended. The campaign stayed on
screen with nothing recorded as presenting it, and the poll arriving on return
took `!continuesOpenPresentation && wasDisplayed(...)` with
`openPresentation.current` already null — the `{kind: "clear"}` branch — and
closed the host.
Reproduced before touching anything, on the rebased branch:
keeps a displayed campaign through a tab switch now that hiding no longer
withdraws it
→ TestingLibraryElementError: Unable to find an accessible element with the
role "dialog"
and the failure is on RETURN, not on hide: the modal survives being hidden, so
this is the interaction and not a regression in either change alone.
The fence is removed. What it protected is still protected, by the presentation's
own deadline: it is anchored to the authorization that opened it, so a sleep long
enough to lapse the lease also lapses the presentation, and the offer arriving on
wake is correctly read as a new one. That is asserted rather than argued.
Everything else from #8269 is kept, including the parts this branch was behind
on: campaign hosts confined to the home view, `onError` releasing the
presentation only for a real withdrawal, the `revokesActive` refactor, and
`touchpointWithdrawsDisplay`.
Deliberate contract change. #8269's `does not re-present a displayed campaign
after the page is hidden and shown again` asserted that hiding takes the modal
down, which OPEND-3363 made false. It is rewritten as `does not re-present a
displayed campaign after a sleep outlasts its authorization` — the property it
was actually protecting — with a sleep long enough to be a real one.
* fix(daemon): repair a damaged content blob instead of keeping it forever
`remember` skipped any blob file that already existed. The name is the
digest, so a present file "should" hold exactly those bytes -- but a file
damaged after it was written keeps its name, and `remember` is the only
moment the daemon ever holds the correct bytes for it again. Every other
round it is answering from a trimmed reply that carries no content at all.
The result was a closed loop with no exit and no signal: `held` sees the
files exist and offers the version, Vela trims its reply to ~1KB,
reassembly fails on the digest, the fallback refetches the full ~270KB,
and `remember` declines to overwrite. That placement then pays a trimmed
ask plus a full ask on every single poll -- strictly worse than running
with no cache at all -- and nothing in this module logs or counts it.
Writing unconditionally costs nothing in steady state, because `remember`
only runs on a response that was not trimmed. The rewrite is atomic and
idempotent, so the loop now closes after exactly one round.
The existing HTTP case pinned the defect as expected behaviour: it ran a
single round and asserted two upstream calls. It now runs one round
further and asserts the second round is back to one conditional ask.
* fix(daemon): make a blob's digest a statement about the file itself
`readVerifiedBlob` hashed a decode of the stored file and then returned
the file. Those are not the same bytes. Node's base64 decoder drops every
character outside the alphabet -- anywhere in the input, not only after
the padding -- so anything appended to or injected into a stored blob was
invisible to the digest while still being handed back and forwarded to
the browser.
The handover notes called this harmless on the grounds that the browser
decodes the same way. It does not. `atob` is WHATWG forgiving-base64: it
strips ASCII whitespace and throws `InvalidCharacterError` on anything
else. So the daemon reports a verified package, skips the fallback it
keeps for exactly this case, and the browser refuses to mount it. With
the blob-repair fix this placement would still self-heal in one round;
before that fix the pair was permanent, silent, and invisible to every
integrity check on both sides.
Blobs now store the bytes their digest names and are re-encoded to base64
on the way out, so `sha256(file) === digest` is an exact claim about
everything the cache returns. Modules already worked this way; the two
layers now share one invariant.
Two consequences, both deliberate:
- blobs written by an earlier build no longer match their digest. They
are repaired on the next full response, which is why the blob-repair
fix has to land first -- without it every one of them would be
stranded and cost double forever.
- a rebuilt response now carries Node's canonical base64. It stays
byte-identical to the original only while upstream sends canonical
base64 too, which Vela does today. The warm/cold equality case is the
assertion that will fail if that ever stops being true.
The cache fixture's shared resource was exactly 24 bytes, so its base64
form had no padding and appended garbage folded into the decode and broke
the digest by accident -- the new cases would have passed against the old
code for the wrong reason. It is 25 bytes now.
* fix(daemon): bind reassembly to the version it was negotiated for
`reassemble` took only the cache key, so it reread the record from disk
and rebuilt from whatever that record said at that moment. The record is
mutable: a concurrent full response for the same placement replaces it.
When it did, the daemon spliced the new campaign's content into a reply
the server had framed for the old one -- and nothing downstream could
tell. The browser verifies that the content is internally consistent, and
it is; both halves are. So it renders campaign Y's creative and reports
its impressions against campaign X's decision id. Billing and attribution
cross silently, on a path where every integrity check passes.
The pair this attempt offered upstream is now an argument, and
`recordStillHolds` is the invariant: a reply trimmed against one version
may only ever be rebuilt against that version. A record that has moved on
returns null, which the existing fallback already handles by re-asking in
full.
The locale it compares is the one the daemon OFFERED, not the one it
asked for. Vela resolves a placement locale through [requested, base
language, en-US], so a zh-CN request is legitimately answered and cached
as en-US, and `held` reports that faithfully. Comparing the requested
locale would strand every placement the server answers through a
fallback, which is why a case now pins that too.
Dropping the upstream request on abort ships with it, because the two are
one defect in practice. This proxy had no cancellation at all, while the
AMR proxy a few hundred lines up has had it all along. The browser's
per-attempt budget is 15s and this proxy's is 30s, so every attempt the
browser gives up on stayed alive here for up to fifteen more seconds:
still downloading, still buffering, and still writing the assembly record
that a later, live request was about to rebuild from. `res.headersSent`
does not catch it, because an attempt aborted before a byte went out has
sent no headers. That is what turns the race above from a one-round-trip
window into a fifteen-second one, which is why fixing only the first half
would leave reviewers with the wrong idea of how wide it is.
The fake Vela can now be taken over by a case that needs to control when
a reply lands, not only what it says. It still may not bypass the server's
own rule -- content is omitted only for a caller that already holds
exactly the version being served -- so the crossing above is produced the
way production would produce it: by promoting a campaign mid-flight.
* fix(daemon): put a ceiling back on buffering and decompression
Before content assembly this route forwarded production decisions as a
stream, so its memory was bounded by the socket. Reading the body to
assemble it gave that up and replaced it with nothing: `size` was tracked
only to size the final `Buffer.concat`, and the three `zlib.*Sync` calls
ran without `maxOutputLength`, which leaves the limit at
`buffer.kMaxLength` -- effectively none. Measured on this Node: a 199KB
gzip response allocates 200MB in 51ms, a ratio of about 1000:1. The 2MiB
check people point at runs four allocations later, inside the cache, long
after the damage is done.
Two things make this worse than it looks for a daemon. The decompressors
are synchronous, so an oversized body does not merely consume memory, it
holds the event loop of a privileged local process while it expands. And
this is not an edge route: it is the steady-state poll every placement
makes twice a minute.
Both limits now derive from one number. `MAX_CONTENT_BYTES` -- the budget
the web host already enforces on a content package -- is exported, and
the proxy allows four times it, which covers base64's 4/3 inflation plus
the manifest and metadata wrapped around the resources. A body past that
cannot be assembled into anything the browser would accept, so buffering
it buys nothing.
Past the ceiling the route degrades to streaming rather than refusing:
the caller still gets its answer, in one WAN fetch, framed exactly as
upstream framed it. That is the behaviour this route had before, and it
is what the pre-existing `passThrough` path does for every other request.
* chore(daemon): escape the key separator so the file is not binary to git
`keyName` separated the placement key from the locale with a literal NUL
byte written straight into the template string. Git classifies a file
containing NUL as binary, so this one showed up in the pull request as
`+0/-0` and `Binary files differ`. It is the 11KB module the whole
content-assembly feature lives in, and no reviewer on GitHub could open
it -- which is exactly where the defects fixed earlier in this branch
were sitting.
`\u0000` is the same character. Nothing about the hash, the file names on
disk or any stored record changes; only git's guess about the file type
does.
* fix(web): stop the timer limit binding the production lease again
`PRODUCTION_MAX_LEASE_MS` exists to catch a server clock that grants past
the activity it is granting for. Giving it `MAX_TIMER_MS` answered a
different question -- what one `setTimeout` can name -- and so made it the
binding term in `resolveAuthorizationDeadline` for every schedule longer
than ~24.9 days.
That is the same defect OPEND-3366 was written to remove, one tier up. The
five-minute value truncated every longer authorization to five minutes;
`MAX_TIMER_MS` truncates every schedule longer than ~24.9 days. `armExpiry`
has segmented long waits since this landed, so the timer's reach was never
the lease's problem to borrow.
It is reachable. The server validates only `endsAt > startsAt` and a future
`startsAt` -- there is no maximum schedule length anywhere in the write
path -- and its own production-runtime fixtures assert multi-year windows
are handed back unshortened. A device that misses every poll for 24.9 days
(a lid closed over a long trip, then opened offline) wakes to a truncated
lease, `wake` treats the offer as a new presentation, and the recorded
device impression retires it. That campaign never shows on that device
again.
The docblock made the same mistake in prose, and said so confidently: "At
the longest interval a timer can name it stops binding, and `endsAt`
governs". True below 24.9 days, false above it, and it is the sentence that
would have let the next reader repeat this. Rewritten to keep the two
bounds apart -- one timer segment versus one authorization -- and to record
both times the constant was given the wrong one.
Ten years, not `Infinity` and not a dropped term: skew is exactly what
`endsAt` cannot catch, because `validForMs` is `deadline - serverTime` and
`endsAt` is what the lag is measured against. Not one year either, for the
same reason five minutes failed -- the team's own fixtures schedule past it.
* test(web): give the sleep case a control that can tell absence apart
Both assertions in "does not re-present a displayed campaign after a sleep
outlasts its authorization" are absences. An absence is also what a
lifecycle that never came back looks like, so the case could not
distinguish the device impression closing a live offer from no offer
arriving at all -- and a `wake` that stopped waking would have left it
fully green.
Verified by construction: with `resume`'s lapsed-lease branch stubbed to
return instead of calling `wake`, both `toBeNull()` assertions still pass
and only the new control fails.
The control drops the impression and wakes once more through the same
event and the same advance, so the impression is the only difference
between presenting and not.
* test(web): cover the two background-recovery cases A5 had no test for
A5 (screen sleep / backgrounding) was carried as unverified because
`document.hidden` cannot be forced inside a real Electron renderer. That is
true of Electron and not of this suite, which has been forging it all along
with `vi.spyOn(document, "hidden", "get")`. Two of its four cases had no
coverage at all.
Short side, at the modal. The existing tab-switch case toggles visibility
on the real clock with nothing in between, so it cannot tell "the lease was
left alone" from "nothing had time to lapse". The new case is hidden for
forty-five seconds of a sixty-second authorization: a poll tick falls
inside the spell, and the lease is still the server's to renew on return.
It asserts the same dialog node, the same `opend-touchpoint` host, and zero
requests while hidden.
Long side, at the hook. Nothing covered a lease lapsing WHILE the page is
hidden. `armExpiry` does not consult `document.hidden`, so the grant
retires on the server's deadline whether anyone is watching or not, and the
return has nothing left to revalidate: it must go through `wake`, exactly
once. `generation` is asserted as changed rather than as a number, because
this path steps it three times and pinning the count would pin the route
instead of the outcome.
Both were checked by breaking what they claim to hold:
- visibilitychange bound back to `wake` (pre-OPEND-3363) -> the modal case
fails on the first assertion after hiding.
- `refresh` no longer fencing a hidden page -> the modal case fails on the
zero-requests assertion.
- `armExpiry` consulting `document.hidden` -> the hook case fails on
`current` being null.
- `resume` no longer waking a lapsed lease -> the hook case fails on the
single `load`.
Recorded for the next reader: reverting OPEND-3378 does NOT red the modal
case. The lease is still inside its window at the moment of return, so
`same` holds either way; 3378 needs a clock step forward, which is already
covered by "a clock step forward does not re-mount a campaign that is still
on screen".
What is left for a human is one platform fact, not product logic: whether
an Electron renderer flips `document.hidden` on sleep, occlusion and
minimise at all. Recovery does not depend on that event firing -- the
expiry timer ignores it, the thirty-second poll runs regardless, and focus,
online and pageshow share the same handler -- so the worst case is one poll
interval of delay rather than a wrong branch.
* docs(web): scope the OPEND-3377 stripping claim to production
"They do not survive into the lease" reads as a property of
`useTouchpointLifecycle`. It is a property of `touchpointLeaseValue`, and
the hook has a fourth consumer that does not apply it: `TestCampaignModal`
retains whole decisions with `serverTime`, `endsAt` and
`authorizationExpiresAt` intact, and reads across polls from the object it
retained.
That is the right call there -- the Test channel exists to show an operator
what the schedule is doing, its authorization is capped at sixty seconds
and every round recomputes from a fresh `serverTime`, so nothing it holds
can be stale by more than one poll -- and this change does not touch it.
What it does is stop the note reading as a repository-wide invariant when
the source scan behind it only visits the three production files. Left
unqualified, the next reader adding a consumer would believe a guarantee
nothing is enforcing.
* test(web): stop waiting for a real animation frame on a fake clock
The device impression is recorded inside a `requestAnimationFrame`, and
`requestAnimationFrame` was not in either case's `toFake` list. So the frame
stayed on the REAL clock while the case advanced only the fake one, and
`expect(localStorage.getItem(marker())).toBe("1")` after a fixed
`advanceTimersByTimeAsync(16)` was really asking whether enough WALL time
had happened to pass inside the awaits. On an idle machine it had; under
load it had not.
Measured, 12 busy cores against one file, six runs per arm:
- "keeps the same host through a background spell" (mine) 3/8 red
- "does not re-present ... after a sleep outlasts" 3/8 red
with every one of my commits reverted -- this one arrived with
the #8269 rewrite and was already failing before I touched the file
- after this change 0/29 red
Both now go through one helper that advances in small steps until the
marker lands. Each step yields to the real microtask queue, which is what
lets `verifyWebTouchpoint`'s real crypto finish; the frame it then requests
fires on fake time. Ablation, same load: the stepping alone is what carries
it (6/6 green); faking frames alone is strictly worse than before (6/6 red),
because a single fixed advance can run out before the frame is even asked
for. Frames are faked anyway, so the mechanism the case waits on belongs to
the clock the case controls, instead of to the wall.
"keeps the displayed campaign on screen when a poll fails and its retry
recovers" -- the case CI reported, and the A1 watchman from #8269 -- is not
touched. It sets the marker by hand precisely because of this, and it was
green in all 14 runs with my commits reverted. It has a weaker form of the
same fragility, noted for a follow-up: it reads
`document.querySelector("opend-touchpoint")`, which the host satisfies
synchronously, so nothing in it waits for the mount that sets
`openPresentation`. Starve that crypto long enough and the recovering poll
finds no open presentation, reads the impression it set by hand, and clears
the host. That is how it can fail, and it is not this PR's to change.
* fix(web): stop bounding production display authority by a lease duration
`resolveAuthorizationDeadline` took `serverTime + PRODUCTION_MAX_LEASE_MS` in
its minimum, so the ten-year value truncated every longer grant. The server's
own production-runtime fixtures run 2020-01-01 to 2100-01-01, so the premise
that no operator's schedule could reach it was false when it was written:
after ten years offline the client would withdraw a campaign the server still
authorizes, which is OPEND-3366 again at a larger threshold.
The term is removed rather than widened. Three times it was filled with a
number that answered a different question -- five minutes (the poll interval),
`MAX_TIMER_MS` (one timer's reach), ten years (a guess at "far enough") -- and
each became the binding answer to this one. Every fill was defended as a skew
backstop, but a duration cap only ever sees the SUM of the skew and the
schedule, so no value can bound one without binding the other: a value small
enough to catch a month of skew truncates every multi-year campaign. There is
no fourth value to try.
Skew is carried instead by the terms that survive it. `authorizationExpiresAt`
stays in the minimum, so a clock-derived credential window cancels the offset
exactly; `endsAt <= serverTime` still refuses an activity already over on the
server's clock; and a successful poll replaces `validForMs` outright, so an
inflated window is only ever spent by a client that cannot reach the server
for the whole of it. The residue -- a server clock wrong by a month
over-displays by a month, an error the size of the skew and not of the lease
-- is recorded as accepted, together with why comparing `serverTime` against
the device's `Date.now()` is not the fix.
The one maximum left is the Test runtime's sixty-second contract, now named
`TEST_MAX_AUTHORIZATION_MS` and a rejection threshold only; it cannot shorten
anything. The regression matrix gains the server fixture's own 2100-01-01
window, and the three-placement guard now asserts production passes no
maximum at all.
* test(web): wait for the mount behind the impression, not for a hand-written marker
`keeps the displayed campaign on screen when a poll fails and its retry
recovers` wrote the device-impression marker itself, to get past a frame its
`toFake` list had left on the real clock. That manufactures a state the
product cannot produce: `openPresentation` is assigned when `mountTouchpoint`
resolves and the marker only in the frame after it, so "impression recorded,
nothing open" exists in the case and nowhere else -- and it is exactly the
`{kind:"clear"}` branch of the load callback. Whenever the real SHA-256 behind
the mount had not finished inside the case's fixed ten-millisecond advance,
the recovering poll therefore tore down the very host the case then asserted
on, which is the `expected null not to be null` this went red with.
This is an existing weakness in the case, not one this branch introduced.
Starving the digest reproduces it identically on
|
||
|
|
bc426c24e5 | Merge branch 'feat/share-ui-restore' into feat/share-lane4-inbound | ||
|
|
ad0c2dd2e7 | Merge branch 'feat/share-ui-restore' into feat/share-lane1-publish | ||
|
|
0f1af0906e |
feat(contracts): define share bridge v2 geometry boundary
Define authoritative OD v2 viewport geometry, fallback pin bounds, display tokens and geometry-update gates. Add 16 passing boundary tests; root guard and typecheck passed.
Known pre-existing failure: share-contract.test.ts expects SHARE_SNAPSHOT_DISCOVERY_IN_P0=false while baseline
|
||
|
|
02688fac97 | fix: authorize and bootstrap public share project bindings | ||
|
|
f0fac8c2b1 | wip: separate published content from unavailable viewer origin | ||
|
|
50f20601f5 | Merge branch 'feat/share-comment-stage0' into feat/share-lane1-publish | ||
|
|
8ec4de6f5b | feat: read authoritative project share lifecycle through daemon | ||
|
|
bd912342fa |
fix(contracts): a backfill result is per file, and its revision is a token
Two defects in the field frozen a couple of hours ago, both found by the lane that had to implement against it. generation was a number. What the publish path actually carries is a revision token, not a counter. A numeric field forces the producer either to invent an ordering the system does not have or to cast a token to a number, and a consumer that then writes 'generation < current' gets an answer from a comparison that never meant anything. It is now an opaque string compared for equality: stale means 'not the revision this file is published at now', never 'smaller'. backfill also had no file. Publications are per file, so a project with three published files has three independent outcomes, and a project-scoped read that returns the most recent one presents another file's result as though it described this one - publishing file B would clear file A's failure banner while A is still not backfilled. So the field is present only when the caller named a file, and absent still means not attempted. |
||
|
|
a798f942d4 | wip: preserve deferred deleted-file stop retry implementation | ||
|
|
be171380b5 |
feat(contracts): a backfill result belongs to one publish
K2 needed a field of its own rather than a reading of lastError. lastError is the ordinary outbox's most recent failure across the whole scope, so folding backfill into it renders an unrelated network blip as 'the existing comments did not go up', and lets a real backfill failure be erased by the next unrelated success. generation is required, not convenient. Without it a failure outlives the publish it described: the owner republishes, the new backfill succeeds, and a banner from two publishes ago still asks them to retry something that no longer exists. Consumers compare against the publication's current generation instead of rendering the newest record handed to them, which is also how a success at N clears a failure at N-1 - by superseding it, not by someone remembering to delete it. Publish-succeeded-and-backfill-failed is a normal pair, not a contradiction: the link works and is safe to copy, and only the comments predating the publish are missing. Disabling share or copy-link over this state removes a capability that works because a different one did not. |
||
|
|
2b802b9f27 |
feat(contracts): a link does not outlive the file it points at
Product ruling: deleting the file or project must make its public link inaccessible and say why. The delete residual describes the window before that holds - it is not a state the system may settle in, so retrying:false is an unmet obligation rather than a tolerated outcome. A stop caused by deletion is still 410, but it is not the same event to the person holding the link: 'the owner stopped sharing' invites them to ask for it back, 'the original file was deleted' tells them there is nothing to ask for. A stop reason keeps those two from collapsing into the one sentence that is true for every 410 and useful in none of them. unspecified is not a synonym for stopped_by_owner - a cause the server did not record must not be rendered as one it did. |
||
|
|
db29f79e37 |
feat(contracts): name the projection align compares, and what it must not
Align exists to catch a merge that dropped or mangled a record the transport delivered correctly. Three things in the proposed field list would instead have reported divergence about rendering: - the anchor ladder's output (anchorState, anchoredVersion, lastGoodPosition) is recomputed locally against this device's copy of the HTML; the cloud never runs the ladder, so two devices that rendered at different moments hold different values for an identical comment; - authorDisplayName is a captured display snapshot travelling on a separate channel from the member directory by design; - podMembers is member identity, which align does not compare at all. Naming the projection in the contract rather than leaving it as a field list each side maintains: a comparison whose two sides disagree about which fields count reports a difference of opinion as a difference of content, silently. Same reason positions round to whole pixels and attachments compare by identity and order - a JSON float round-trip and an upload-integrity question are not edits the author made. |
||
|
|
824cbcd984 |
fix(contracts): shareStopped can be unreadable, and pending is scoped
shareStopped was a required boolean, which cannot say "could not read it". The authoritative answer is on the cloud binding, so a 404, an auth failure, a network error or an unusable projection each leave the client with nothing — and a boolean forces one of those to be written as false, a confident claim about something never read. The K3 banner would then be absent for a share that really was stopped. pending gets the matching rule: the outbox spans projects and possibly identities, so its raw count would put another project's backlog on this banner and tell a viewer about work outside what they can see. |
||
|
|
bb8bc5b3cc |
feat(contracts): batch comment push and content-based align
A batch is transport, not a unit of work: rolling back 400 entries because one failed discards 399 that were accepted, and the retry re-sends all of them, so nothing lands until the failure rate reaches zero. Each entry therefore carries its own idempotency key and commits alone, and results are keyed by the caller's handle rather than by position, which retries do not preserve. Align compares the merged projection against the cloud's consistency snapshot. Equal cursors are not equal content — a comment can be edited, removed, or skipped by a filter without the cursor moving — so a cursor comparison answers a different question and anything less than the content check is unknown. unknown is never aligned, and neither is absent: on CommentSyncState a missing align means nobody checked, and reading that as a clean result turns every un-run comparison into a clean bill of health. |