Skip to content

feat(tray): glance section — recent calls, connected clients, 24h histogram - #930

Merged
github-actions[bot] merged 77 commits into
mainfrom
feat/tray-glance
Jul 30, 2026
Merged

feat(tray): glance section — recent calls, connected clients, 24h histogram#930
github-actions[bot] merged 77 commits into
mainfrom
feat/tray-glance

Conversation

@Dumbris

@Dumbris Dumbris commented Jul 29, 2026

Copy link
Copy Markdown
Member

Implements docs/superpowers/specs/2026-07-29-tray-glance-design.md via docs/superpowers/plans/2026-07-29-tray-glance.md.

What it adds

A compact block at the top of the macOS tray menu: the five most recent tool calls, the connected MCP clients, and a 24-hour calls-per-hour histogram behind a submenu. Plus one Go change — a status filter on GET /api/v1/sessions, applied during the storage cursor walk before truncation.

Rows are plain NSMenuItems, never hosted SwiftUI views: custom menu-item views receive mouse but not keyboard events, so SwiftUI rows would not be keyboard-selectable. Only the histogram is a hosted view, and it lives in a submenu.

Load-bearing behaviours, each verified by mutation

  • Menu open performs zero network requests (spec 048's invariant) — structural: neither the section nor the submenu delegate can reach a client.
  • The menu never restructures while it is open. Rows update in place; structural changes defer to menuDidClose. This is load-bearing, not a nicety: glanceSessions republishes on nearly every 30s poll for an active session.
  • In-place updates rewrite a row's whole identity — title, image, tooltip, accessibility label and representedObject — keyed on requestId, not the id, which turns over on every reconcile. A title-only refresh would leave a row whose click opens the previous record's session.
  • A bucket's calls includes its errors, so the chart stacks calls - errors and errors. Verified on the producer, not just the consumer.
  • Shared AppState feeds are never narrowedrecentActivity backs the Dashboard's full log, recentSessions backs session-name lookup; the glance has its own feeds.
  • Status is encoded by shape and colour, never colour alone; the chart's two series are separated by lightness, since a legend names a channel without adding one.

Testing

289 → 455 tests, 0 failures. All gates green at HEAD: swift build clean (no new warnings across ~1,900 new Swift lines), swift test 455/455, go build ./... and -tags server, go test on 7 packages, make swagger-verify, golangci-lint v2 strict 0 issues.

Eight tasks, each independently reviewed and fix-looped clean, then a whole-branch review, then a cross-model (Codex) review, then one fix wave and a scoped re-review. Roughly 90 mutants killed across the reviews.

⚠️ Not yet verified — visual QA outstanding

The histogram is the first NSHostingView in this menu and has never been seen on screen. Unverified: whether the submenu opens populated, whether 24 bars read at ~10pt each, legend legibility at 288pt, a 132pt menu row height, Color.secondary against menu vibrancy in both appearances, and whether the chart's accessibility label is announced once or twice. Please do not merge before that pass.

Depends on #927

The glance clears its state on a coreState transition out of .connected. In external-attach mode that transition never fires (#927), so without it the block would keep presenting a dead core's data as a live, ticking display. The branch's logic is correct; its input is broken. Merge #927 first.

Dumbris added 30 commits July 29, 2026 13:38
…acOS menu

Design for a compact glance section at the top of the macOS tray menu:
five most recent tool calls, active MCP clients, and a calls-per-hour
histogram for the last 24h behind a submenu.

Key decisions and their reasons:
- NSMenu with SwiftUI views in NSMenuItem.view (CodexBar's approach), not
  a popover — a popover penalises the common quick-status case.
- Histogram plots calls per hour, not tokens: no hourly token series
  exists (usage buckets carry calls/errors/resp_bytes; token_source is
  "bytes"), and real tokenisation only accumulates per session.
- Fixes two feed bugs in scope: CoreProcessManager matches a literal
  'activity' SSE event the core never emits (activity is 30s-polled
  today), and APIClient decodes last_active where the API emits
  last_activity.
- Glance items and their hosting views are cached and re-inserted on
  rebuild, since rebuildMenu() calls removeAllItems() without dismissing
  the menu; extracted into Menu/Glance/ rather than growing the
  1120-line MCPProxyApp.swift.

Menu open stays network-free (spec 048 invariant); the histogram fetch
happens on submenu open only.
Codex reviewed the design against the actual code and found nine issues;
all were verified against the repo before acting on them. Substantive
changes:

- Rows are plain NSMenuItems, not hosted SwiftUI views. Apple documents
  that custom menu-item views get mouse but not keyboard events, so
  SwiftUI rows would not be keyboard-selectable and would need
  hand-rolled accessibility. Only the histogram stays a hosted view, in
  its submenu. This also removes the view-caching scheme, which was
  unnecessary for plain items and would not have prevented churn anyway.
- Activity selection now keeps failed call_tool_* wrapper records. The
  backend intentionally retains them because a failed wrapper has no
  corresponding upstream tool_call record; the previous built-in
  allowlist would have discarded the only trace of a failed call.
  Page size raised 10 -> 25 so noise cannot starve the five rows.
- Header count now derives from /activity/usage (in-memory snapshot),
  not /activity/summary, which sets Limit=0 and scans every record in
  the window and counts all activity types, not just calls. Same fetch
  feeds the histogram, so the submenu needs no fetch of its own.
- SSE fix consumes activity.* event payloads directly instead of calling
  refreshActivity(), which would have fired a REST GET per event.
- Sessions page size raised to 25 before the active filter, since closed
  sessions can crowd out older still-active ones.
- Deep links use ?session=, the query the Activity view actually reads;
  request_id would have required a Web UI change, an explicit non-goal.
- Chart stacks calls-errors plus errors (calls already includes errors)
  and synthesises zero buckets for missing hours.
- Added an honest scope inventory and the protocol extraction the
  zero-network test requires.
Six more findings, all verified against the code first:

- Do not narrow AppState.recentActivity: the native Dashboard renders the
  full activity log from it on purpose (security scans, quarantine, OAuth)
  so a quiet proxy still shows something (DashboardView.swift:572).
  The glance section gets its own filtered feed instead.
- Header now reads 'calls this hour' and selects the bucket matching the
  current UTC hour, showing zero when absent. Buckets are UTC-hour
  aligned and sparse (usage_aggregate.go:229), so 'the most recent
  bucket' could have displayed an hours-old count as current.
- Selection rules were self-contradictory (include all failed internal
  calls vs exclude management built-ins). Precedence is now explicit:
  management names are excluded first, then tool_call, then discovery
  built-ins or non-success internal calls.
- Sessions page raised to the API maximum: storage truncates newest-first
  by START time before the runtime sorts by last activity
  (manager.go:1355), so a long-lived active session falls outside any
  small page. Server-side status filter recorded as a follow-up.
- Deep links go through an app-delegate action, not a URL built inside
  the component: a first-time browser session needs the API key that
  openWebUI() fetches from the core manager (MCPProxyApp.swift:985).
- Usage state fields are optional so 'not loaded' stays distinguishable
  from 'loaded and idle' — a valid usage response can be empty.
No P1s this round; three P2s and two P3s, all verified against the code:

- rebuildMenu() gains a tracking guard. Live SSE rows make the debounced
  objectWillChange -> rebuildMenu() sink fire during active traffic, and
  rebuildMenu() calls removeAllItems() without dismissing the menu — so a
  busy agent could restructure the menu under the user's cursor and
  disturb an open histogram submenu. While tracking, rows update titles
  in place and structural changes are deferred to menuDidClose.
- Glance state is explicitly cleared on disconnect. Optional fields only
  make a cleared state expressible; without an actual reset the menu can
  show the previous core's numbers, since the connection path flips to
  .connected before the first refresh completes.
- Glance page size 25 -> 50. Management built-ins match the server-side
  type filter and are excluded client-side, and storage filters before
  truncating, so a burst of upstream_servers calls could fill a small
  page and starve real tool calls.
- Mockup label corrected to 'calls this hour', matching the load-bearing
  bucket semantics.
- SSE payload field is error_message, not error; reading the wrong key
  would drop failure text from optimistic rows until reconciliation.
No P1s; three P2s and one P3, all verified against the code:

- Specify the SSE payload adapter. The wire format is an envelope with a
  Unix-seconds timestamp, and internal events send internal_tool_name
  plus optional target_server rather than tool_name/server_name
  (event_bus.go:552). Feeding raw payloads into the selection rules
  would have silently dropped every internal row.
- Fix the sessions gap properly instead of papering over it. Storage
  truncates newest-by-start-time before the sort, and the handler parses
  offset without using it, so no page size guarantees active clients are
  found; a 100-record page was still a page. Adds a status filter to
  GET /api/v1/sessions applied during the cursor walk — a small Go change
  now in scope, because the alternative is a section that reports 'no
  clients' while a client is working.
- In-place row updates must rewrite the whole row identity — title,
  image, tooltip, accessibility label, and the representedObject that
  carries the session for the click action. Updating only the title
  would leave a row whose click opens the previous record's session.
- Selection test now pins the 50-record page the production feed uses.
No P1s; three P2s, all verified against the code:

- Add a deduplication rule. A failed upstream call emits BOTH
  activity.tool_call.completed and activity.internal_tool_call.completed
  under one request id and both are persisted (mcp.go:2142,2146), so the
  previous rules would render one failure as two rows. Records sharing a
  request id now collapse to the tool_call one, which names the real
  server:tool; wrappers that failed before dispatch have no paired
  record and still render.
- Provisional SSE ids are request_id + event type. ActivityEntry derives
  identity from id alone (Models.swift:536) and the paired events share
  a request id, so a bare request_id would collide two distinct records
  before the dedup rule could choose between them.
- Give the glance section its own glanceSessions feed. recentSessions is
  shared: ActivityView resolves session ids to client names through it
  and DashboardView falls back to it for Recent Sessions, so pointing
  that feed at status=active would silently narrow both. Same mistake
  this design already avoided for recentActivity — now stated as a rule:
  a shared AppState feed the glance wants differently gets its own
  field, never a narrowed shared one.
…lders)

Produced by a 21-agent workflow: four recon agents mapped the exact Swift
and Go surfaces, eight drafting agents wrote one task each with real code,
and each task was then adversarially verified against the repo by its own
agent (all eight came back corrected, not clean).

Tasks: (1) Go status filter on GET /api/v1/sessions applied during the
cursor walk; (2) Swift API client methods, usage models, last_activity
coding-key fix, glance data-source protocol; (3) AppState glance fields,
refresh wiring, disconnect reset; (4) SSE payload adapter replacing the
dead activity case; (5) GlanceSelection + GlanceFormatting pure logic;
(6) GlanceSection plain NSMenuItems with whole-identity in-place updates;
(7) MCPProxyApp integration, open-menu rebuild guard, session deep link,
TrayMenu.swift deletion; (8) SwiftUI Charts histogram in the submenu.

156 checkbox steps, each with real code or a real command and expected
output. Self-review passed: no placeholders, no Claude attribution in the
commit templates, symbol names consistent across tasks, and every
load-bearing spec detail present (request_id collapse, current-UTC-hour
bucket, calls-includes-errors stacking, representedObject rewrites,
shared feeds never narrowed).
Storage walked the sessions bucket newest-first by START time and truncated
to limit before anyone could filter, so a session that began hours ago but is
still active fell outside every page once enough newer sessions existed. The
macOS tray's client glance would then show "No connected clients" while a
client was actively calling tools, and offset cannot recover it (the handler
parses offset but never passes it down).

Push the filter into the cursor walk so it runs before truncation:

- storage.Manager.GetRecentSessions(limit, status) filters on SessionRecord
  .Status during the walk. When filtering, it walks the whole bucket (capped
  at 100 keys by session retention) so `total` counts matching records.
- runtime.Runtime / server.Server / httpapi.ServerController thread status
  through unchanged.
- The handler validates status against the closed domain (active|closed) and
  returns 400 on anything else rather than silently returning unfiltered
  sessions the caller believes are filtered.

Regenerates the OpenAPI artifacts and documents the parameter.
The status-filter suite never made `limit` binding on the filtered path:
limit=2 had one match and limit=10 had four, so deleting the
`if len(sessions) < limit` guard in GetRecentSessions still passed the whole
suite. The filtered walk could have silently ignored limit.

Add GetRecentSessions(2, "closed") over four closed sessions, asserting the
page truncates to 2 while total still reports 4, and pinning the two returned
IDs so the newest-first order of the filtered walk is covered too. Verified by
removing the guard: only this sub-test fails.
GET /api/v1/activity/usage?window=24h&top=1 feeds the tray glance header
and 24h histogram. UsageBucket parses the RFC 3339 bucket start itself so
the shared fetchWrapped decoder needs no date strategy.

Adds the first APIClient unit tests, via a URLProtocol stub on the
existing init(session:) seam.
GET /api/v1/activity?type=tool_call,internal_tool_call&limit=50 is a
separate feed from recentActivity(), which the native Dashboard uses to
render the full log. The 50-record page is oversized because management
built-ins are filtered client-side.
MCPSession declared last_active, but the API emits last_activity, so the
field was nil for every session — DashboardView was sorting and labelling
Recent Sessions off an always-empty string.

Adds activeSessions(limit:), which asks the server for status=active
rather than filtering a truncated page client-side.
The glance section depends on a three-method protocol instead of the
concrete APIClient actor, so a counting stub can be injected and the
spec-048 invariant (menu open issues zero requests) becomes assertable.

APIClient's conformance is declaration-only — an actor's isolated methods
already satisfy async protocol requirements.
The stub's statusCode knob existed but nothing drove it, leaving the
error path of all three new calls unasserted. Pins that a non-2xx
surfaces as APIClientError.httpError keyed off the status code — the 400
case is what the live core returns for ?status=bogus, and its error body
carries a request_id that must not disturb message extraction.

Verified by mutation: neutralising the 200...299 guard in performRequest
fails all three, the 503 case by degrading to a decodingError.
When the body is enveloped and a model's decoder throws, the bare-decode
fallback re-fails on the top-level shape and that second error was the
one reported — so UsageBucket's "Not an RFC 3339 timestamp" surfaced as
"no value associated with key window", naming the wrong cause.

Both decode errors are now kept and the informative one is chosen by
probing the body for the envelope. Unwrapped endpoints keep reporting the
bare error exactly as before; success paths are untouched.
Adds glanceActivity/glanceSessions/usageTimeline/callsThisHour alongside the
shared recentActivity/recentSessions feeds, which stay broad because the native
Dashboard renders the full activity log and ActivityView resolves session ids
through recentSessions.

callsThisHour matches the bucket whose start equals the current UTC hour and
reports 0 when that hour is absent; usage buckets are sparse, so taking the
newest bucket would present an hours-old count as live.
The connect path transitions to .connected before the first refresh completes,
so hiding the glance block is not enough — without a reset the menu would
briefly render the previous core's activity, clients and call count as live.

The reset hangs off coreState's didSet rather than transition(to:) because
CoreProcessManager.awaitExternalCore and MCPProxyApp.stopCore assign coreState
directly and would otherwise bypass it.

updateGlanceActivity fingerprints id+status+hasSensitiveData rather than id
alone: ActivityEntry's Equatable is id-only, so an id guard would discard the
reconciling poll's late status and sensitive-data corrections.
…he refresh loop

Three additional fetches on the existing 30s CoreProcessManager cycle, each
failing soft like the surrounding refreshers. No new timer and no menu-open
network call: the tray menu still renders entirely from AppState (spec 048).
clearGlanceState() alone left a window: a fetch already past its
`guard let apiClient` when the core leaves .connected resolves after the reset
and writes the dead core's data back over the cleared fields. shutdown()
transitions to .shuttingDown before it cancels refreshTask, and cancelling
would not close the window anyway — a suspended fetch still resumes and still
runs the update that follows it.

These are the one field set whose stated purpose is never showing a previous
core's numbers as live, so the three updaters now drop writes that arrive while
disconnected.
Status symbol, server:tool row label, middle truncation and a compact
locale-free relative age, salvaged from the dead Menu/TrayMenu.swift.
Eleven unit tests; no AppKit, no networking.
Management built-ins (upstream_servers, quarantine_security) are excluded
whatever their status; every tool_call qualifies; internal_tool_call
qualifies for the three discovery/execution built-ins or on any failure,
so a pre-dispatch call_tool_* failure still gets a row.
A failed upstream call persists both a tool_call and a call_tool_* wrapper
under one request id; rule 4 keeps the tool_call one because it names the
real server:tool. Adds activityRows() (rules 1-4 then first five) and
activeClients() (status==active, first five). Nine more unit tests.
A failed call_tool_* wrapper is persisted with both a tool_name and a
server_name, so dropping the type check would render it as
server:call_tool_read instead of the bare built-in name. Adds that case
plus the at-limit and zero-limit truncation boundaries.

Also documents what would invalidate rule 4: nested code_execution calls
all share one request id, and are invisible here only because the nested
caller writes to the legacy history without emitting an activity event.
Dumbris added 3 commits July 29, 2026 20:22
The legend was the one user-visible thing on the chart that no test
asserted the presence of: flipping .chartLegend(.visible) to .hidden left
the suite green while silently reclaiming the 20pt the frame grew to fit
it — and testRealChartItemIsSizedAndLabelled would then have failed on
the size, inviting whoever reclaimed the space to "fix" that test.

Measured rather than asserted from the outside: on an all-zero axis the
chart draws no error segments, so the only red the view can contain is
the legend's "Errors" swatch. Probed both ways before writing the
assertion — visible: 56 red samples, all in the bottom third; hidden:
zero, everywhere. The sampler walks the rep's pixel extent so "the bottom
24 points" means the same thing at 1x and 2x, which the existing
pixels(of:) helper does not do.

The decode test now also asserts `type` and `status`. They are the two
fields the entire feature branches on — type drives rules 1-3 and
rowLabel's server:tool decision, status drives the symbol, tint,
VoiceOver phrasing and error clause — and nothing else sees the wire
format for them.
…p one loop

Five small things the whole-branch review called out:

  * CoreProcessManager's periodic-refresh comment read "SSE doesn't emit
    'activity' events", thirty lines below a switch that now consumes two
    activity SSE events. Literally still true, but a reader would take it
    as "SSE carries no activity" and delete the activityVersion bump on
    its strength.
  * AppState's prepend comment described updateGlanceSessions as carrying
    an id-list guard; that became a whole-value Equatable guard for a
    load-bearing reason (an id-only comparison froze the live call count).
  * EnvelopeProbe's residual: it answers "does this body carry a success
    field", so a bare payload with its own success field probes as
    enveloped. Reachable, harmless, and previously documented as the
    unreachable shape.
  * sendSensitiveDataAlert has no production callers and never has had
    any; the note names sensitive_data.detected as the intended trigger
    so the next reader does not assume it is live.
  * updateInPlace indexed `entries` by `activityRows.indices` while the
    adjacent loop used zip. Now both use zip: one of the two could read
    out of bounds if the count guard above were weakened, and it was the
    only one that could.
…orrectly

Two corrections to the fix wave.

mergeGlanceActivity discarded every retained row when the polled page was
empty — finding 2's own bug shape surviving in the branch that handles
"no newest record". An empty page contradicts nothing: it says the server
had recorded no matching calls when it answered, which is silence about a
call it had not written yet, so an SSE row prepended while that poll was
in flight was erased anyway. The doc comment reasoned about unparsable
timestamps and never noticed the empty page takes the same branch; both
cases are now enumerated, and they mean opposite things — a page WITH
records is the server's account of the feed and still wins.

The previous commit's corrected comment also invented the event names.
`activity.upstream_completed` / `activity.internal_completed` exist
nowhere in the repo; the wire names, grepped from
internal/runtime/events.go:29,41 and matched by GlanceEvent, are
`activity.tool_call.completed` and `activity.internal_tool_call.completed`.
@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Jul 29, 2026

Copy link
Copy Markdown

Deploying mcpproxy-docs with  Cloudflare Pages  Cloudflare Pages

Latest commit: 99c8130
Status: ✅  Deploy successful!
Preview URL: https://1d90afbb.mcpproxy-docs.pages.dev
Branch Preview URL: https://feat-tray-glance.mcpproxy-docs.pages.dev

View logs

Dumbris added 2 commits July 29, 2026 21:45
The empty-page guard I added to keep a racing SSE row on screen returned
the whole existing list, so it also preserved canonical rows the server
had legitimately dropped — and on an idle core nothing ever reached back
past them, so they stayed forever while the successful polls kept
clearing the staleness marker that would have hinted at trouble. The
same hole existed before that guard, for any deleted record whose
timestamp was newer than the remaining page's maximum.

"Absent from the page" was never the right predicate: retention is for
rows the server has not written yet, which is exactly the rows this tray
prepended from SSE and no poll has carried back. AppState now tracks
those keys, and the merge retains nothing else — a row the page carries
becomes the server's record and leaves the set, a row that is dropped
leaves it too.

My own test passed the broken behaviour because it started from an
optimistic row and polled empty once. The three new cases are the ones
that could not: a canonical row deleted server-side, a deleted row newer
than everything the page still carries, and a live row confirmed by a
poll and deleted afterwards.
The REST refreshes check the generation; the SSE path checked only
`.connected`, and the "it is one MainActor hop" argument for leaving it
that way was wrong — executors guarantee no ordering between that hop and
the reconnect work, so an event read from a dead core's stream can resume
after `.connected` has been restored and prepend that core's call to the
new core's feed. The existing SSE test covers the disconnected window,
where the plain guard already worked.

prependGlanceActivity now takes the generation and checks
isCurrentConnection, the same shape as the three fetches. The caller
captures it where the STREAM is opened rather than at event arrival: a
stream belongs to exactly one connection, whereas a generation read at
arrival is the post-reconnect one and would pass the guard.

Pinned in both directions — an event from the previous connection is
dropped, one from the live connection still publishes.
@codecov-commenter

codecov-commenter commented Jul 29, 2026

Copy link
Copy Markdown

⚠️ Please install the 'codecov app svg image' to ensure uploads and comments are reliably processed by Codecov.

Codecov Report

❌ Patch coverage is 79.41176% with 7 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
internal/storage/manager.go 84.21% 2 Missing and 1 partial ⚠️
internal/runtime/runtime.go 0.00% 2 Missing ⚠️
internal/server/server.go 0.00% 2 Missing ⚠️

📢 Thoughts on this report? Let us know!

Dumbris added 2 commits July 29, 2026 21:48
…he glance ask

Measured against the real activity log on this machine (2585 records,
newest 100 matching the glance's type filter): 848 KB of JSON whole,
30 KB in summary form. Per record, p50 is 1.3 KB but p90 is 22 KB and
p99 is 166 KB — `response` is truncated at 64 KB, `arguments` and
`metadata` are not truncated at all. The tray repeats this request every
30 seconds and renders none of those three fields.

So the earlier rationale for the deeper page was materially incomplete:
it compared bucket scans and struct copies, which is the server's cost,
and ignored the wire. 50 -> 100 was not "50 more struct copies", it was
another ~420 KB per poll of serialisation, transfer and decoding.

GET /api/v1/activity now takes exclude_payloads=true, which drops
arguments, response and metadata from the response. It is a projection,
not a filter — applied at serialisation, so it changes nothing about
which records match — and has_sensitive_data is still derived from
metadata before metadata is dropped, since the row icon keys off it.
Default is unchanged, pinned by a test, so no other client is affected;
recentActivity() deliberately does not set it, because the Dashboard
renders exactly those fields.

This makes the 100-record page ~28x cheaper than a 100-record page was,
and ~14x cheaper than the 50-record page this branch started from.
Rule 4 collapses every record sharing a request_id into one row, so five
records that pass rules 1-3 can be three rows — a failed call contributes
a wrapper and an upstream record under one id. The guarantee restated
with the deeper page said "five qualify", which is true of records and
not of rows.

The depth tests all use unique or absent request ids, so nothing would
have caught the discrepancy; the new test is a wrapper/upstream page
where six qualifying records make three rows.
@github-actions

github-actions Bot commented Jul 29, 2026

Copy link
Copy Markdown

📦 Build Artifacts

Workflow Run: View Run
Branch: feat/tray-glance

Available Artifacts

  • archive-darwin-amd64 (28 MB)
  • archive-darwin-arm64 (25 MB)
  • archive-linux-amd64 (16 MB)
  • archive-linux-arm64 (15 MB)
  • archive-windows-amd64 (28 MB)
  • archive-windows-arm64 (25 MB)
  • frontend-dist-pr (0 MB)
  • installer-dmg-darwin-amd64 (22 MB)
  • installer-dmg-darwin-arm64 (20 MB)

How to Download

Option 1: GitHub Web UI (easiest)

  1. Go to the workflow run page linked above
  2. Scroll to the bottom "Artifacts" section
  3. Click on the artifact you want to download

Option 2: GitHub CLI

gh run download 30521454890 --repo smart-mcp-proxy/mcpproxy-go

Note: Artifacts expire in 14 days.

Dumbris added 7 commits July 29, 2026 21:58
… run in CI

Both encoding tests failed on the GitHub macOS runner. Two causes, and
neither was flakiness.

NSHostingView.cacheDisplay needs a display context the runner does not
have: it painted 13 sample points there against hundreds locally. So the
tests were developer-machine-only coverage that happened to look green
because they never ran — the `swift-test` job is gated on native/ paths
and this is the first branch to touch them. SwiftUI's ImageRenderer
draws offscreen through Core Graphics with no window server involved.
Measured both ways before switching: identical red and chroma counts, so
it is the same raster, not an approximation.

The runner's 13 chromatic pixels were real and meaningful — the legend's
red "Errors" swatch, which every render carries, success-only included.
Asserting no chroma over the whole image was therefore asserting
something false; it passed locally only because the old sampler strode
`rep.size`, which is the POINT size at 2x and the PIXEL size at 1x, so it
covered a quarter of the image on this machine and all of it on the
runner. That test is now scoped to the plot area, which is what it always
meant, and `scale` is pinned so the pixel grid belongs to the test rather
than to the display.

The alpha cut-off drops from 0.5 to 0.1: Color.secondary resolves to
~0.5 alpha, so the old threshold excluded the entire success series
against a transparent backing and read as a blank chart.

No skip was reintroduced. Each assertion is positive or guarded by a
drawn-pixel floor, so a blank raster fails loudly instead of passing.
The previous commit said the hosting-view path "needs a display context
the runner does not provide". The CI log does not support that: the
success-only fixture drew 181 samples there, 13 of them chromatic, and
13 is exactly the legend's red swatch at 1x (54 at 2x here). So the
runner renders solid shapes fine — it is axis TEXT that did not come
through, at 1x backing scale.

The two failures were caused by the sampler, not by a missing raster:
`rep.size` is points at 2x and pixels at 1x, so the old stride covered a
quarter of the image here and all of it on the runner (hence a legend
swatch that was invisible locally and fatal there); and the `> 0.5` alpha
cut-off dropped the entire Color.secondary success series, which is what
made 181 samples look like a blank chart.

ImageRenderer is still the right call — explicit scale, offscreen, and
nothing below depends on text rendering — but the reason is host
variance, not an absent display context.
prependGlanceActivity added a key for every SSE event and trimmed the
feed to the cap without trimming the set, so keys outlived the rows they
described. The poll is the only other thing that prunes the set — which
means the set grew without bound exactly while polls were failing and
SSE stayed healthy, i.e. during the scenario the staleness marker exists
to report, for as long as the core stayed up.

The cap now trims both together: after the feed is truncated the set is
intersected with the keys still in it.
… the guard

The round-2 tests hand prependGlanceActivity a generation the test picked,
so they verify the final guard and nothing before it — they pass
unchanged against the implementation that reads the generation when the
event ARRIVES, which after a reconnect is the new generation and sails
through. That is the regression the fix was written for, and no test
could see it.

The stream loop moves into SSEStreamSession, whose whole purpose is to
own the rule: capture once, before the stream is opened, and hand that
value to every event the stream delivers. startSSEStream is now a single
call to it.

Three stream-level tests drive it: a reconnect between the stream opening
and the event arriving (dropped), an uninterrupted stream (published),
and a reconnect during the connect itself (dropped, because the capture
precedes open). Verified by reverting to the arrival-time read — the two
negative tests fail on their assertions, while every test in
GlanceReconnectGenerationTests stays green, which is the point.

The handshake is on `open`, not on `captureGeneration`: `open` is called
at the same point by both implementations, so the test's sequencing does
not depend on the thing under test. Hanging it off the capture made the
wrong implementation fail by 5s timeout instead of by assertion.
…aster

Two holes in the same file, both found by mutation rather than by reading.

`drawn` counts axes, grid lines and tick labels, so no assertion here
proved the success bars were painted at all: setting the success fill to
Color.clear left all three encoding tests green. The no-chroma test was
the worst of them — with no bars it passes more easily, since what it
measures is the absence of colour. The new test compares like for like,
the same single-hour axis with ten successes and with none, so the
difference is the bar and nothing else.

And `measure` still turned "no raster at all" into an XCTSkip. That is
the third narrowing of one hole: a failed threshold used to skip, then a
blank raster did, and each time the surviving shape was "the environment
where these tests assert nothing reports success". It is now XCTUnwrap.
There is no environment where a macOS build can legitimately fail to
produce a CGImage here and the suite should still pass — the tray draws
this chart with the same machinery, so a host that cannot rasterise it
cannot run the feature either.
The disclosed residual turned out to be reachable: with SSEStreamSession
left correct, restoring the arrival-time generation read inside
startSSEStream passed all 13 tests across both generation test files.
Keeping the body to a single call makes the omission visible on sight,
and visible on sight is not a test.

Three small things make the wiring drivable:

  * SSEStreaming — the two members (connect/disconnect) the manager
    actually consumes, so a stub can stand in for SSEClient.
  * installSSEClient + an internal startSSEStream, the seam the test
    needs. Production wiring is unchanged; connectToCore still builds
    the real client.
  * NotificationService's UNUserNotificationCenter is now resolved
    lazily. An eager `let` traps with "bundleProxyForCurrentProcess is
    nil" in an unbundled process, i.e. in any XCTest runner, which is
    why nothing holding one had ever been constructed in a test — the
    rate-limit tests mirror the logic in a private struct to get around
    exactly this. Safe because the type is an actor, so the lazy
    initialisation is isolated.

The negative assertion is deterministic rather than timed: a `status`
event follows the glance event, its handling is not generation-guarded,
and events are handled in stream order — so once the status event's
effect is visible, the glance event has already been processed.
Verified with the mutation from the report: only the new wiring test
fails, on its assertion.
Dumbris added 2 commits July 30, 2026 10:00
The debounced objectWillChange -> rebuildMenu() sink scheduled on
RunLoop.main. Combine's RunLoop scheduler installs its timers in the
run loop's .default mode, but while an NSMenu is on screen the main run
loop runs in NSEventTrackingRunLoopMode — so the sink was not serviced
until the menu closed.

That made the whole open-menu update path unreachable in production.
GlanceSection.updateInPlace and MenuRebuildGuard's updateInPlace /
deferUntilClose branches only ever run if something calls rebuildMenu()
while the menu is up, and this sink is the only caller that does. The
glance was therefore a snapshot frozen at menu-open time: live QA held
the menu open for 45s and watched a 45-second-old call stay captioned
"3s", the header counts stay put while the REST API moved on, two
captures 2s apart come out pixel-identical, and a call made while the
menu was open never appear at all.

Schedule on the main dispatch queue instead, which is drained in every
run-loop mode. Same reason the timers alongside it publish in .common.
The 500ms window and the icon updates are untouched, and the menu is
still never restructured under the cursor: with the guard armed,
rebuildMenu() returns before touching menu structure on both branches.

The scheduler is named rather than written inline so a test can bind to
the very value production uses.
Every existing glance test calls GlanceSection.updateInPlace directly,
so the whole suite stayed green while the one production caller that
reaches it during menu tracking was never serviced. These cover the
delivery layer underneath: they pump the main run loop in
NSEventTrackingRunLoopMode ONLY, the mode it runs in while an NSMenu is
up, and never in .default.

The end-to-end case arms a MenuRebuildGuard, mutates AppState, and
asserts the refresh reaches the section on the updateInPlace branch and
rewrites the row already on screen. It binds to
AppController.menuRefreshScheduler, so reverting that to RunLoop.main
fails it — verified by doing exactly that: the header stays at
"12 calls this hour", which is the QA symptom.

A control case pins that RunLoop.main delivers nothing under the same
pump but is released by one turn of .default, proving the pump is
selective rather than draining every mode, and two more pin that the
500ms window still coalesces a burst into one main-thread repaint when
nothing is tracking.

The tests initialise NSApplication: .eventTracking only reaches the
main dispatch queue once AppKit has added it to the run loop's common
modes, which holds for the real tray but not for a bare xctest bundle.
Without it every case here would pass by measuring a process where no
scheduler at all is serviced during tracking.

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Arming auto-merge: live QA on a real tray. Glance counts matched the REST API exactly on every comparison, and SSE delivery was verified (a call appeared with no intervening HTTP request). The one medium defect found — the menu froze while open because the debounced sink was scheduled on RunLoop.main, whose .default mode is not serviced during NSMenu tracking, making the updateInPlace/deferUntilClose machinery unreachable — was fixed and re-verified live: rows and header now update under an open menu across a poll boundary, a call made while open appears without reopening, ages tick, and the menu is not disrupted (highlight and open submenu survive, identical highlight-pixel counts across an 8-frame burst). 473 Swift tests, 0 failures. Cosmetics tracked in #934.

@github-actions
github-actions Bot enabled auto-merge (squash) July 30, 2026 07:24
Conflict in NotificationService.swift: both sides made the center lazy to
unblock unit construction. main's version (from #927) is a superset — it
also adds the deliveryEnabled flag and its guards — so it is taken whole.
The glance branch's other change to the file, the doc comment on
sendSensitiveDataAlert, merged cleanly and is preserved.

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Arming auto-merge: re-verified live under the plain documented setup after the main merge. Menu held open ~50s now updates in place across a poll boundary (all four capture digests differ; previously pixel-identical), a call made while the menu is open appears without reopening, ages tick, and the menu is not disrupted (identical highlight-pixel signature across frames, submenu stays open). Merged with main — the NotificationService conflict took #927's superset, preserving both changes. 502 Swift tests, 0 failures. Cosmetics tracked in #934.

@github-actions
github-actions Bot merged commit 282d251 into main Jul 30, 2026
38 checks passed
github-actions Bot pushed a commit that referenced this pull request Jul 30, 2026
… gained a status filter (#939)

Semantic merge conflict, not a logic bug: #928 added sessions_retention_test.go
calling GetRecentSessions(limit) while #930 added a status parameter for the
tray glance's client filtering. Both PRs were green independently and broke
main only once combined, because branch protection does not require a PR to be
up to date with main before merging.

Pass "" — the documented no-filter value — at the four call sites. The
retention tests want every surviving record, so unfiltered is the correct
argument, not merely the compiling one.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants