Skip to content

DO NOT MERGE — merge train (dry run, 2nd): 2778,2780,2781,2782,2783,2784,2785,2787,2772,2893,2894,2895 - #2897

Closed
dolho wants to merge 31 commits into
devfrom
train/20260918-0948-dolho-2
Closed

dolho wants to merge 31 commits into
devfrom
train/20260918-0948-dolho-2

Conversation

@dolho

@dolho dolho commented Sep 18, 2026

Copy link
Copy Markdown
Contributor

Integration surface for #2778, #2780, #2781, #2782, #2783, #2784, #2785, #2787, #2772, #2893, #2894, #2895 after the #2896 findings were fixed on #2781 and #2784. Dry run, never merged.

dolho and others added 30 commits September 18, 2026 10:32
…in, and a tier it does not reach fails the run (#2888)

Fixes #2888

The unclassified-directory guard globbed `tests/*/` after the script had
already `cd`'d into `tests/`, so it looked for `tests/tests/*/`, matched
nothing, stayed a literal `*` under default globbing, failed
classification and `exit 1`'d the run at the api boundary. Every
full-suite run since a646de8 (2026-08-27) stopped before the `api`,
`standalone` and `postgres` tiers — ~1,900 tests — and nothing said so.

- The guard is now `tests/harness/check_test_dirs.py`, handed TESTS_DIR
  explicitly instead of globbing relative to the cwd, and testable: only
  a directory that HOLDS test files is a finding (`__pycache__`,
  `reports/`, a stale local checkout with only `node_modules` are on
  every machine and collected by nothing), and an owner entry with no
  directory on disk is flagged too (pytest ignores a missing `--ignore=`
  path silently).
- Tier ledger: DECLARED_TIERS is checked against the recorded rows at
  the end. A declared tier with no row that the caller did not deselect
  is reported `NEVER RAN` and fails the run; deselected tiers (`--tier`,
  `--no-pg`) are listed as SKIP and the verdict says `partial`.
- Abort trap: any exit before the summary — including SIGINT/SIGTERM —
  prints an ABORTED banner naming the tiers left unrun and exits
  non-zero. The disposable postgres container is torn down on that path
  too (the old per-tier `trap ... EXIT` is folded into it).
- The alembic step of the postgres tier now carries the same dummy
  Redis/encryption env pg-migrations.yml sets: revision 0041 (#2330)
  imports `services` → `config`, which raises without Redis credentials.
  Broken since 2026-08-20, a week before the tier stopped being reached.

Guards in tests/unit/test_2080_harness_contract.py: the glob is matched
as code (not the comment that names it), the guard passes on the real
tree with the real owner lists, fires on an unwired directory holding
tests, ignores test-less directories, every `run_tier` name is in
DECLARED_TIERS, and the NEVER-RAN / abort paths are present.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A 503 from POST /chat or /task was skipped as "Agent server not ready" at
~50 live-test sites, so an exhausted credit balance — the agent ran the turn
and failed on the credential — read as a benign readiness skip while the run
stayed green. The two causes shared one reason string because the sync 503
body is prose reconstructed from the agent's error text; the backend already
computed TaskExecutionErrorCode and dropped it at _map_task_failure.

Backend: an ADDITIVE X-Trinity-Error-Code header on every sync /chat|/task
failure the backend classifies (auth / billing / network / agent_error /
capacity / timeout). /task threads TaskExecutionResult.error_code; /chat
derives it from agent_status_code with the same producer-side rule
task_execution_service applies. Bodies are byte-identical to before.

Tests: tests/testkit/readiness.py holds the one classifier —
require_agent_answer reads the header first, falls back to transport
vocabulary pinned to the backend source, and FAILS on anything the agent
answered; only `network` skips, naming its evidence. A `requires_model`
marker opts a test into the session model_provider_preflight fixture
(claude_auth_configured flag, then one real /task probe on TEST_AGENT_NAME),
so a stack whose credential cannot execute fails once with one cause; a
credential-class verdict seen mid-session fails later model turns at setup
instead of spending a 120 s call each. 53 sites converted across the six
files in the issue plus test_fan_out.py and test_dynamic_thinking_status.py.
An AST guard fails CI on a bare `if status == 503: pytest.skip` after a
model-turn POST.

Fixes #2889

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

Six bespoke overlays each re-implemented the same markup and each omitted the
same two behaviours: Esc did nothing, and Tab walked out behind the overlay.
Verified still true on `dev` before starting — **zero** `Escape` references
across all six files (the `@keydown` hits there are Enter-to-submit), no shell
primitive, no focus trap anywhere.

`BaseModal` owns exactly four things — overlay, Esc, focus, scroll lock — and
nothing about content, so adopting it is deleting two wrapper divs rather than
rewriting a dialog. It teleports to `<body>`: several of these modals are
declared inside panels that establish a stacking context, where a `z-50`
overlay nested in one renders BEHIND its siblings.

**The decidable half is a separate pure module, and that is the point.** This
repo's vitest runs `environment: 'node'` with no jsdom/happy-dom and no
`@vue/test-utils` — I checked, because a focus trap written entirely inside an
SFC would be a rule no unit test could reach, and source-text assertions would
prove only that it had been TYPED. `utils/focusTrap.js` holds every decision as
a function over plain data (tabbable filtering, the Tab wrap, the dismiss-key
predicate, safe-action selection, backdrop identity) with 25 tests. What that
leaves uncovered is the wiring itself — listener attachment, the focus() calls —
which needs a browser and belongs to e2e. Said plainly rather than implied by a
green suite.

Three decisions worth naming:

* initial focus goes to the SAFE action, and destructiveness is DECLARED
  (`data-destructive`) rather than guessed from label text, which would be
  wrong in every language but English. A dialog that opens with Delete focused
  turns a reflexive Enter into data loss.
* backdrop dismissal compares identity against the overlay node, not a
  rectangle — a rectangle test mis-fires for a select popup or date picker
  rendered at the document root and closes the modal under the user.
* a modified Escape (Ctrl/Cmd/Alt/Shift) does not dismiss; that is a browser or
  OS gesture, not an intent to close.

Migrated in this commit: `SystemViewEditor.vue` and `NavBar.vue`'s build-info
modal. The remaining four files are enumerated in the PR with their exact
overlay bounds; they are 80-230 line tag surgeries and are deliberately left
for a reviewed pass rather than done blind in one go.

One acceptance-criteria item is stale: `views/Agents.vue` was deleted by
ent#260 (the Agents page folded into the Dashboard list view), so its
bulk-tag popover no longer exists.

Related to #1923

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q19uRCksdn4DiRAJ55rfpZ
…irm() sites move onto it (#1924)

Stacked on #1923 — `ConfirmDialog` adopts the `BaseModal` shell built there.

The issue reads as "replace confirm() calls", but the thread rules otherwise
(webmixgamer, 2026-08-21), and I re-verified it still holds on dev:

  ConfirmDialog.vue has no focus trap, no initial focus on the safe action,
  no Esc handler, and its DOM order is confirm-first — so this issue's AC
  "Initial focus lands on the safe action" needs the primitive itself fixed,
  not just the call sites.

Confirmed: zero Escape / @keydown / focus() / role="dialog" in the file, and
`variant="danger"` at :58 ahead of `variant="secondary"` at :66.

**The confirm-first DOM order is fixed WITHOUT reordering the buttons.** The
confirm is marked `data-destructive`, which is what `focusTrap.initialFocusIndex`
skips, so focus lands on Cancel while the DOM order and `sm:flex-row-reverse`
stay exactly as they were and nothing moves on screen. Reordering was the
obvious fix and the worse one: it would have changed the rendered layout of a
dialog seven components already use.

Adopting BaseModal gives all seven existing callers Esc-to-close, a focus trap
and focus return at once — none of them change.

Every `data-testid` is preserved. `confirm-dialog-backdrop` is the one that
goes (BaseModal owns the overlay now); checked first — no test or component
referenced it, and the two that ARE used (`confirm-dialog`,
`confirm-dialog-confirm`) are untouched.

Call sites migrated here: `SystemViewEditor` delete-view and `GitPanel`
clear-PAT. Each names the verb ("Delete view", "Clear token"), restates the
consequence, and offers a named safe action instead of "Cancel/OK".

Remaining on the issue and NOT done here: Settings.vue (3) and
SchedulesPanel.vue (3), plus MobileAdmin's bespoke overlay and the two
unconfirmed single-click actions.

Not touched, by the thread's ruling: `/m`'s approval submit step (#2370) is an
inline p19-shaped flow by design and must not be re-modalised.

**Baseline edited by hand, not regenerated.** ConfirmDialog IMPROVED (raw_gray
9 -> 7 — its bespoke backdrop and the gray-500/gray-900 overlay went away), and
a stale ceiling fails the guard too. A wholesale `--baseline` run deleted the
entire `refrozen` provenance block and absorbed canvas/CanvasDocument.vue and
views/SharedCanvas.vue — two files that landed on dev after this branch was cut
— so the edit is scoped to the two entries that actually moved, per the rule
the 2638 note already states.

Related to #1924

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q19uRCksdn4DiRAJ55rfpZ
…ent#242)

The six admin endpoints (`enabled`, `provision`, `sync`, and the three
`access` routes) shipped with no writer outside curl: the consumer tools
`run_skill` / `list_runnable_skills` are advertised over MCP but gate on an ACL
table that nothing could populate. An entitled operator could activate the
feature and grant access only by hand-rolling HTTP.

This is that writer — one entitlement-gated Settings tab covering
enable -> provision -> sync -> per-(caller, skill) grants.

**No backend change.** All six endpoints already exist, are typed, and are
`require_human_admin`-gated (admin AND a human principal — widening an
allow-list must not be reachable by a prompt-injected agent's own key). That
premise was checked before planning rather than assumed, because it decides the
PR shape: had anything needed the private submodule, this would have been three
PRs in the ent#545 order instead of one.

Gated with `requires: 'skill_runner'`, the same declarative seam `sso` and
`credential-vault` use, so an unentitled install renders no tab rather than a
panel whose every control 404s. That gate is UX, not containment — the server
refuses regardless — which is what keeps a stale entitlement list from becoming
an escalation.

Decidable rules live in `skillRunnerPanel.js`, not the SFC: vitest runs
`environment: 'node'` with no mount harness, so a rule inside a component is one
no test can reach (the ent#392 precedent). 32 tests cover them.

Two details worth naming:

* `SkillRunnerStatus` reports each fact independently, and the panel keeps it
  that way: "no runner yet", "provisioned but not running" and "running" stay
  three states with three badges, and the library's own unconfigured /
  never-pulled / unreadable states are three more. None collapse into a
  spinner, and a blocked operator is told which screen fixes it.
* There is no endpoint listing the library for a grant picker — `/available` is
  agent-facing (ACL ∩ library for ONE caller, and 422s for a user-scoped key
  with no `caller_agent`). The picker uses the OSS `GET /api/skills/library`.
  Worth knowing for whoever builds the MCP half.

The revoke confirmation names the CALLER, not just the skill: "Revoke
summarise" reads harmlessly, while the decision being made is which agent loses
the ability to execute it.

Loading uses a skeleton keyed on "no data yet", not `ScanlineReveal` — #2540
ruled the beam is chart-loading only, and that primitive's importer allowlist
caught this panel reaching for it. Fixed the panel rather than widening the
allowlist, and pinned the choice with a test.

No feature-flow doc: a public doc describing this module would name enterprise
internals under `docs/**`, which is the enterprise-docs guard's scope and the
CLAUDE.md standing rule. No API or architecture changed, so tiered docs do not
require one.

Deferred, recorded on the issue: the MCP admin tools half (Invariant #13's third
surface), so an ops agent still cannot enable or grant without curl.

Fixes Abilityai/trinity-enterprise#242

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q19uRCksdn4DiRAJ55rfpZ
… and NavBar (#1925)

Three audited surfaces hand-rolled a tab/nav strip instead of using the
`OverflowTabs` primitive (#1114). Settings and Operations now render their tabs
through it; NavBar gets the same measured priority+ split without mounting it,
because its items are `<router-link>`s that must keep a real href and the
primitive's are `<button>`s that emit a selection.

NavBar is the case the audit called worst. Its link row was an `overflow-x-auto`
scroller with the scrollbar suppressed inside the 64px bar (#1789), so an
overflowed link stayed reachable but became invisible and undiscoverable — the
bar gave no signal that more navigation existed. It now renders the links that
fit plus a counted "N more ▾" disclosure, with the active state reflected on the
trigger when the current route's link is one of the hidden ones.

The fit arithmetic is SHARED, not re-derived: `utils/overflowFit.js` holds the
rule verbatim from `OverflowTabs::recompute`, and both surfaces call it. It also
gained a `gap` term, which NavBar needs and the tab strip does not — the tabs are
padding-spaced, while the nav row carries a real flex gap that is invisible to
the per-item rects, and a gap-blind sum hides ~120px on a six-link row (one whole
link) and so keeps the last one inline to clip.

Both new modules are pure because vitest runs `environment: 'node'` with no mount
harness, so a rule inside an SFC is one no test can reach (the ent#392
precedent). `utils/navLinks.js` likewise derives the link set once — a strip that
can hide a link renders the same link three times (inline, menu, hidden mirror),
and three hand-written copies is three places for an active predicate or a badge
to drift.

Colours: the new code is semantic-token only, so the active underline moves from
raw `blue-500/400` to `action-primary` (the primitive's own active token) and the
Enterprise PRO pill from raw `purple-*` to `accent-purple-*`. Visible as a slight
indigo shift on the nav's active indicator.

Triage of the audit's nine scanner candidates: every one is a bounded wide table
or a preformatted code block, where horizontal scroll inside the container is the
correct treatment (design-system principle 7) — collapsing columns into a "More"
menu would hide data, not navigation. None migrate; each is marked in place so a
later audit does not re-flag it. The advisory two-pill strip in Audit.vue is a
segmented control, a different primitive, and is recorded as such.

`workspaceNewTab.spec.js` (ent#456) asserted `target`/`rel` against NavBar's
template text; that truth moved into `navLinks.js`, so the NavBar half now drives
the data and additionally pins that the strip binds both fields onto its links —
the half a data-driven refactor can get wrong is correct data that never reaches
the DOM.

Baseline: NavBar raw_nongray 35 -> 19, raw_gray 125 -> 105. Edited by hand rather
than regenerated, per the `refrozen` note — a wholesale run deletes that block.

Fixes #1925

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q19uRCksdn4DiRAJ55rfpZ
Caught by a live check, not by the suite: at 1440px the strip hid four of five
links behind "4 more". The row and its parent cluster sized to content, so
`clientWidth` reported the row's own min-content — and because the inner nav is
`overflow-hidden`, that is near zero — making the fit rule correct about a
container that was lying to it.

The old #1789 row never needed this: a row that SCROLLS sizes to its content and
lets the overflow happen, while a row that COLLAPSES has to be told how much
space it actually has. `flex-1` on both supplies it; `justify-between` plus the
controls' `flex-shrink-0` keeps the right-hand cluster where it was.

Verified against the local dev instance across 1440/1300/1200/1100/1000/900/800/700
and back up again: one row at every width, never clipped, collapsing at 800 and
700 and re-expanding on the way back up.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q19uRCksdn4DiRAJ55rfpZ
…ll row (#1925)

`navbar-overflow.spec.js` is #1789's guard, and its third test asserts
`overflow-x: auto` on the link row — the hidden-scrollbar scroller this issue
deliberately replaced. It went red the moment the strip started collapsing, and
I missed it because I grepped `src/` for that pattern and never `e2e/`.

Confirmed it is this branch and not the tree: the spec passes against plain
`origin/dev` built and served the same way, and fails here.

Properties 1 and 2 — links never collide with the controls, controls stay
on-screen and the user menu stays hit-testable — are unchanged and still pass;
they were never about the mechanism. Property 3 now pins what #1925 actually
guarantees: the links that fit render inline, the rest collapse into a counted
"N more" disclosure, the count agrees with what the menu holds, every hidden
link is a real href, and Escape closes it.

It also pins the ABSENCE of the old mechanism — no `overflow-x: auto`, no
scrollWidth past the box. A future refactor reintroducing the scroll row would
restore exactly the undiscoverable-link failure this issue removed, and this is
where that gets caught.

`settleNav` is not politeness: the strip re-measures on a rAF after a resize, so
a snapshot taken in the same tick as `setViewportSize` reports the previous split
— "it fits" one frame before the links move into the menu. That is how the first
version of this rewrite failed, and the fix is to sample until two reads agree
rather than to widen a timeout.

Verified against this branch's build: 4/4 pass, and the collapsed branch is
genuinely exercised — at 640px the trigger reads "4 more" with 1 link inline and
4 in the menu, at 1440px there is no trigger and 5 links inline.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q19uRCksdn4DiRAJ55rfpZ
…#242)

`GET /api/enterprise/skill-runner/access` answers `{ grants: [...] }`
(`service.list_access` wraps `db.list_access`), and the panel read the
payload AS the array — so `grants` was always `[]`, every grant rendered as
"No agent can run any skill yet", `isDuplicateGrant` never fired, and the
only human writer for the ACL had no revoke path. The PR's own "the entitled
render is unexercised" note is exactly where it hid.

`grantsFrom(payload)` is the one unwrap rule, pure so the node-env suite can
pin the envelope shape; a bare list is still accepted, anything else is an
empty list rather than a throw (the status half of the same load must still
render). A source guard pins that the panel reads through it — reverting the
call site turns the spec red.

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

`test_no_service_parses_yaml_without_the_shared_loader` excluded virtualenvs
with `"/venv/" in rel`, where `rel` is RELATIVE to the scanned root — so a
venv at the root of that tree (`src/backend/venv/`, i.e. where one actually
lives) yields `venv/lib/...` with no leading slash, never matches, and every
vendored `safe_load` in `site-packages` is reported as an unguarded parse on
a clean checkout. Only a venv nested a directory deep was ever excluded.

The scan is now `_bare_safe_load_offenders(roots)`, pure over the filesystem,
with the exclusion matched on `path.relative_to(root).parts` against
`_VENDORED_PARTS` ({venv, .venv, site-packages, node_modules, __pycache__})
— position-independent, and a directory merely NAMED like one
(`my-venv-tool/`) is still scanned. `git ls-files` was rejected: it would
hide an untracked first-party file from the pre-commit scan.

Proven both ways on the real tree: a planted
`src/backend/venv/lib/python3.12/site-packages/starlette/mod.py` with a bare
parse no longer fails the guard, and a planted `services/_tmp_bare.py` still
does. The new fixture tests pin both directions across root/nested/.venv/
node_modules/__pycache__ placements. The other 34 `rglob("*.py")` guards
under tests/unit were audited with the same planted venv: none false-fires.

Fixes #2890

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…anical, per the merge-train note on the PR

CodeQL js/incomplete-multi-character-sanitization (train #2896, alert 364)
flagged the one-shot replace in a source-text guard. A fixpoint loop is the
shape the rule accepts; behaviour on every real input is unchanged.

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

Two papercuts found together, on the one page a 31-page sweep flagged as the
only source of a page-level console error — and it produced one on every tab.

**The 404.** Settings read `public_chat_url` through the generic
`GET /api/settings/{key}`, which answers 404 for a key nobody has written. The
store already treated 404 as "unset", so nothing was broken; what was lost was
the signal — a real failure on that call looked exactly like the ordinary case —
and no client-side handling can suppress the browser's own network log, which is
why the fix is a route and not a try/catch. `GET /api/settings/public-chat-url`
answers 200 with `value: null` when unset, following the `/mcp-url` precedent:
a named route for a named setting, declared above `/{key}` (Invariant #4). The
generic route's 404 is deliberately unchanged — it is the documented contract for
every other key and is read outside this repo.

Writing the spec found a SECOND key with the same defect, unnamed in the issue:
`platform_default_model`, 404ing eight times per Settings load. It needed no new
route — `/api/settings/feature-flags` already carries the resolved value — so the
page now reads it from there, which is also more correct: the control shows what
the platform will actually use instead of blank.

**The unbounded list.** MCP Keys rendered every key an instance had ever minted:
measured 306, of which 294 revoked (96%), ~71KB of DOM text, no filter, no bound.
Agent keys accumulate structurally — one per agent, one per #1854 rotation, one
per ephemeral ghost — so the page grows for the life of the instance and the 12
keys that still work are buried in the 294 that do not. Revoked keys are now
hidden behind an explicit toggle that STATES the count, the list is searchable by
name/prefix/agent, and rendering is bounded at 25 rows with a "Show more". The
rules are pure (`utils/mcpKeyList.js`) because vitest runs `environment: 'node'`;
the non-admin agent-key filter is carried through unchanged and asserted, since
it is an access rule wearing a filter's clothes.

Three empties, three next actions — "No API keys" was a lie to an operator
holding 294 revoked ones — but ONE piece of chrome: the wording is computed and
only the action row branches, because three copies of the markup would have
tripled this file's palette-class count.

The new controls are built from the Base* primitives (#2122), and the two
container borders they need are paid for by converting the create form's
hand-rolled name input and description textarea to `BaseInput`/`BaseTextarea` in
the same component: `McpKeysTab.vue` raw_gray 97 -> 83. Baseline edited by hand,
not regenerated.

Verified against a local dev instance (a key created and revoked to exercise the
toggle, removed afterwards): every tab loads with zero 404s and zero console
errors, the list renders 2 of 3 keys with "Show revoked (1)" and "2 active", the
toggle reveals the revoked row while staying bounded, search narrows to a named
empty state that offers the way back, and the converted create form renders and
binds in both themes. Red without the fix on both halves. Frontend unit suite 134
files / 2949 tests green.

Fixes #2202

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q19uRCksdn4DiRAJ55rfpZ
…g the page (#2197)

Three rigid flex rows scrolled the page body below 1024px, against the
design-system rule that wide content scrolls inside its own container.

The report blamed the tab strips, and that is not what was wrong. `OverflowTabs`
was collapsing correctly the whole time, at 375px included: the hidden mirror row
it measures against lives in a 0x0 `overflow: hidden` box and is invisible to the
user and to `scrollWidth` alike, but a probe that does not skip clipped subtrees
reports it as the widest thing on the page — which is how a measurement sweep
came to name `NAV.-mb-px.flex` and the "More" button. Measured with clipped
subtrees excluded, the real offenders are:

* **Agent Detail** — three `AgentHeader` rows, each a non-wrapping flex row of
  two rigid clusters: identity + Workspace/Talk/Running, toggles + Tags + the
  live CPU/MEM/uptime readout, and the lifetime-cost line. Together ~790px of
  min-content, so the page widened at every width below ~1024px (+496px at 375).
* **Settings** — the Default Model `<select>`, `flex-1` with the default
  `min-width: auto`. A select's min-content is its widest OPTION ("Claude Opus 5
  — Most capable Opus (latest) (recommended)", ~417px), so one control scrolled
  the page by 157px at 375. `min-w-0` is the whole fix.

The Dashboard overlap is a different failure with the same root shape. #1830 made
the stats cluster elastic so it degrades instead of being clipped — but elastic
with no floor means it collapses to ZERO, so the pressure never reached the
controls and the row never wrapped. Measured at 640px: stats 0px wide (its
clipped children still sitting under the controls' first button — the 8px the
spec reports), controls pinned at their 692px max-content and running ~70px past
the row's right edge. The row now wraps, the stats cluster carries the ladder's
own agents-only floor (~71px) so the overflow becomes a wrap, and the controls
may shrink and wrap their own buttons rather than being pinned wider than the row.

Rendering above ~900px is unchanged — measured single 30px row at 1600/1440/1280/
1024/900, wrapping to 91px at 768 and below, zero overlap at every width.

`space-x-*` becomes `gap-*` on each row that can now wrap: a wrapped `space-x`
row mis-indents every line after the first.

Verified against a local dev instance in BOTH themes at 375 and 640 (light and
dark, `dark` class asserted): `scrollWidth <= clientWidth` on Settings, Agent
Detail, Dashboard, Operations and Library at 375/640/768/1024.

`e2e/dashboard-stats-overflow.spec.js` (#1830), red on `dev`, is green. The new
`e2e/body-horizontal-overflow.spec.js` measures the one thing the contract states
— the document scroll width — and names the widest UNCLIPPED element on failure,
because an element-shaped assertion is exactly what produced a report naming the
wrong element. Proven to bite: with these three fixes reverted it fails Settings
at +157px naming the `<select>`, and Agent Detail at +496px naming the action
cluster.

Its `ready` predicates are load-bearing, not politeness. The widest markup on
both pages is gated behind a later render — Settings' admin-only `<select>`
behind `/api/users/me`, Agent Detail's live stats behind the stats poll — and on
a fixed settle the Settings arm PASSED with the fix reverted. That vacuous green
was caught by running the negative control, not by reading the test.

Fixes #2197

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q19uRCksdn4DiRAJ55rfpZ
A `table` block's cells were plain text, so `**Deploy**`, `` `done` `` and
`[runbook](https://example.com)` — what agents routinely put in status and
reference columns — showed their literal characters. The same canvas rendered a
GFM pipe table written in `markdown` prose correctly, so an agent saw two tables
behaving differently, and the one the MCP tool guide recommends was the broken
one. Both structured paths are fixed at once: `CanvasBlock` delegates `table` to
`ReportRenderer`, and a ```table fence inside a `markdown` block delegates to the
same component.

Cells render INLINE markdown only. `marked.parseInline` never emits a block
element, and the cell policy drops the ones raw HTML could smuggle in, so a
heading or a list in a cell degrades to its own text (DOMPurify's `KEEP_CONTENT`)
rather than breaking the row the #2583 gallery pins.

The split is about testability, not taste. `markdown.js` cannot be imported
without a DOM — DOMPurify's DOM-less stub has no `addHook` — and vitest runs
`environment: 'node'`, so anything decided inside it is unreachable by a unit
test. `utils/inlineMarkdown.js` holds the decidable half (the parse, the
markdown-vs-text decision, the escape, the allowlist) and is executed by
`inlineMarkdown.spec.js`; `renderInlineMarkdown` stays in `markdown.js` beside
every other DOMPurify call, on the SAME instance and hooks, so a cell link
inherits the app-wide `target="_blank"` / `rel="noopener noreferrer"` hardening
and there is no second sanitizer (H-005).

Only strings are parsed. A number, boolean or object keeps exactly its
pre-#2771 rendering (`JSON.stringify` for an object, `String()` otherwise) and is
escaped rather than parsed — running a JSON blob through a markdown parser would
let its own `*` and `_` italicise a value nobody wrote as prose.

Headers render on the same terms as cells: an agent that bolds a column name and
bolds the values under it should not get two behaviours.

`ReportTable` is shared with reports (ent#537 / #1535), so report tables gain the
same rendering — the same defect, fixed once. `ReportRenderer.vue` is untouched,
so the `display_hint` / `shapeOk` pins in `test_1535_report_prompt_guidance.py`
are unaffected.

Verified: 17 unit cases over the real configured parser; a new e2e seeds a canvas
through the real `PUT .../canvas/{id}` route and asserts the rendered DOM — bold,
code, a hardened link, em/del, the fenced path, and the half a unit test
structurally cannot reach: a `<script>` + `<img onerror>` payload in a cell
leaves `window.__2771` undefined with zero `script`/`img` nodes in the table.
Red without the fix (the `<strong>` never appears), green with it. Full frontend
unit suite 133 files / 2933 tests green.

Fixes #2771

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q19uRCksdn4DiRAJ55rfpZ
On every cold load the conversation column rendered full width, then lost the
rail's width the moment the roster arrived — sliding the composer, the thread and
the header left. Since #2676 that is a 300ms animated slide rather than a
one-frame jump, which is prettier and still a shift: the contract's
layout-stability rule is that loading and loaded share ONE footprint and nothing
moves on arrival.

Measured on a local instance before the fix, the Send button at 1440px goes
1327 → 1300 (27px) as the roster lands, and 916 → 884 (32px) at 1024px.

`railVisibleFor` answers "may the rail RENDER", and false for a non-ready stage
is right — its tabs need the roster. But the column's WIDTH does not need the
roster: it comes from the persisted rail state and is known synchronously at
first paint. So the wrapper now renders on `railHasColumn || railColumnReserved`
and gates only `PortalRail` inside. Space is reserved; content is not faked.

`railColumnReservedFor` is deliberately NARROWER than `railVisibleFor`'s route
set, and the exclusions are the interesting part:

  * an agent page never carries a rail, so reserving there would invent a gap;
  * a ROOM route is excluded even though a ready room usually has a rail,
    because that depends on `roomsAvailable`, which arrives ON the roster
    payload (#2128). Reserving against a capability we have not been told about
    yet would trade this shift for the opposite one on every install without
    rooms.

So it reserves for exactly the case the bug is about: a 1:1 conversation route,
mid-load. If the rail then turns out not to render, the column leaves through the
existing width transition — a shrink, not a jump.

Nothing about the motion changes: the `<Transition>` classes, the #2676
voice-canvas swap and `motion-reduce` are untouched, and the reserved column is
present from the first frame so its enter never runs.

Verified: the composer's x is stable within 1px across 24 samples through roster
arrival at 1440/1024/768/640, the column keeps the same width when the rail
lands, and `workspace-model-choice.spec.js` — the spec whose measurements the
#2676 transition split — stays green. Reverted, the same spec fails with the
27px/32px slides above, so it bites. Unit suite 135 files / 2957 tests.

One e2e case was written and then removed rather than left skipping: "an agent
page carries no rail column" asserted a premise the router does not hold —
`/workspace/a/:name` REDIRECTS into a conversation, which legitimately has a
rail. The rule it meant to check is covered in the unit spec, which does not
depend on which URL the router settles on.

Fixes #2711

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q19uRCksdn4DiRAJ55rfpZ
#2711)

Review finding, reproduced: the reservation fires for any non-room, non-agent-
page route while the stage loads, but `stageZone` keys on the ROSTER — so a
caller with no rostered agents settles `empty` and a failed roster fetch settles
`failed`. In both the rail never arrives, the reservation drops, and the column
left through the 300ms width transition:

    EMPTY roster   ->  none -> reserved(48) -> reserved(22) -> reserved(3) -> none
    FAILED roster  ->  none -> reserved(48) -> reserved(10) -> reserved(1) -> none

A 48px animated shift on first run — nobody has shared an agent yet — and on
every roster error, landing beside an error state. That is the same defect this
PR removes, handed back, and it is exactly the hazard the docblock reasons about
for ROOMS without applying it to `empty` / `failed`.

The fix is not to reserve less. Narrowing to `/workspace/c/:id` would protect the
AC's route and leave the common path — the nav's `/workspace`, which redirects
into a conversation — shifting as before, because the redirect happens after the
roster lands. Instead the LEAVE is animated only when the column actually held a
rail (`railEverHeldRail`): a reservation that paid off animates as before, one
that did not is given back in a single frame. Measured after:

    EMPTY roster   ->  none -> reserved(48) -> none
    FAILED roster  ->  none -> reserved(48) -> none

and the normal load is unchanged: `reserved(48) -> rail(48)`, no shift.

The ENTER keeps its transition unconditionally — it only ever runs for the
voice-canvas swap (#2676), since a reserved column is present from the first
frame and never enters. The flag resets per route, because the question is about
THIS stage: navigating from a conversation to an empty roster must not inherit
the conversation's verdict.

Two spec arms added for the second finding — the suite measured bare
`/workspace`, not the `/workspace/c/<session>` the AC names, so it was right
only by accident of the fixture:

  * a direct load of `/workspace/c/<id>` (resolved from the sessions route)
    keeps Send within 1px;
  * an empty and a failed roster give the column back with NO intermediate
    width. The assertion is about the shape of the removal, not its speed:
    every width observed must be the full reserved width or nothing, because an
    intermediate width IS an animation frame. Negative control: forcing the old
    unconditional leave fails it with "animated away through 6,22px".

`portalVoiceLayoutMotion.spec.js` pinned the literal `leave-to-class="!w-0"`,
which is now a binding. Updated rather than deleted: the #2676 property it
protects is intact, since the canvas only ever takes the row from a rail that was
on screen, so `railEverHeldRail` is true wherever that spec cares.

Frontend unit suite 135 files / 2957 tests; the rail and model-choice e2e suites
12/12.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q19uRCksdn4DiRAJ55rfpZ
…route-set guard (#2202) — mechanical, per the merge-train note on the PR

Train #2896's regression diff: the four route tests raised 'no current event
loop' on the CI interpreter (get_event_loop() on a fresh main thread), and
test_1028_settings_package pinned the post-split route set, so the new
GET /api/settings/public-chat-url read as a route invented by the split.
Neither could surface on this PR's own CI — a PR against a feature branch
does not run backend-unit-test.

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

Caught by chasing a red CI run rather than dismissing it. The reset watcher
added with the reservation read `route.value.fullPath`, but `useRoute()` returns
a reactive OBJECT, not a ref — every other route read in this 2,000-line file is
the plain form, and this line was the only `route.value.` in it.

It is not a silent no-op, it is a silent THROW: Vue routes a watch-getter error
to its error handler instead of aborting setup, so the page rendered, the rail
e2e passed 12/12, the unit suite passed 2957, and the watcher was dead the whole
time while every Workspace load logged

    TypeError: Cannot read properties of undefined (reading 'fullPath')

Verified in a browser before and after: the error is on the previous build and
gone from this one. The measured rail behaviour is unchanged (`reserved(48) ->
none` on an empty or failed roster, `reserved(48) -> rail(48)` normally) because
the flag is only ever SET by the other watcher on a cold load; what was broken is
the per-route reset, which is the in-session case.

Guarded rather than just fixed: `portalRailReserve.spec.js` now fails on any
`route.value.` in the shell, and the guard was proven to bite by reintroducing
the bug (it fails) — a dead watcher is invisible to every test that does not read
the console, so the spelling is worth pinning in the one file that mixes it.

Unit suite 135 files / 2959 tests. The three workspace e2e suites CI flagged —
rail-reserved, model-choice, stick-to-bottom — 15/15 locally.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q19uRCksdn4DiRAJ55rfpZ
… rest (#2201)

Several recurring treatments fell below WCAG AA. Fixed at the tier, not per
component, and the tiers are now measured rather than asserted in prose.

**The inverted tertiary pair.** `text-gray-400 dark:text-gray-500` is below AA on
BOTH sides — 2.54:1 on white, 2.13:1 on gray-700 — and it is the contract's own
ladder written backwards: light tertiary is gray-500, dark tertiary gray-400,
which is what 890 other call sites already use. 187 occurrences across 62 files
swapped. The class COUNT per file is unchanged, so the raw-colour ratchet does
not move.

**The warm families' light text tier.** On a light surface the 600 tier fails for
success (3.30), warning (2.94), autonomous (3.19) and urgent (3.56) while the
cool families pass (danger 4.83, info 5.17, purple 5.38, primary 6.29) — so the
rule is per TIER, not per family: light text is 700 (4.92–7.90 across all nine),
dark text is 400 (4.92–9.59 on gray-800). 128 paired occurrences across 52 files.

**The Operations count badge.** White ink on a 500 solid is 2.80:1 (urgent) and
3.76:1 (danger). Now urgent-700 (5.18) and danger-600 (4.83). It is small,
high-salience and carries a number someone is meant to read, so AA-normal is the
bar rather than the 3:1 large-text allowance.

**Host telemetry percentages** are read as numbers, so they are text: the 500
tier they used measures 2.28 (success) through 3.56 (urgent) on white. Raised to
700/400. The meter separators between them are decoration and are now marked
`aria-hidden` rather than darkened — styling a dot nobody reads would be the
wrong fix — and their one class string is hoisted, which pays for the dark half
the no-data placeholder was missing: HostTelemetry raw_gray 7 -> 4.

**The mechanical half (AC 6), in two layers.** `utils/contrast.js` is pure WCAG
arithmetic — linearized, because a channel-average scan is wrong in both
directions, which is why this issue's own first numbers had to be recalculated.
`tests/unit/contrast.spec.js` drives it over the REAL Tailwind palette the tokens
alias, so a palette bump or a token remap turns red instead of silently darkening
the app below AA; it pins both ink ladders, all nine status families at both
tiers, the badge recipe, and — deliberately — the two grounds the tertiary tier
does NOT clear, so a future palette change that fixes them prompts a revisit
instead of leaving a stale prohibition in the doc.

`e2e/contrast-ratchet.spec.js` is the page-level layer: distinct failing text
treatments per page and theme against a checked-in baseline, counted per
TREATMENT rather than per node so the number does not move with how much data an
instance holds. A page with no entry is held to zero, and an improvement that is
not banked fails as a stale ceiling — the #2605 rules.

What it freezes rather than fixes, stated plainly: a long tail of `text-gray-400`
written with no `dark:` sibling. Adding the missing half at ~1,500 sites would
grow the raw-colour ratchet by ~1,500 raw classes, so the contrast guard and the
palette guard pull against each other and the way out is a semantic ink token,
not a baseline bump. That is written into the contract beside the measured
ladders.

Verified in both themes against a local instance. Reverting the sweeps grows the
ratchet on all ten page/theme pairs (light dashboard 13 -> 16, settings 6 -> 10,
dark settings 2 -> 5), so it bites. Frontend unit suite 136 files / 2973 tests.

Fixes #2201

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q19uRCksdn4DiRAJ55rfpZ
@dolho dolho added the ui PR touches the frontend UI — triggers Playwright e2e tests label Sep 18, 2026
@vybe

vybe commented Sep 20, 2026

Copy link
Copy Markdown
Contributor

Closing this dry-run train surface — its members have since landed or moved on, and a lingering DO NOT MERGE PR is indistinguishable from a live one in the review queue. Of its twelve members only #2778 and #2894 were still open today; #2894 is merging on today's train and #2778 was ejected with findings on its own thread. Deleting the branch with it. (merge-train housekeeping, 2026-09-20)

@vybe vybe closed this Sep 20, 2026
@vybe
vybe deleted the train/20260918-0948-dolho-2 branch September 20, 2026 20:53
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ui PR touches the frontend UI — triggers Playwright e2e tests

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants