Skip to content

The watch-item worklist names its servers, off the whole retained history (#3482) - #3483

Merged
erikdarlingdata merged 1 commit into
devfrom
fix/3482-watch-item-names
Sep 16, 2026
Merged

erikdarlingdata merged 1 commit into
devfrom
fix/3482-watch-item-names

Conversation

@erikdarlingdata

Copy link
Copy Markdown
Owner

Closes #3482 — ceremonially; the watcher closes it at merge.

The bug

The watch-item worklist — the one table an operator reads to see what is still standing — rendered server 412767232 where every sibling table on the page says SQL2025. BuildWatchItemsNode emitted server_id and no name; the detail node's verdicts and ledger both join names, so the worklist was the odd one out on both surfaces (/api/sweeps/watch-items and get_sweep_reports' watch_items).

One correction to the issue's otherwise-exact diagnosis, so the reviewer isn't misled by the line cite: sweeps.js:227 is the would-have-paged ledger, whose feed already sends server. The worklist table (~line 367) is the one that never read a name — it branched fleet-scope or raw id, with no name preference to fall back FROM. Both ends needed the fix: the feed now sends the field, and the cell now prefers it.

The name source: the whole retained history, not a span and not the latest sweep

Watch items are current-STATE rows, and a carried item can outlive its server's presence in the fleet — departure is a miss, misses are evaluations, and the hysteresis bar is the point. So the latest sweep's verdicts (the detail node's precedent) provably cannot name a departed server, and any span a caller picked would be a guess. The new GetSweepServerNamesAsync reads the whole retained verdict history, which is bounded by the same horizon that prunes the worklist itself: a name this read cannot find is a name the store no longer holds anywhere, and that item degrades to the bare id honestly.

The read carries #3476's determinism contract verbatim — GROUP BY with each pair's newest sighting ordered LAST, sweep_id tiebreak, the paired last-write-wins reader comment — so a renamed server shows its newest name here for the same reason it does in the rollup. Fault posture is presentation-grade: log-and-degrade to an empty map, so a failed lookup costs the names (the client falls back to exactly the pre-fix rendering) and never the worklist. Both surfaces share one gate-and-read helper, and the read is skipped outright when the worklist is empty or all fleet-scope — the common case on a healthy fleet under the page's poll, which should not pay a history scan for nothing.

The fleet-scope sentinel, guarded rather than assumed

The sentinel (server_id 0) has no server name, and a name for "the fleet" is a fabrication however it arrives — so the builder refuses to emit one even if a hand-fed names map carries id 0, rather than trusting that no caller ever will. Unresolvable ids omit the field entirely (the degrade shape), and the client renders the sentinel as the fleet label it already had.

Tests

The watch-item shape pin grows three arms (named, omitted-degrade, fleet-scope-omission-despite-map); a JS source pin holds the render preference and that "server 0" is unreachable for the sentinel; the MCP shared-builder pin extends to the names read so the two surfaces cannot drift; the rung census gains the new SQL const (no-now() discipline) and its determinism shape; and the live-Postgres suite proves the departed-server coverage and the two-names-one-id newest-wins arm against a real store. Tests cannot run on macOS; verified by full-solution build and static reasoning — CI is the first execution.

…tory (#3482)

The Fleet Sweeps worklist (and get_sweep_reports' watch_items) rendered raw
server_ids while every other table on the page showed display names: the shared
builder never emitted the server field sweeps.js has preferred since lane 3, so
the client's "server N" fallback was the only rendering. BuildWatchItemsNode now
takes a names map and emits ["server"] exactly when it means something - omitted
on the fleet-scope sentinel (a name for the fleet would be a fabrication; the
fleet_scope flag is what a client renders from) and omitted when the id resolves
to no retained verdict row, where the bare-id fallback is the honest degrade.

The names come from a new whole-history store read, deliberately unwindowed: the
worklist is current-STATE rows, and a carried item can outlive its server's
presence in the fleet, so its name may exist only in sweeps older than any span
a caller could honestly pick - and the population is bounded by the same base
horizon that prunes the worklist itself. The statement carries the span
sibling's #3476 dedup-and-determinism contract (newest sighting ordered last,
last-write-wins reader), and the read is log-and-degrade: a failed lookup costs
the names, never the worklist. Both surfaces share one gate-and-read helper (the
ValidateWatchState sharing pattern), skipped when the worklist names nothing -
the common case on a healthy fleet under the page's 60-second refresh.
@erikdarlingdata
erikdarlingdata enabled auto-merge (squash) September 16, 2026 14:14
@claude

claude Bot commented Sep 16, 2026

Copy link
Copy Markdown

Review

Went through the full diff (FleetSweepStore.cs, FleetSweepPresentation.cs, DarlingFleetSweepEndpoints.cs, DarlingMcpFleetSweepTools.cs, sweeps.js, plus the test files) against CONTRIBUTING.md's Darling/PostgreSQL conventions and the two-store parity rules.

Correctness — looks right. Both call sites of BuildWatchItemsNode (the web feed and get_sweep_reports) were updated together, and both route through the same ReadWatchItemNamesAsync gate-and-read, so the web page and the MCP tool can't drift on when/whether names are joined. The fleet-scope sentinel (server_id == 0) is guarded explicitly in the builder rather than relying on the names map never containing it — a sound defense-in-depth choice even though today's GetSweepServerNamesSql can never actually produce that key (it only ever selects from fleet_sweep_server_verdicts, which never carries the sentinel). The last-write-wins reader is correctly paired with ORDER BY v.server_id, MAX(r.swept_at), MAX(r.sweep_id) so a renamed server resolves to its newest spelling, consistent with the existing GetSweepServerNamesBySpanSql sibling (#3476). Log-and-degrade fault posture is implemented correctly (catch (Exception ex) when (ex is not OperationCanceledException) → empty dict), matching this file's established discipline for presentation reads.

Security — no issues. The new SQL (GetSweepServerNamesSql) takes no parameters (no injection surface), is schema-qualified (collect.fleet_sweep_server_verdicts, collect.fleet_sweep_runs) per the PostgreSQL style rule, and the frontend renders w.server through cellText/el(), which is textContent-only (util.js's R4 rule) — no innerHTML path, so a server display name can't be used for stored XSS.

Lite/Darling parity — no drift. Fleet Sweeps (fleet_sweep_* tables, FleetSweepStore, FleetSweepPresentation) is a Darling-only feature; there's no FleetSweep-anything under Lite/, so this isn't a "shared seam" per the Two-Store Parity section and doesn't need a Lite-side change.

Style — matches conventions: AS-free but consistently-aliased Postgres SQL (this codebase's Postgres statements don't use table aliases like the T-SQL style guide requires for SQL Server, and this PR's new statement matches its own sibling exactly), XML doc comments on public members, CommandTimeoutSeconds used consistently, timestamps untouched by this change (no new DateTime binding here) so the naive-UTC / DateTimeKind rule doesn't come into play.

Minor observation, not a blocker: GetSweepServerNamesSql is deliberately unwindowed (whole retained history vs. the by-span sibling's date-bounded query), and runs on every ~60s poll of /api/sweeps/watch-items whenever the worklist has any non-fleet-scope item — which in practice on an actively-monitored fleet may be most of the time, not just the "unhealthy fleet" edge case. The PR's own reasoning (bounded by the same retention horizon that prunes the worklist) holds given the default hourly cadence + 30-day FleetSweepRetentionDays, so row counts stay small in practice. Flagging only as something to watch if the sweep interval floor (15 min) and larger fleets become common, not something to change now.

Test coverage is thorough (shape pins for named/omitted-degrade/fleet-scope-omission, a live-Postgres test proving departed-server naming and rename-determinism, and an MCP shared-builder pin) — nothing further to add there.

🤖 Generated with Claude Code

@erikdarlingdata
erikdarlingdata merged commit cd5545b into dev Sep 16, 2026
8 checks passed
@erikdarlingdata
erikdarlingdata deleted the fix/3482-watch-item-names branch September 16, 2026 14:26
@erikdarlingdata

Copy link
Copy Markdown
Owner Author

On the unwindowed-read observation: agreed with your own arithmetic — the GROUP BY runs over the same rows the retention horizon already bounds, so at the shipped hourly cadence and 30-day horizon it stays small even on a fleet that always has something on watch. The watch condition is real though, and it has a natural tripwire already shipped: the store's own query activity is monitored by the product, so a 15-minute-floor fleet where this read grows teeth shows up in its own top-queries surface before an operator feels it. If that day comes, the fix shape is a windowed read with a departed-server fallback arm — recorded here so the next editor inherits the design instead of re-deriving it.

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.

1 participant