3700 Commits
Author SHA1 Message Date
open-design-crew[bot]andlefarcen 1b47e60bd4 fix(cms): mount Test campaigns with the requested app locale (#8432)
Co-authored-by: lefarcen <935902669@qq.com>
2026-09-24 06:58:26 +00:00
open-design-crew[bot]andlefarcen 3ede8daf11 test(daemon): answer Codex fixture probes without waiting for stdin (#8426)
Co-authored-by: lefarcen <935902669@qq.com>
2026-09-24 05:26:37 +00:00
open-design-crew[bot]andlefarcen 61bda5c78c fix(ci): allow setup headroom for prerelease daemon tests (#8424)
Co-authored-by: lefarcen <935902669@qq.com>
2026-09-24 04:22:53 +00:00
Marc Chanandlefarcen 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>
2026-09-24 03:22:31 +00:00
open-design-crew[bot]andelifive555555 5885573546 feat(web): localize welcome screen and starter credit ribbon (#8351)
Co-authored-by: elifive555555 <296440099+elifive555555@users.noreply.github.com>
2026-09-24 03:13:16 +00:00
lefarcen 419c735843 docs(release): add v0.24.1 changelog (#8419) 2026-09-24 03:09:03 +00:00
PerishFireandLooper f166c02636 fix(packaged): let a macOS headless runtime become the desktop in place (#8398)
* fix(packaged): let a macOS headless runtime become the desktop in place

When Codex starts Open Design headlessly through MCP, macOS treats that
windowless process as the running app. Opening the app from the Dock,
Finder, Launchpad or `open -a` then only reactivates it, so no launcher
runs and no window ever appears (OPEND-3426).

On macOS the headless runtime now runs the desktop path with its window
deferred, and restores the window in the same process when the user
opens the app: through `activate`/`open-url` paired with the app becoming
active, or through SHOW from a newer launcher that sees a `restorable`
owner. The daemon, its port and connected MCP clients stay. A new outer
hands headless launches to the active payload and marks the launch as
managed; payloads started by older outers keep today's behavior.

Managed MCP registrations carry absolute sidecar endpoints for every mode
of the namespace, so the MCP process finds the daemon whichever mode owns
it, waits for a running owner instead of reopening it, and cold-starts
only when nothing runs. The daemon refreshes an existing Codex
registration on start and never installs one. A write interrupted by a
service restart now says it may or may not have been applied.

* fix(daemon): scope Codex refresh to its managed install

Require the existing registration's managed discovery marker to match before refreshing it, so local, stable, and prerelease installs cannot take over the global name from one another. Cover the unrelated-owner path without invoking a config mutation.\n\nGenerated-By: looper 0.11.2 (runner=fixer, agent=codex)

* fix(daemon): prove Codex registration ownership by data root and app

Tighten the ownership check from d2d47a5f8e. Matching the discovery
endpoints alone still let a copy of the app that reuses the installed
namespace take over the registration (the endpoints depend only on
channel, namespace and source), and it could never refresh a
registration written before the managed contract, which has no
discovery value and still names a payload version that cleanup removes.

Read the existing registration and rewrite it only when it serves the
same daemon data root and bootstraps the same installed app; the managed
marker is ignored so a pre-managed registration of this install still
counts. Anything else, including a registration that cannot be read, is
left untouched.

---------

Co-authored-by: Looper <looper@noreply.github.com>
2026-09-23 12:47:31 +00:00
PerishFire 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.
2026-09-23 12:19:20 +00:00
Ray Xi 282416ccd8 feat(diagnostics): automatically collect consented failure evidence (OPEND-3397) (#8357)
* feat(diagnostics): persist and upload consented Agent fault evidence

* feat(diagnostics): capture cancellation and delivery experience failures

* feat(diagnostics): collect visible frontend failures through consented relay

* fix(web): reset cached analytics before diagnostic test mocks

Generated-By: looper 0.13.0 (runner=fixer, agent=codex)

* fix(web): reset template picker before it becomes interactive

* test(web): await campaign presentation before retry polling
2026-09-23 11:12:53 +00:00
Ray Xi 055621c906 fix(web): keep the task-type menu open when the click follows a type change (#8386)
TemplatePicker closed its menu from a passive effect keyed on activeChipId and
disabled. On a loaded machine React runs a commit's passive effects in a later
task, so a click on a trigger that already showed the new type opened the
menu and the stale effect closed it again. Close in the render that
introduces the change instead.

Home also enabled the picker one render before its first-visit default type
(or restored draft / handoff) landed, so a click in that gap opened a menu the
arriving type then closed. Keep the picker disabled until the initial type is
settled.
2026-09-23 09:38:12 +00:00
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 1c4751266d, before the
lease-cap change; and the value that change removed never bound this fixture,
whose `authorizationExpiresAt` of `serverTime + 60s` was the minimum's
binding term under the old ten-year cap and stays so without it.

The case now fakes frames through `IMPRESSION_TIMERS` and waits through
`advanceToRecordedImpression`, like the two siblings beside it. That wait is a
real mount barrier, because nothing can write the marker until the
presentation is open. Nothing is relaxed to get there: this case guards the
blocking defect from the OPEND-3363 acceptance report, and reverting the
`onError` guard it exists to protect still turns it red.

`advanceToRecordedImpression` also stops spending fake time on a wait that is
really about the real queue. A step used to buy exactly one turn of it, so a
machine slow at the crypto ran out of steps while fake milliseconds were still
plentiful; each step now yields the real queue before advancing its frame.
Against a deliberately starved digest the case used to break at forty turns
and now survives four hundred, and when it does give up it names the wait that
failed instead of leaving an unexplained null host behind.

* feat(cms): keep a cached activity on screen while the runtime is unreachable

OPEND-3436. A client that cannot reach the CMS runtime went blank sixty
seconds later — the length of the authorization window it happened to be
holding — and then spent the outage retrying a request that could not
succeed. Restarting lost the activity entirely, because the daemon's
content store held the bytes of a package but nothing about WHEN it was
authorized to show them.

The daemon now owns persistence and the clock. Its assembly records carry
the server's own startsAt/endsAt/serverTime/authorizationExpiresAt, the
activity/deployment/content identity, and the decision envelope, scoped by
(environment, account) so one account's packages are unreachable from
another's session. When the runtime cannot be reached it rebuilds the last
decision it stored, re-times it, and answers 200 with `offlineReplay` — the
same body the browser already parses, with two fields rewritten:
`serverTime` becomes the daemon's estimate of now, and
`authorizationExpiresAt` becomes `endsAt`. That second rewrite is the
ticket's product ruling: offline display lasts to the last known END of the
activity, not to the short authorization it was issued.

Elapsed time is measured so it can only increase — an in-process monotonic
reading, and a persisted high-water mark that survives a restart — so
winding the system clock back cannot buy display time. A record whose
window has closed is deleted on the next read, along with the bytes no
surviving record still references; the blob pool is shared by digest, so
reclaiming without asking the survivors first would break every other
activity built from the same component. A 410 deletes only when all four
fields of its receipt name this record. A 404, a timeout and a 5xx delete
nothing.

The browser reads one field and changes two behaviours: a failure that
means the runtime was not reached suspends the poll and the backoff chain,
and recovery becomes one deduplicated revalidation per reconnection —
however many of online/focus/pageshow/visibilitychange that fires. The
window still ends on its own timer with no network involved, and a lease
that lapsed while the device slept is retired before anything is
revalidated, so waking does not flash an activity that is over.

The opt-in is off by default. The Test channel shares this hook, its job is
to keep asking, and there is no cached content behind it to fall back on.

Scope note: three existing regression tests recovered from an outage on the
next poll tick and now dispatch the `online` event a real reconnection
fires. What they guard — no remount across the outage — is unchanged.

* fix(web): keep asking while a 5xx holds the offline fallback

OPEND-3436 stands the poll down on the first failure that means the
runtime was not reached, and leaves recovery to `online`, `focus`,
`pageshow` and `visibilitychange`. That bargain holds for exactly one of
the two failures it covers.

When the device's own network broke, its repair fires `online`. When the
server answered 5xx, nothing broke at the device end at all: the request
crossed a healthy network and came back with an answer, `navigator.onLine`
stayed true throughout, and a user who simply leaves the app open on the
page the activity appears on fires none of the other three either. No
event anywhere says the server is healthy again, so a client that went
quiet on a 5xx stays quiet for the whole cached window while fully online.

What that costs is the ticket's own product bargain. A revocation is only
delivered in the answer to a request this client makes, so a client that
has stopped asking cannot be told an activity was pulled -- it keeps
showing it. A shortened schedule and a newly published activity are lost
the same way.

So a fallback held by a 5xx now keeps one request every
SERVER_FAULT_HEARTBEAT_MS. The interval is fixed rather than backed off
because its job is not "recover early" but "bound how late a withdrawal
can land": the longest a pulled activity can stay up is the longest this
client will go without asking, and only a fixed number answers the
question an operator actually asks. It goes through `revalidateOnce`, so
a heartbeat landing beside a reconnection is still one request, and a
heartbeat that fails starts no retry chain -- it just waits for the next
bound. One success returns it to the ordinary 30s poll.

A transport failure gets none of this, and a red guard pins that: a
device with no network answers every request the same way and its
recovery is already announced.

* test(web): wait for the menu the click schedules, not the tree before it

`pickHomeTemplate` read `home-hero-template-menu` with a synchronous
`getByTestId` on the statement after `fireEvent.click`. The click only
schedules the open state; the menu is not in the DOM until React commits,
so the helper passed only while that render happened to win the tick.

On `main` alone it wins every time. Merged with this branch — which mounts
the campaign placements into HomeView — the same tick carries more work and
the read starts losing: measured 0/6 failures on `main`, 1/6 on the merge
that CI actually builds, surfacing as `HomeView.example-dismiss` failing on
a testid that file never mentions.

`findByTestId` waits for the commit the click asked for. The read is what
was wrong, not the timing it happened to get.

* fix(web): arm the heartbeat on the path production actually takes

The heartbeat added in the previous commit could not fire in production.
It armed from `abandonAttempt`, which only runs when a request REJECTS,
and an unreachable runtime never reaches this browser as a rejection: the
daemon absorbs it, rebuilds the decision from its own cache and answers
HTTP 200 with an `offlineReplay` marker. The loader then collapsed that
marker to `offline: true`, so the lifecycle stood its poll down and armed
nothing. Every heartbeat test passed by mocking a rejection the real
stack does not produce.

That left the exact hole the heartbeat was written to close, and left it
on the likelier path. Both of the daemon's reasons -- `upstream_unavailable`
for a runtime 5xx, `upstream_unreachable` for its own DNS, connect or
timeout failure -- happen entirely behind the daemon, over a
browser-to-daemon connection that never broke. `navigator.onLine` stays
true so `online` cannot fire, and a user sitting on the page fires no
`focus`, `pageshow` or `visibilitychange` either. With no request on a
clock, a withdrawn activity stays on screen until the replayed `endsAt`,
which the daemon re-times to the whole remaining window and which a real
schedule may set years out.

So the marker is carried up whole instead of flattened, and the lifecycle
takes a recovery POLICY rather than a flag: "stop asking" and "nothing
will tell you when to start again" are different facts, and merging them
is what hid this. `productionTouchpointRecovery` maps reason to policy in
one exhaustive switch, so a third reason added upstream cannot inherit an
answer nobody ruled on.

The guard is unchanged and still green: a failure of the browser's own
transport is announced by `online` and gets no heartbeat.

Pinned by an integration case that drives the real loader -- daemon 200
replay in, 410 withdrawal out, and not one browser event in between.

* fix(daemon): decide what a status licenses before degrading to streaming

A decision body that outgrows MAX_BUFFERED_DECISION_BYTES makes the proxy
give up assembly and become a pipe. Becoming a pipe sends headers, and
sending headers retires the status block in the `end` handler — so the
size of a body was silently deciding what its status meant.

Two consequences, both reproduced by the specs added here:

  - an oversized 410 never called `forgetWithdrawn`, so the browser cleared
    the screen while the stored package survived its own withdrawal and
    stayed eligible to replay the next time the runtime was unreachable;
  - an oversized 5xx was forwarded instead of answered from cache, which is
    the outage this route exists to survive.

Neither decision needs the bytes. `settleOversizedStatus` runs before the
degrade: a 410 reclaims and a transient 5xx replays, and a body this size
is by construction one no revocation receipt can be read out of — which
`touchpointWithdrawalReclaims` already rules is the whole deployment being
withdrawn. Once the cache has answered, the remainder has no reader, so it
is dropped rather than buffered back over the ceiling.

* test(daemon): pin that a 410 reclaims when its body expands past the decode ceiling

The streaming ceiling had a hole; this is the same rule one ceiling further
in. A body small on the wire but oversized once expanded leaves `decoded`
null, and the status block must still run — it precedes the unparseable-body
bail-out, and nothing stated that order.

Verified the guard can fail: moving the `!decoded` return above the status
block (one site) turns exactly this spec red.

* fix(daemon): make a damaged record a miss, and make a wound-back clock still pass

Two ways the persisted cache could break the fail-closed guarantee it states.

`parseAssembly` validated every scalar it reads but not `envelope`, which
`rebuild` dereferences with `Object.entries`. A record that is valid JSON of
the current version with `envelope: null` therefore threw — from inside the
proxy's upstream `error`/5xx handlers, where nothing catches it, so cache
corruption could take the daemon down in place of the documented cache miss.
Validating it here keeps "damaged record" and "miss" the same outcome.

The high-water mark survives a restart, but on its own it cannot MEASURE
time while the wall clock sits behind it: `max` pins `now` to the mark,
`elapsed` stops growing, and an activity keeps its authority for as long as
the clock stays wound back. The defect is not that the mark is too high — it
is that the mark does not move. Anchoring it to this process's monotonic
reading makes the wait count, per key so one placement's mark is never
evidence about another's.

This keeps `does not let a backwards system clock extend the window` green
rather than reversing it: the ruling that observed time cannot be
un-observed stands, and is now paired with time that continues to be
observed.

Both specs were verified to fail without their fix: removing the `envelope`
guard throws `TypeError: Cannot convert undefined or null to object`, and
removing the anchor replays an activity 25 hours past its `endsAt`. The
other 16 specs stayed green through each ablation.

* test(daemon): pin that the clock residual is bounded by downtime, not by the rollback

The monotonic anchor cannot recover time the daemon was not running, so a
rollback straddling downtime still buys display time. How MUCH is the whole
question, and it was being argued rather than measured.

This fixture measures it: one hour of downtime plus a twenty-four hour
rollback costs exactly one hour of over-display — the activity ends after
twenty-four hours of UPTIME, not twenty-four hours past the rollback.

Verified it can fail: removing the anchor lets the same activity survive the
final assertion.

* fix(daemon): refuse offline replay after an uncertain clock restart

Do not restore cached display authority when startup local time is behind the persisted observation. Fresh server responses establish a new per-record monotonic anchor, preserving consistently offset clocks and in-process rollback handling.

---------

Co-authored-by: lefarcen <935902669@qq.com>
Co-authored-by: lefarcen <ontf116@gmail.com>
2026-09-23 09:22:48 +00:00
PerishFire 00c2d8ae57 feat(plan): establish reusable workload and postinstall contracts (#8383)
* feat(plan): add verified atomic workload product restoration

* perf(plan): probe product availability without downloading payloads

* refactor(ci): formalize workspace initialization plans

* feat(plan): add versioned workload execution contracts

* refactor(plan): colocate postinstall defaults and docs

* fix(convergence): enforce cache result integrity
2026-09-23 07:57:35 +00:00
PerishFireandClaude Opus 5 9aa3114f3e fix(daemon): make heap pressure, SQLite growth and OOM exits observable (#8354)
A daemon that dies of a V8 OOM cannot report itself, and today nothing
records heap usage, SQLite growth or why the previous daemon ended. Add a
persist-then-report health checkpoint:

- sample heap spaces, RSS and event-loop delay (60s and after major GC),
  flag pressure levels and jumps, and keep a synchronous in-flight marker
  for large reads so an OOM inside one stays attributable
- opt the daemon into Node's fatal-error report once listening; the next
  boot reduces it to controlled fields, deletes the raw report, and
  reports the previous session as daemon_health_summary or
  daemon_unclean_exit (carried forward across crash loops)
- add storage_* fields to the emitted run_finished (and its startup
  replay) measured with SQLite octet_length, plus a constant-cost page
  snapshot (daemon_storage_snapshot)
- bundle the checkpoint in the diagnostics export

Observation is additive: existing return values, rows, HTTP responses,
event properties, recovery snapshots, logs and exit statuses are
unchanged, and every hook isolates its own failures.

Refs OPEND-3402

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-23 06:42:56 +00:00
lefarcenandlefarcen b40f25a1c8 docs(release): add v0.24.0 changelog (#8365)
Co-authored-by: lefarcen <ontf116@gmail.com>
2026-09-22 12:07:09 +00:00
PerishFire a8d94dfa6e fix(updater): make payload runtime handoff atomic (#8348) 2026-09-22 09:50:31 +00:00
Cheems 0450945f2d fix(daemon,web): keep a cleanly exited run succeeded when its task is blocked (#8350)
* fix(daemon): keep a cleanly exited run succeeded when its task is blocked

A Run records how its process ended; the strategy task records its own
verdict. The run settlement used to rewrite a clean exit to `failed`,
send an `OD_NEXT_TASK_BLOCKED` error frame and mark the message failed
whenever the task ended `blocked` at the production stage. That put a
red failure card and a "Run failed" execution record under a turn whose
files are in the project and whose preview renders — a turn that edited
a page other than the entry, or wrote the entry and answered in prose
without the machine block — because the card for that error code is the
same one a crashed process gets.

Remove the rewrite. A Run whose process exits 0 stays `succeeded` at
every stage; the terminal event still carries the task projection with
`outcome: blocked` and its reason codes, and the client decides from
those and the deliverable on disk what the turn produced: a project with
a usable entry keeps "Done", a turn that produced nothing still gets a
failure card drawn by the client from the reason code. A non-zero exit,
a signal, a timeout and an unavailable upstream fail the Run as before.

Tests: the production-block server case now asserts the Run ends
`succeeded` with exit code 0, no error frame, `strategyTask.outcome:
blocked` at `inputStage: production`, the message `runStatus` succeeded
and `run_finished` reporting `result: success` with the primary reason
code. The refused-planning-turn case is unchanged, and the
`OD_NEXT_TASK_BLOCKED` classifier case keeps covering Runs recorded by
earlier versions.

* fix(web): keep a succeeded run Done beside a blocked task

The shared rule `canRetainSuccessfulRunForBlockedStrategy` decided
whether a Run that succeeded kept its success on screen when the
strategy task ended `blocked`: only a Run that wrote the entry, a
project that already held one beside a reply, a refusal before
production with a reply, or a block the agent declared and explained
did; everything else was marked failed on the client and given a card
keyed on the reason code. With the daemon no longer failing such Runs,
that client-side failure was the one thing still turning a clean exit
into a failed turn — and on the live stream it left a generic card under
a turn whose folded footer read Done, while a reload rebuilt the same
turn as failed with the reason-specific card.

Make the rule the Run's own result: a succeeded Run keeps its success at
every stage, whatever the task recorded and whatever the agent said. The
verdict — outcome, reason codes, visible text — still reaches the message
through the settled task projection for the diagnostics; a failure card
is drawn only for a Run that did not succeed. The signature and the three
call sites (live settlement, cold history load, artifact recovery) are
unchanged, so each still hands over the facts it resolves.

Tests: the cases that expected a run error or a restored failure for a
succeeded Run beside a blocked task now expect Done, no error event and
the verdict on the settled projection (`sse.test.ts`,
`strategy-blocked-verdict.test.ts`,
`ProjectView.blocked-history-hydration.test.tsx`,
`ProjectView.artifact-recovery-verdict.test.tsx`). The artifact-recovery
suites that used a blocked task as the way into the failed-turn recovery
path now end their Run with a non-zero exit, so they keep guarding that
path; every case that asserts a card for a failed Run is unchanged.
2026-09-22 09:04:16 +00:00
陈志谦 2764ad5705 fix(daemon): correct the lintArtifact JSDoc parameter (#8253)
The doc said '{string} html', but the parameter is 'rawHtml: unknown'
(html is the comment-stripped local derived from it).
2026-09-22 08:31:17 +00:00
陈志谦 9d84e243ac docs(daemon): drop the citations to the nonexistent brand-framework doc (#8255)
* docs(daemon): drop the citations to the nonexistent brand-framework doc

Both palette.ts comments cited docs/brand-framework.md as provenance,
but no such file exists anywhere in the tree or history; keep the
Ant Design provenance, which the header already names.

* docs(daemon): drop the redundant provenance parenthetical

Review follow-up: the opening clause already states the port provenance,
so the parenthetical repeated it. Suggested by @lefarcen.
2026-09-22 08:31:03 +00:00
Rubén GarcíaandClaude Opus 5 77335ac7c9 fix(od-next): keep the Run input access root writable for sandbox teardown (#8024)
createOdNextRunInputProjection froze the per-Run access root to 0o555 and
handed that same path to adapters as a writable root. On Linux codex
materialises synthetic bubblewrap mount targets (.codex, .agents, .git)
inside every writable root: creating them succeeds inside the user
namespace, but the teardown that unlinks them runs as the real user and
needs write permission on the PARENT. The run aborted with

  failed to remove synthetic bubblewrap mount target ...: Permission denied

and removeOdNextRunInputProjection then hit its non-empty branch, re-froze
the root and left it on disk for good, so the per-Run roots accumulated.

Immutability belongs to the projection directory and its 0o444 files one
level down, which are unchanged. The access root now stays owner-writable,
and teardown clears empty leftover mount targets before removing the root.
Entries that carry data are still left untouched.


Claude-Session: https://claude.ai/code/session_0194Hms4LU5cR3fJ2Pa4Mirx

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-22 08:30:50 +00:00
Mad Dinh 0fbdeafd16 fix(web): restore theme tokens on the full-screen present-exit button (#7272)
The full-screen present-exit button was moved onto theme tokens in #4593,
but the rule was reverted to its hardcoded form while
apps/web/src/styles/viewer/composio.css was rewritten during a later
merge, so dark mode shows a bright white block over presented content
again.

Restore the merged treatment (--bg-elevated / --bg-muted, --border-strong,
--text, and the :focus-visible outline) and add a style test so the rule
cannot silently lose its tokens a second time.
2026-09-22 08:30:07 +00:00
lorenzozane 97e79f8a00 fix(daemon): report fatal MCP stdio process errors (#7340)
* fix(daemon): report fatal errors from the MCP stdio process

The `od mcp` stdio child could die without writing any diagnostic while
the main daemon stayed healthy, leaving clients with only a dropped
connection. Install guarded uncaughtException/unhandledRejection
handlers around the stdio lifetime so a fatal JS-level error is
reported to stderr and exits non-zero instead of disappearing silently;
the listeners are removed again whenever the stdio run ends, including
on startup failure.

* fix(daemon): exit the MCP process when the fatal report fails

reportAndExit wrote the stderr diagnostic before calling exit(1), so a
throwing write (String(reason), or ERR_STREAM_DESTROYED once the MCP client
has torn down the pipe) skipped the exit, and the follow-up exception was
swallowed by the exiting guard. Report inside a guarded try and leave through
a finally, draining stderr first so the diagnostic survives the pipe.
2026-09-22 06:08:17 +00:00
open-design-crew[bot]andlefarcen f2e649efb2 fix(daemon): handle media generate help before dispatch (#8066)
Co-authored-by: lefarcen <935902669@qq.com>
2026-09-21 12:05:37 +00:00
open-design-crew[bot]andlefarcen 94f27cb0ab fix(e2e): align smoke fixtures with OD Next execution intent (#8329)
Co-authored-by: lefarcen <935902669@qq.com>
2026-09-21 11:57:41 +00:00
Cheems 753a23dc04 fix(daemon,web): end a refused planning turn as the agent's reply instead of a failed run (#8322)
* fix(daemon): end a refused planning turn as the agent's reply instead of a failed Run

When the strategy gate refuses a turn before production — a greeting,
an off-topic question, a request the agent declined to plan a build for
— the agent has already answered in prose and the process exited
cleanly. The settlement still rewrote that Run to failed, sent an
OD_NEXT_TASK_BLOCKED error frame and marked it non-retryable, so the
user saw a red "task could not complete" card under a reply that was
the whole outcome of the turn, and a refresh drew the card again from
the persisted failure.

The rewrite now applies only to a task whose input stage is production:
there the user asked for a deliverable, the plan was frozen, and nothing
usable was written, which is the failure it exists to report. A task
refused at the request, clarification or contract-repair stage keeps the
Run's own clean exit and sends no error frame. The task record still
carries the blocked verdict and its reason codes, the message's run
status follows the Run, and run_finished reports success with the
blocked reason code as before.

The regression test drives a planning turn that answers in prose with
no machine block through the real server and asserts the Run, the end
frame, the message row and the analytics record. The production-stage
regression keeps asserting the failed Run and the error frame.

* fix(web): keep a refused planning turn's reply on screen instead of a run error card

A blocked strategy task whose physical Run succeeded kept its success
in only two cases: the Run (or the project) held a usable deliverable,
or the agent had declared the block itself and explained it. Every
other block — a planning turn refused for a missing machine block — was
remapped to a failed turn with a "task could not complete" card, even
though the daemon now settles such a Run as succeeded and the agent's
reply is the outcome the user was waiting for.

canRetainSuccessfulRunForBlockedStrategy gains a third case: the task
was refused before it reached production and this turn has a visible
reply. The streaming settlement in the daemon provider now calls that
shared rule instead of an inline copy, so the live stream, the cold
history load and the artifact recovery decide the same way. A
production block with prose beside it still raises the card, and so
does a refused turn that left the user nothing to read.

Tests: the SSE case for a refused planning turn flips to "no error,
status succeeded" and gains a production counter-example; the cold
history hydration gains a request-stage Done case and a no-reply
failure case. Fixtures that describe a production gate refusing a turn
that put its output in the chat now say so with inputStage production,
so their existing assertions keep guarding that path.
2026-09-21 11:02:27 +00:00
open-design-release-bot[bot]andopen-design-release-bot[bot] <open-design-release-bot[bot]@users.noreply.github.com> 894d55466b chore(release): bump main to 0.23.1 ahead of stable 0.23.0 (#8300)
Co-authored-by: open-design-release-bot[bot] <open-design-release-bot[bot]@users.noreply.github.com>
2026-09-21 03:48:10 +00:00
nettee ac6115406f chore(pack): upgrade bundled Vela CLI to 0.1.3 (#8299) 2026-09-20 14:39:36 +00:00
open-design-crew[bot]andlefarcen 0ffafb3d76 fix(chat): keep live strategy continuations in their original task (#8195)
* fix(chat): keep live strategy continuations in their original task

* fix(chat): re-derive the task position when a reattach follows a successor Run

`reattachDaemonRun`'s `onRunCreated` re-points the row at the daemon-created
successor Run but spread the predecessor message, so a page reloaded mid-task
kept the predecessor's `strategyTaskRunIndex` on a row that is now streaming a
different Run. That position is what `foldStrategyTaskTurns`, the fork boundary
and the successor-absorption rule read.

Resolve it through the same `strategyTaskRunIndex(...)` helper the live send
path uses, so a projection that does not name this Run exactly once yields
`undefined` instead of the predecessor's index.

---------

Co-authored-by: lefarcen <935902669@qq.com>
2026-09-20 08:55:21 +00:00
arccat-114 d5eddcb59c fix(daemon): count manifest-backed artifacts in run snapshots (#7586)
* fix(daemon): count manifest-backed artifacts in run snapshots

The run-finish snapshot/diff filtered tracked files by renderable extension
only, so a manifest-backed Markdown export written via create_artifact was
invisible to the diff and finalization reported artifactCount: 0 -> no_artifact
even though the file and its manifest were in project storage.

Track files carrying a valid <file>.artifact.json sidecar alongside the
extension whitelist, reusing parsePersistedManifest for validation so stray
or malformed sidecars never widen tracking to arbitrary unmanifested files.
The sidecar itself remains metadata and is never tracked.

* fix(daemon): honor manifest classification in run snapshots

Use asynchronous sidecar reads on the async snapshot path and apply the final manifest-backed classification before choosing a snapshot budget. Add regression coverage for both budget boundaries and the non-blocking path.

Fixes #7579
2026-09-20 08:54:37 +00:00
Amy 287357b12e fix(amr): reset workspace subscriptions on profile switch (#8020)
* fix(amr): reset workspace subscriptions on profile switch

* fix(amr): fence stale workspace release handles

Generated-By: looper 0.13.0 (runner=fixer, agent=codex)
2026-09-20 08:53:01 +00:00
Huy Pham 1aa228115f fix(web,daemon): preview snapshot bridge captured every real page as blank (#7125)
Two stacked bugs in the foreignObject snapshot bridge (the srcDoc bridge
in apps/web/src/runtime/srcdoc.ts and the URL-preview bridge the daemon
injects in apps/daemon/src/routes/project/index.ts share this code):

1. inlineSnapshotStyles stripped clone <script>/<link> nodes BEFORE
   pruneHiddenSnapshotNodes ran, but prune pairs the original/clone
   querySelectorAll('*') lists by index. The early removals shifted every
   later clone under the wrong original, so the misdirected hidden-node
   removals deleted visible content (often the <body> itself). Any page
   containing a script or stylesheet link rasterized as a uniform frame
   and the capture failed with 'empty-render' ("Preview is still
   loading. Try again in a moment."). The stripping now runs after prune
   (stripSnapshotResources).

2. bodyContent was serialized with innerHTML, whose HTML serialization
   emits void elements (<br>, <img>) without self-closing slashes -
   invalid XML inside <foreignObject>, so once bug 1 is fixed the SVG
   image fails to parse ('snapshot image failed'). Now serialized via
   XMLSerializer (serializeSnapshotXhtml), and XML-invalid attribute
   names (@click, :href) are dropped from the clone.

Desktop is unaffected because the host compositor path short-circuits
the bridge; every pure-web deployment (browser against the daemon, e.g.
the Docker/K8s setup in #5444) always takes it and has been broken.

Fixes #5444
2026-09-20 08:44:31 +00:00
7413c6daa1 feat(web): default Home to Prototype and switch types in composer (#8278)
* feat(web): switch creation types from the composer dropdown

* fix(web): limit type picker changes to dropdown interaction

* fix(web): separate creation type menu rows by one pixel

* fix(web): default Home to Prototype and migrate type picker coverage

* test(web): cover default selection and dropdown loading boundaries

* test(web): activate team catalog before context submission

* fix(web): keep loading type picker on the capsule surface

---------

Co-authored-by: wangchenglong <honam884844@gmail.com>
Co-authored-by: Siri-Ray <2667192167@qq.com>
2026-09-20 03:24:26 +00:00
f5707c8cae fix(cms): keep campaign touchpoints on home and off the wake path (#8269)
* fix(cms): host campaign touchpoints on the home view only

Every placement this app authorizes is a home placement (`opend.home.*`),
but the hosts did not follow. The modal pair mounted on every route except
onboarding, and the account badge rode the entry rail across all of its
tabs plus the project workbench corner — so a CMS campaign could open over
an open project, which is where a user reported meeting it.

The hosts now answer to the home view: App mounts the Test and Production
modals only there, EntryShell gates the badge and the hover entry on
`view === 'home'`, and the workbench cluster carries no CMS touchpoint at
all. The built-in DeepSeek pill is product chrome rather than a CMS host
and keeps its reach across entry tabs and project detail; the source
contract that guarded that reach is split to say so.

* fix(cms): stop a wake from re-presenting a displayed campaign

Screen sleep hides the page, which withdraws the lease and takes the modal
down. Waking refreshes — and the refresh carried the withdrawn lease as its
revalidation subject, which the impression gate read as "the active
activity" and exempted from the device impression. The server offers the
same activity, so the campaign re-opened on every wake, for as long as it
was published.

The exemption now belongs to the presentation still on screen and nothing
else. A page that goes hidden releases that presentation, so the offer
arriving on wake is a new one and the impression closes it. A suppressed
offer has to clear rather than retain: retaining republishes the very lease
the visibility fence just withdrew, which was how the first cut of this fix
kept the modal on screen. Retain stays for the case it was written for — an
offer arriving beside a live presentation, which keeps its mount.

* fix(cms): keep a displayed campaign through a recoverable poll failure

The lifecycle draws a line no other layer may redraw: only the server's own
withdrawal ends a live lease, and a transport failure rides out on the lease
already granted, retried inside the same cycle. The modal's `onError` ignored
that line and released its open presentation for any error at all.

That was harmless while the impression gate still exempted the active lease.
Keying the exemption on the presentation alone — which is what stopped a wake
from re-presenting a displayed campaign — made the two disagree: a poll that
failed on a dropped connection left the modal mounted with no presentation to
show for it, so the retry that recovered the very same activity read the device
impression, found nothing on screen, and cleared the host. The campaign
vanished mid-flight on ordinary network noise and could not return for the rest
of the run.

The presentation now follows the lease it belongs to, withdrawn only by a
withdrawal. The wake fence still releases it on hidden, because that
presentation is genuinely over.

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

* fix(cms): retain presentation with active lease

* test(cms): await mounted presentation before refresh

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: ivy-ting <234137810+ivy-ting@users.noreply.github.com>
2026-09-19 11:28:37 +00:00
Ray Xi 053abdc1b7 test(web): stop the clarification reattach spec racing the transcript load (#8270)
* test(web): pin the transcript-load race in the clarification reattach spec

Settle the transcript read 200ms after the first render, the ordering a slow
CI runner produces. The case now fails deterministically with the CI
signature (expected null not to be null at the streamViaDaemon wait).

* test(web): send only once the composer accepts a send in the clarification reattach spec

onSend is mounted from the first render, but ProjectView refuses a send
issued before the active conversation's transcript read settles without
starting a Run. Wait for the same readiness the production composer gates on
(active conversation, not loading, send enabled) before sending.
2026-09-19 06:17:04 +00:00
Ray XiandAmy 6fd2f60802 fix(web): answer an empty wallet on Home before the project hand-off (OPEND-3300, OPEND-3309) (#8240)
* fix(web): answer an empty wallet on Home before the project hand-off

A Home send opened the optimistic project frame on the click tick and only
then ran the OpenDesign Cloud balance gate, so a $0 wallet produced a
"准备中" frame first and the balance dialog seconds later — on a Team
workspace up to ~6s, since the gate forces an authoritative upstream read
with retries (OPEND-3300, OPEND-3309).

The shell already holds the wallet reading it shows in the rail. When that
reading is a definitive $0 for the exact send scope, the dialog now opens on
Home immediately: no frame, no project, no request. One background
confirmation re-reads the wallet; only a fundable answer moves the send onto
the ordinary hand-off, and a dismiss in the meantime wins.

Red specs: EntryShell.amr-pending-handoff (OPEND-3300 cases, red on the
unpatched shell) and e2e/ui/home-amr-pending (blocked case now on Home, plus
the stale-$0-then-fundable case).

* fix(web): wait for the workspace identity read before gating a Home AMR send

A Team member with no billing permission and a $0 Team wallet was shown the
personal upgrade dialog on Home while the project page correctly asked them
to find the owner. Home's balance gate read the shell's exact workspace
identity at the click; while that read was still in flight (cold start, a
switch or a sign-in resolving) it had no scope, fell back to the ACCOUNT
wallet and, with no context to name an audience, chose the owner branch.

`awaitHomeAmrGateWorkspaceContext` now holds the send until the read the
shell already has in flight settles (bounded; a read that never settles
returns the draft with the existing "couldn't confirm balance" notice). Send
still never starts identity discovery, and a shell that has settled without a
workspace identity keeps the legacy account-scoped gate.

Pins: settled Team member → owner dialog + Team scope; identity in flight →
the gate waits and then scopes the Team wallet; never settles → notice, no
gate, no dialog.

* fixer: address PR #8240 follow-up items

Generated-By: looper 0.13.0 (runner=fixer, agent=codex)

* Revert the Home gate identity wait and its test adaptations

Reverts 8ee3a105c1 ("wait for the workspace identity read before gating a Home
AMR send") and 0fdd577c1c (the fixer's test changes on top of it), restoring
the approved state of this PR.

The change rested on an unconfirmed reading of a QA report and altered a
deliberately pinned behavior without a product decision: the existing pin
"starts the team gate from in-memory directory identity when sessionStorage
fails" had to be rewritten into "waits for the team identity before gating"
to make CI pass. Both commits are kept on
fix/opend-3300-home-gate-identity-wait until the tester's runtime readout and
the decision are in.

* fix(web): preserve Team authority in local Home balance gate

Forward the selected workspace type and role to the local context endpoint so a Team member or admin with an empty wallet sees the owner top-up dialog on Home. Cover both roles with a daemon-style context response.

---------

Co-authored-by: Amy <1184569493@qq.com>
2026-09-18 10:27:25 +00:00
nettee a4775ecea2 chore(pack): upgrade bundled Vela CLI to 0.0.38 (#8273) 2026-09-18 07:57:29 +00:00
Ray Xi 3c7b16b442 fix(web): keep the project split still across the Home hand-off (OPEND-3207) (#8229)
* fix(web): keep the project split still across the Home hand-off

The optimistic creation frame left `.split` on its stylesheet default
(460px) while ProjectView resolved the saved width, or the equal split of
the container, only after it mounted — and that first write ran through
the 200ms width transition, so the chat column moved once the real view
arrived (OPEND-3207).

Move the split geometry into `project-split-layout.ts`, have both surfaces
resolve their first-paint width through `resolveProjectSplitLayout`, and
settle a surface's first measured write without the transition.

Red spec: e2e/ui/home-send-split-width.test.ts (red on main, green here).

* test(web): read the 4px handle constant from project-split-layout

The material spec pinned `SPLIT_RESIZE_HANDLE_WIDTH = 4` in ProjectView's
source; the constant now lives in project-split-layout.ts and ProjectView
imports it, so assert on the module and on the import.

* test(cms): wait for the Test modal impression before switching language

The locale case only passed while the impression frame never fired: on a
fast machine the next language switch unmounted the modal before its
requestAnimationFrame recorded the impression. CI is slow enough for the
frame to land, and the switch that follows then closes the modal for good.
Waiting for the recorded impression makes that order deterministic.

* fix(cms): keep the open Test modal across a same-deployment reload

A language switch (or lease renewal) reloads the selected Test deployment.
While the new decisions are in flight the runtime publishes a session
without decisions, and the modal treated that gap as the presentation
ending: it dropped its "open" marker, then the returning decision looked
like a new offer of an activity this account had already seen and stayed
closed.

Key the open presentation by the selected deployment and snapshot. A
reload of the same selection continues it; a redeploy, a different
account, a dismissal, or the session ending still releases it.

* test(e2e): enroll the Home hand-off specs in the ui_p0 entry-settings group

home-amr-pending, home-send-single-loading and home-send-split-width were
merged (or opened) without being enrolled in uiP0Groups, so no merge lane
had ever executed them; only the prerelease full pool would have. Enroll
them with the other Home-entry files.
2026-09-18 07:45:47 +00:00
lefarcen 792480fe21 fix(daemon): bound stored run events and keep crashed runs from acting on truncated prompts (#8170)
* test(daemon): red specs for unbounded persisted run events

Incident 2026-09-14: a cursor-agent conversation (~169 turns) stored every
unrecognised stdout line verbatim as a `raw` event. One `editToolCall`
completion repeats the edited file twice (~1.2 MB) and the `user` line echoes
the whole composed prompt, so each turn stored ~1.4 MB that nothing renders.
The renderer and daemon ran out of memory, and `GET .../messages` failed with
`RangeError: Invalid string length`.

These specs pin the invariants the fix must establish, against 0.22.x-shaped
rows (seeded straight into the daemon's tables) and the real parser +
persistence path:

- I1: every persisted run event fits a 64 KiB budget; meaningful events
  (status, done_key, text, usage) are stored unchanged; nothing is dropped;
  live `raw` lines are bounded too.
- I2: GET messages returns a bounded transcript; the PUT sibling-Run guard and
  the project run-status projection keep their verdicts without any database
  read handing a run's event log to JS.
- I3: a background, idempotent, one-row-per-transaction heal bounds existing
  rows, skips active runs, and is safe to interrupt.

* fix(daemon): bound persisted run events and heal oversized rows

A persisted run event never carries an unbounded payload (I1):
`runtimes/run-event-payload-budget.ts` bounds every stored event to 64 KiB of
JSON. An oversized event keeps its kind, identity fields and count; its
payload is shortened deterministically to a 16 KiB head+tail with an inline
`[open-design: ...]` marker (bytes cut, original size, digest) and a
`truncated: { originalBytes }` field. cursor-agent `tool_call` lines drop their
full-file copies (`beforeFullFileContent`, `afterFullFileContent`, read
`content`) first so the retained JSON stays valid and meaningful; Write/Edit
rows keep their `+N -M` via `od_diff_stat`; text/thinking are never cut.
Applied at the json-event-stream source (live SSE + run buffer), at the
SSE->persisted conversion, and at every events_json/batch write.

Loading a conversation never materializes unbounded data (I2): GET messages
streams rows and bounds legacy rows on read; the PUT sibling-Run guard reads
each sibling's first done_key in SQLite (`listSiblingRunDoneKeys`); the
project run-status projection picks each project's latest run in SQLite and
reads only the events `eventsEndedWithUnfinishedWork` can see.

Existing oversized rows are healed once (I3):
`storage/message-event-payload-heal.ts` runs after the daemon is listening,
one row per transaction, streaming each row's events via json_each, skipping
queued/running runs, and records completion in `daemon_maintenance_passes`.

Contracts gain the optional `AgentEventPayloadTruncation` field on raw,
tool_use and tool_result events (and the SSE raw payload).

* fix(daemon): keep listMessages working on all()-only DB adapters

Streaming rows with `iterate()` is what keeps a pre-heal conversation read
from holding every row's legacy event log at once, but some integration
boundaries hand listMessages a lightweight adapter that models only
`prepare().all()` (as `hasMessageEventBatchStorage` already tolerates). Fall
back to `all()` there. Also prepare the run-status completeness statement once
per listing instead of once per project.

* test(daemon): red spec for unbounded live raw lines from every parser

claude-stream, copilot-stream and qoder-stream hand unrecognised stdout lines
to the SSE fan-out verbatim; only json-event-stream bounded them at the
source. Pin that every parser's live raw event fits the 64 KiB per-event
budget on both the newline and flush() paths, and that a line that fits is
passed through unchanged.

* fix(daemon): bound raw lines at the source in every stream parser

Apply boundedRawAgentEvent to the raw emitters of claude-stream,
copilot-stream and qoder-stream, as json-event-stream already does, so the
live SSE stream and the in-memory run buffer carry the same bounded line the
transcript stores. Live tool_use/tool_result stay unbounded on purpose:
Langfuse tool spans and artifact detection read them from the live run.

* fix(daemon): deliver agent prompts intact and reap leftover agents after a daemon crash

Plain-text-stdin runtimes (cursor-agent, codex, opencode, copilot, qwen,
amp, ...) received the composed prompt through an async pipe write. When
the daemon died right after spawn, only the kernel pipe buffer (64 KiB)
reached an agent that reads stdin late, and the detached agent (running
with --force) kept going on the truncated prompt, whose last visible
request was the conversation's oldest one.

- Deliver the prompt as a file-backed stdin: a 0600 file under the run
  directory, passed as the child's stdin fd at spawn and deleted right
  after. If it cannot be written, the run fails before any agent exists.
  Claude's stream-json pipe is unchanged.
- Record each spawned agent (pid, process group, spawn window, daemon
  identity) in its run directory. On the next daemon start, terminate
  leftovers whose recording daemon is gone and whose identity still
  matches, and sweep stale prompt files. A failed record write fails the
  spawn instead of leaving an agent nothing can reap.
- Add process-identity helpers to @open-design/platform.

By design a leftover agent keeps running until the next daemon start;
the interrupted run reads back failed / DAEMON_RESTARTED.

Red first: with main's behaviour a late-reading agent received 65,536
bytes and its process group survived a restart. The agent-process specs
and e2e/tests/dialog/leftover-agent-reap.test.ts go red there and are
green on this change.
2026-09-18 05:58:46 +00:00
lefarcen 441ab84b0b fix(web): hide an od-card whose JSON does not parse (#8264)
* fix(web): hide an od-card whose JSON does not parse

A closed `<od-card>…</od-card>` block whose payload fails to parse was
painted to the user as prose — Markdown and all, so `user_profile` came
out italic. A tail comma, a missing `summary`, a misspelled `type`: the
model writes all three, and each one put raw protocol markup on screen.

Product ruling (user, 2026-09-18): "od-card 如果 json 不对, 就不显示,
不然用户会觉得是乱码...还不如不显示". The block is now dropped instead.

The drop lives in the web render helper `splitShellCards`, not in the
shared `splitOnOdCards` parser: that parser is a lossless split whose
other callers (`chat-protocol-context` Markdown skip-ranges, daemon
`memory-verify`) read spans of the original text and need every
character back. Both chat surfaces — shell narration (`SayBlock`) and
prose (`AssistantMessage`) — already route through `splitShellCards`.

"Malformed" and "still streaming" stay separate states: dropping needs a
matched close tag, so a card mid-flight is still withheld and can still
complete into a real card. A fenced code block quoting the protocol is
still user prose, and a valid card still renders as `OdCardView`.

Refs OPEND-2745

* fix(web): classify Markdown over the text a dropped card leaves behind

Dropping a malformed block without recomputing the Markdown context opened
a second leak of exactly the kind this change exists to close. `codeRanges`
was still derived from the raw input, so markup inside a payload the user
never sees kept voting: a payload carrying an unclosed ``` fence on its own
line marked everything after it as code, the next perfectly valid card was
skipped by `rangeContains`, and the tail `appendText` emitted that valid
card as raw `<od-card …>` markup.

Markdown context is now classified over the text that will actually be
rendered — the prose retained so far in the current render plus the
unconsumed suffix — and positions are mapped into that view.

Recomputing from the suffix alone would be wrong in the other direction. A
dropped block is a hole in one continuous render, not a break in it: the
prose before and after it is handed to Markdown as a single string, so the
prose before the drop must keep its say over what follows. Judged on the
suffix alone, a ``` that sits mid-line in the real render becomes a
line-leading fence that hides everything below it — the same leak again.
A rendered card, by contrast, really does end the render, so that branch
keeps resetting as before.

Refs OPEND-2745
2026-09-18 05:57:03 +00:00
Ray Xi b419463f9e fix(web): switcher menu survives transcript scroll, no selected-card ring, queue drop bar between cards (OPEND-3283/3284/3203) (#8223)
* fix(web): keep the switcher row menu open under transcript scroll, drop the selected-card ring, draw the queue drop bar between cards

OPEND-3283: the project switcher's row ⋮ menu closed on ANY captured document
scroll, so the chat transcript auto-scrolling under a running turn dismissed it
before it could be used. A scroll now only dismisses the menu when it moves the
trigger (the document, or a scroll container that contains the ⋮ button).

OPEND-3284: a selected project card in multi-select mode no longer draws the
hairline ring around the whole card (grid and list). The check badge is the
selected state.

OPEND-3203: the drag-reorder insertion bar was a pseudo-element on the row
inside the card, clipped by the row's overflow: hidden; only its halo leaked
back inside the card. QueuedSendStack now draws one bar in the column, in the
gap between the two cards.

* fix(web): keep the Test campaign modal open across a language switch

The Test runtime republishes with no decision while it reloads for a new
locale, and ProductionCampaignModal dropped the open presentation's claim in
that window. Once the activity had been recorded as displayed, the decision
that came back for the new locale read as a new offer of a recorded activity
and stayed closed — every language switch turned into a permanent close.

The claim now survives a reload of the same deployment (locale swap, lease
renewal) and is released by a dismissal, the runtime going away, or a
different deployment, so a redeployment of a recorded activity still stays
closed as before.

TestCampaignHosts "switches all Test placements together when the client
language changes" flaked on CI on exactly this: whether the visibility frame
had recorded the modal before the switch depended on runner speed. The spec
now waits for that frame so it covers the recorded case deterministically.

* fix(web): scope campaign claims to deployments and use chat theme tokens

Generated-By: looper 0.13.0 (runner=fixer, agent=codex)
2026-09-18 04:09:38 +00:00
CaprikaandClaude Opus 5 3d76f3c94c fix(cms): keep watching a hover entry until it is really visible (#8263)
The hover entry sampled its visibility once, one animation frame after the
state update that removes `hidden`. When that frame won the race with React's
commit the host was still `display: none` and reported no box, and because
nothing ever looked again the Test acceptance receipt was suppressed for the
whole session — the entry then displayed normally, so the campaign sat in the
CMS waiting on a receipt that could no longer arrive.

Visibility now resolves through one shared watch that re-checks on layout,
on page visibility and after re-mount, reports at most once, and keeps
watching a slow host instead of giving up on it. `mountTouchpoint` moves onto
the same watch so the modal and badge hosts stop carrying a second, weaker
copy of this rule.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-18 04:08:17 +00:00
Caprika e35e6542f3 fix(cms): refetch a replaced Test context instead of failing every placement (#8210)
* fix(cms): refetch a replaced Test context instead of failing every placement

The Test runtime adapter fetched its context once and compared every
decision's testContext.updatedAt against it. After the server replaced the
context generation, each 1s/3s retry and 30s poll reused the stale context,
so every placement failed with touchpoint_decision_mismatch and nothing
rendered.

A decision that names the selected deployment and scenario under another
context generation now triggers one single-flight context refetch per load
attempt, then the decisions are judged again. Any other mismatch still fails
without refetching, and a refused refetch fails closed without restoring the
old context.

Refs OPEND-3298

* fix(cms): publish a refreshed Test context under a new lease

After a context refetch, the load reused the previous session because its
selection key had not changed, and the lifecycle kept the previous value for
the same lease key. The lease was renewed from the fresh decisions while the
published session still carried the replaced context and decisions, so static
actions stayed authorized against the stale generation.

The lease key now includes the context generation (updatedAt and tester), and
the session is reused only while its context is unchanged.
2026-09-18 03:20:08 +00:00
lefarcenandClaude Opus 5 c3ec5fda71 fix(web): decode od-card in the thinking stream (#8258)
* fix(web): decode od-card in the thinking stream

The thinking stream was the one prose channel that never saw the shared
od-card parser: `ExecutionShell` handed `kind:'thinking'` text straight to
`ThinkingMarkdown` -> `renderMarkdown`, with no `splitShellCards` anywhere
on the chain. A perfectly valid `<od-card type="memory-applied">…</od-card>`
written into reasoning therefore reached the user as raw tag text — the same
symptom #7956 closed for in-shell narration (OPEND-2607), on the third
channel.

Route thinking prose through `splitShellCards`, the same parser `SayBlock`
and `AssistantMessage` already use, so there is exactly one answer to "which
bytes of a model message are a protocol card and which are user-readable
prose". Streaming semantics come from that parser too: a half-written opener
waits for the next delta instead of flashing raw, and an unclosed card does
not swallow the prose in front of it.

Deliberately unchanged: `splitOnOdCards` still keeps a malformed card as raw
text ("Malformed — keep raw text so the user can still see it"). That is a
separate product call and is not touched here.

Refs OPEND-2745

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

* fix(web): keep thinking-stream cards out of the char reveal

Rendering `OdCardView` inside `ThinkingMarkdown` put it under that
component's single char-reveal root, and `useCharReveal` walks the whole
subtree. So the frame where a streaming card closed, the visible-text length
jumped and the reveal shredded the card's OWN text nodes into per-character
`data-char-reveal` spans — truncating React-owned nodes React was still
updating, which is the exact hazard `useCharReveal`'s header warns about.

Mark the card subtree with `[data-no-reveal]`, the opt-out that hook already
declares (`SKIP`) for content that sits inside a reveal root without being
prose the model is typing. This is its first consumer. `collect()` and
`measure()` share the filter, so a card's text no longer counts toward the
length delta either: a card appearing can no longer be mistaken for a burst
of new characters that re-reveals prose which never changed.

`SayBlock` needs no such attribute because its reveal roots live INSIDE
`SayText`, one per prose run, so cards are siblings of every root rather than
descendants. Thinking cannot copy that shape: the reveal budget, the
"settled on mount" state and the module-level `claimHistoryReplayLanded`
token are all per-root, so N roots would mean N independent budgets and a
replay token only the first root claims — breaking OPEND-2590's "replayed
history does not re-reveal" for the thinking slot. One root plus a subtree
opt-out keeps both properties.

Refs OPEND-2745

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 21:55:10 +00:00
lefarcen ea58bd7526 fix(web): stop replaying OD Next turns parked on the user (#8250)
* fix: preserve ChatPanel history and clarification state

* test(daemon): align artifact replay assertions with durable history

* fix(web): stop replaying OD Next turns parked on the user

A clarification turn ends with its Run succeeded while the strategy task
stays open (`clarification_required`, `plan_ready`). The recovery probe in
`attachRecoverableRuns` only bailed out for terminal tasks, so it cleared
the finished row and replayed the Run from event 0. A conversation refresh
landing mid-replay adopted the persisted transcript, the rest of the replay
was appended behind it, and the replay's terminal PUT stored the duplicate:
the question form card rendered with raw form JSON under it.

A succeeded Run whose task holds no active Run has nothing left to follow;
the daemon claims any automatic successor in the same transaction as the
verdict, which a probe would see as a different `activeRunId`. Seal such a
Run without clearing or replaying it. Rows that still need transcript
recovery keep replaying.

Refs OPEND-3230
2026-09-17 16:42:23 +00:00
lefarcen 425a332373 test(web): parse the shared cascade once in plan-step-weight and audio-wave tests (#8249) 2026-09-17 16:28:03 +00:00
open-design-crew[bot]andlefarcen 5b50e9e4b0 fix(chat): retain blocked task verdicts during recovery and reload (#8008)
* fix(chat): preserve manual file ownership across run recovery (OPEND-2928)

Record successful workspace document writes and exclude their unchanged
receipts from assistant produced/trace files while retaining proven Agent
writes and existing unknown-runtime output handling.

Carry receipts through reconnects of the same scoped physical run, including
transport failure ordering and manual reconnect after a missing status.
Keep cleanup on real terminal exits, failed run creation and scope changes.

* fix(chat): preserve file ownership during inline artifact recovery

Use the resolved project authority for artifact writes and carry scoped
manual-write receipts through terminal error handling into artifact recovery.
Read the transport text when deciding whether artifact recovery is pending,
and filter existing produced/trace projections without dropping Agent output.

* fix(chat): preserve task verdict after artifact recovery

* fix(web): restore blocked task verdicts from cold conversation history

* test(chat): align blocked history hydration spec with approved ChatPanel failure actions

#8140 gave the reason-specific and generic run-error cards identical approved
copy and made CLI failures offer Switch to Cloud instead of Retry (OPEND-2807).
Assert the restored error code at the real ChatPane boundary instead of the
now-shared title text, and expect the CLI recovery action.

---------

Co-authored-by: lefarcen <935902669@qq.com>
2026-09-17 16:27:11 +00:00
open-design-crew[bot]andlefarcen 30d184ac49 fix(chat): publish all recovered outputs without claiming manual files (#8007)
* fix(chat): preserve manual file ownership across run recovery (OPEND-2928)

Record successful workspace document writes and exclude their unchanged
receipts from assistant produced/trace files while retaining proven Agent
writes and existing unknown-runtime output handling.

Carry receipts through reconnects of the same scoped physical run, including
transport failure ordering and manual reconnect after a missing status.
Keep cleanup on real terminal exits, failed run creation and scope changes.

* fix(chat): preserve file ownership during inline artifact recovery

Use the resolved project authority for artifact writes and carry scoped
manual-write receipts through terminal error handling into artifact recovery.
Read the transport text when deciding whether artifact recovery is pending,
and filter existing produced/trace projections without dropping Agent output.

* fix(chat): finish scoped recovery of all run outputs

* fix(web): retain manual write receipts through overlapping artifact recovery

---------

Co-authored-by: lefarcen <935902669@qq.com>
2026-09-17 14:56:16 +00:00
lefarcen f5a03f77a7 fix(chat): drop the rounded clip above the chat log so the compositor can hit-test it (#8228)
* fix(chat): drop the rounded clip above the chat log so the compositor can hit-test it

Every wheel notch on .chat-log went through
WidgetInputHandlerManager::FindScrollTargetOnMainThread (13/13 notches in a
real Chrome 152 cc/input trace; 0/6 for a plain overflow div) because the
transparent chat card combined overflow:hidden with border-radius. With the
radius gone the same trace shows 0/13 main-thread hit tests, with and without
the v0.22.2 backdrop-filter. Red spec computes the cascade itself, like the
w95 reserve spec. Also adds OD_MOCKS_TEXT_DELTAS to the claude mock so a
replay streams token deltas like a real --include-partial-messages run.

* test(web): pin the chat card square-cornered in the pane material spec

The material spec pinned border-radius: var(--radius-lg) on the transparent
chat card, which is exactly the rounded clip the previous commit removes.
Pin the square corner instead and cross-reference the rounded-clip guard.
2026-09-17 14:54:36 +00:00
open-design-crew[bot]andlefarcen 863b62b392 fix: preserve ChatPanel history and clarification state (#8225)
* fix: preserve ChatPanel history and clarification state

* test(daemon): align artifact replay assertions with durable history

---------

Co-authored-by: lefarcen <935902669@qq.com>
2026-09-17 14:55:36 +00:00
Ray Xi e285cca2b3 fix(cms): keep the open Test modal across a language switch (#8235)
* test(cms): wait for the Test modal impression before switching language

The locale case only passed while the impression frame never fired: on a
fast machine the next language switch unmounted the modal before its
requestAnimationFrame recorded the impression. CI is slow enough for the
frame to land, and the switch that follows then closes the modal for good.
Waiting for the recorded impression makes that order deterministic.

* fix(cms): keep the open Test modal across a same-deployment reload

A language switch (or lease renewal) reloads the selected Test deployment.
While the new decisions are in flight the runtime publishes a session
without decisions, and the modal treated that gap as the presentation
ending: it dropped its "open" marker, then the returning decision looked
like a new offer of an activity this account had already seen and stayed
closed.

Key the open presentation by the selected deployment and snapshot. A
reload of the same selection continues it; a redeploy, a different
account, a dismissal, or the session ending still releases it.
2026-09-17 14:02:40 +00:00
Ray Xi 2acb7f6f69 chore(pack): upgrade bundled Vela CLI to 0.0.37 (#8224)
* chore(pack): upgrade bundled Vela CLI to 0.0.37

* test(amr): align Vela continuation contract with 0.0.37
2026-09-17 11:38:54 +00:00