feat(solver-frontend): read-only monitor site for the COW solver, as a Compose service (stacked on #58) - #59
Merged
Conversation
A new private workspace package, `@zswap-da/solver-frontend`: one Bun process, zero runtime dependencies, no build step, no persistence. It aggregates three sources into one snapshot for the browser and serves a vanilla page that answers "is the solver quoting, and if not, why" from one screen. Why a separate service rather than the solver serving its own page: the single most important moment for this page is the one where the solver is down, and a page served BY the solver is blank exactly then. This process outlives the solver, keeps the last snapshot it saw, and says "SOLVER UNREACHABLE, last seen 14:05:10" while the kernel and relay panels beside it stay live. What it reads: - the solver's `/status/stream` (SSE, preferred) and `/status/snapshot` (fallback), bearer-authenticated; - the kernel's `/v1/health/sync`, `/v1/offers`, `/v1/known-tokens`, `/v1/pairs`, and `/v1/prices` where the node has it — a 404 degrades silently to names only and is re-probed every five minutes; - the relay's public `GET /tokens`, its whole public surface. Four properties the code enforces: 1. The status bearer never leaves the process. It is attached to solver requests and to nothing else — absent from `/api/*`, from the page, from every log line, and from the startup banner, which prints its length. A test greps eight response bodies, their headers and an SSE frame for it, including the failure path where the solver never answered. 2. No proxying. Every upstream URL is a literal; the only caller-shaped input is the price query, built from colours the snapshot already carries and bounded at the route's own 50-entry cap. Static files come from a compiled five-entry manifest, so traversal never reaches a filesystem call. 3. Bounded in-memory state only. The transition history is capped by SOLVER_FRONTEND_HISTORY_LIMIT; nothing is written to disk. 4. An ended solver stream is NORMAL. The solver closes each stream after five minutes so its client cap can self-heal on a runtime that never reports disconnects; a stream that delivered frames and then ended is reconnected at once, records no outage and raises no alarm. Only a failed or empty stream backs off and falls back to polling. Reachability is three states, and the difference is the point: `never-reached` (the listener has not answered once — usually SOLVER_STATUS_PORT unset or a bearer mismatch, and the page says so), `reachable`, and `unreachable` (it was answering and stopped). The last good snapshot survives a solver restart. The page is the approved mockup, with two deliberate changes: no web fonts (nothing loads from another origin, and no build step is allowed to inline them) and no mock-only state switcher — every state comes from data. The two `withheld` values render differently: `cache-not-current` is the fail-closed withdrawal, `withdrawn` is a deliberate one. Amounts stay integer base units; a coin value is added beside them only where the kernel registry gives the colour decimals, and an unknown colour is short hex, never hidden. Every judgement lives in `public/derive.js` as pure functions of a MonitorSnapshot, so the whole state table is unit-tested without a DOM; `public/app.js` only writes what they return, always through textContent, because the kernel's token registry is documented as unverified. Help texts live in `public/help.js` keyed by block id, and a test asserts the ids in the markup and the keys of that map are exactly equal. 91 tests: env (aggregated fail-fast), server (three real loopback sources, not a fetch double — the behaviours under test are transport behaviours), and derivations.
CI runs `packages/solver-frontend` in the unit-suite list, and the strict solver typecheck now covers it (00007 FR-017). The gate LISTS the package rather than discovering it, and compiles it as its own program with its own options. Both halves of that are deliberate: - the consumer scan matches `@zswap-da/solver` EXACTLY, and the site imports only `@zswap-da/solver-core/status-contract`, so discovery cannot see it; - its pure browser derivations are `public/*.js` (a build step is not allowed) and its tests import them, which needs `allowJs` — forcing that onto the solver's program would change what the solver gate resolves. The package's tsconfig pulls in `start.solver-frontend.ts` explicitly for the same reason `start.solver.ts` is discovered: the deployment entrypoint whose option drift this gate exists to catch must not be the one file outside it. README gains a "Monitoring" subsection under "Running the COW solver" with both commands, the mandatory-bearer rule, the warning never to publish the status port to a public interface, and a pointer to the package README.
…ompose stack
The monitor site (packages/solver-frontend, PR-B) as its own Compose service,
and the solver's read-only status listener switched on for it.
- `solver` gains SOLVER_STATUS_HOST=0.0.0.0, SOLVER_STATUS_PORT=9100 and
SOLVER_STATUS_AUTH_TOKEN. The port is deliberately NOT published: the
snapshot is the solver's whole internal state, and the site reads it across
the Compose network. A commented HOST_SOLVER_STATUS_PORT opt-in exists for a
loopback debugging session only.
- `solver-frontend`: kernel image, entrypoint-solver-frontend.sh, depends on
`kernel` only (never on `solver` — the page must say SOLVER UNREACHABLE, not
die with it), restart unless-stopped, `bun -e` healthcheck, published on
${BIND_ADDR}:${HOST_SOLVER_FRONTEND_PORT:-18080}. The bearer it sends comes
from the SAME .env variable the solver enforces.
- bootstrap.sh generates SOLVER_STATUS_AUTH_TOKEN exactly like SOLVER_AUTH_TOKEN;
.env.example carries the new port, token and poll/history knobs.
- gates.sh: G1b (the 00007 services are in the rendered model), G3g (a short
status token is a listed launch problem), G3h/G3i (the site's own fail-fast,
one and three problems), G8 (the site's static assets are in the image).
- deploy/README.md "Observing the solver"; root README points at it.
Two pre-existing deploy defects surfaced while running the gates and are fixed
here because they block the stack:
- The kernel image was unbuildable from `main`: packages/price-feed (PRs
#54-#56) is in the workspace and the lockfile but its package.json was never
copied into the install stage, so `bun install --frozen-lockfile` refused the
lockfile. One COPY line.
- run_negative_gate grepped its own "### expects:" header, so every literal
pattern matched itself and those gates passed on exit status alone since D1.
The command output is now captured separately and only that is grepped;
with that in place G3b's pattern was wrong (Compose renders an unset
variable as "", which the journal parser words differently) and is widened.
Static gates: 22/22 (compose config, six image builds, ten negative controls,
entrypoints parse, pglite resolver, Compact artifacts, site assets).
A non-fatal one-shot, `register-minted-tokens`, that runs after `kernel` is healthy, reads minted-tokens.json from the shared deploy volume and POSTs /v1/known-tokens for the three faucet colours as TESTTOKENA / TESTTOKENB / TESTTOKENU — the spellings preprod already uses. Why it is a deployment step: mint-test-tokens.ts tries to register the names itself and cannot succeed on this stack — it posts to /api/known-tokens, a path the node never served, and to its own loopback while the kernel does not exist yet (the deploy one-shot runs before it). Fixing the script is a packages/ change (issues/00008 in the organizer); only Compose knows when the kernel is healthy, so the ordering half lives here. Every outcome is logged with its HTTP status: 201 registered, 409 already there (idempotent on re-run), 404 registry disabled. REGISTER_MINTED_TOKENS_ENABLED=false skips it. Without it a fresh stack names only the seeded NIGHT/USDC/USDM rows and the book, the price map and the monitor site show short hex for the test tokens.
On a live stack the relay client emits one `push` diagnostic per second, so the 200-entry event ring held about three minutes of identical "pushed 1 pair(s)" rows, evicted every meaningful event (disconnected, cache-not-current, job-refused) within 200 s, and the monitor's Events panel ran to eleven thousand pixels of the same line (00007 P-D, first capture on the Compose stack). The collector now folds a CONSECUTIVE repeat — same kind, severity and message — into the previous entry: `count` says how many times, `lastAt` when it last happened, `at` stays the first occurrence, and the detail is refreshed to the latest push so the row still reports what the newest push carried. Both fields are optional additions to `StatusRelayEvent`; the contract stays at version 1 and `eventsObserved` still counts every event. A repeat after a different kind is a new row, so ordering is preserved. The page renders a folded row once, at its latest time, as "… — ×N since HH:MM:SS". Tests: 500 pushes + a disconnect + a push produce three rows with the right counts, detail and sequence numbers; the page's derivation is pinned on a folded fixture. Solver/solver-core/validator/frontend suites 772/0, typecheck:solver 0 diagnostics over 113 roots.
…tates
The page's "reconciled · N open" stage treated COMPLETED/REFUSED as the
terminal journal states — names the operation journal never uses — so on a
live stack every SETTLED row counted as open ("11 open", amber) after three
successful settlements (00007 P-D, first capture after the E2E driver).
Terminal is now SETTLED / REVERTED / FAILED, taken from operation-journal.ts;
QUARANTINED stays open because it needs an operator.
Also: "not started (dry-run)" was the only wording the relay, ladder and
journal stages had for a not-started section, and the console's transition
text said the same, so a LIVE solver's first seconds read as dry-run. The
wording now follows the solver's reported mode.
Fixtures and tests use the journal's real lifecycle names; two tests added
(QUARANTINED is open; a live solver coming up is "not started yet").
Frontend suite 94/0, solver suites unchanged, typecheck:solver 0 diagnostics.
…-04, F-05, F-06 - F-01 journalTone keyed on invented states; now SETTLED ok, QUARANTINED bad, REVERTED/FAILED warn, everything else in flight. Fixtures and tests use the journal's real lifecycle names. - F-02 the published-ladder stage stayed green while the relay socket was down; it now reads "not on the wire" (red) with the last push time, since the relay drops every per-solver frame with the socket. - F-04 the monitor's solver stream had only the 5.5-minute hard timeout, so a half-open socket read "reachable" with a frozen snapshot; a per-frame idle deadline of 15 s (three missed 5 s heartbeats) now ends the stream. - F-05 gates.sh G1 redacts *_TOKEN / *_SECRET / *_PASSWORD / *_API_KEY values in the rendered-model transcript. - F-06 serveStatic looked the name up with [] on a plain object, so inherited names (constructor, __proto__, toString) reached Bun.file().exists(); the manifest check is Object.hasOwn now. Audit: audits/00007-cow-solver-frontend-final-gate.md — PASS WITH FINDINGS, 0 blockers. Suites 776/0, typecheck:solver 0 diagnostics.
acedward
changed the base branch from
codex/00007-solver-status-listener
to
main
September 3, 2026 16:58
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
Stacked on #58 (the solver's status listener). This PR adds the page that reads it, plus the deployment.
packages/solver-frontend— a LIGHT aggregator (oneserver.ts+env.ts, zero runtime dependencies, no build step, no persistence, bounded in-memory state) and a vanilla page (public/{index.html, styles.css, app.js, derive.js, help.js}, no external assets). It joins three sources — the solver's bearer-gated/status/*(SSE with the 5-minute rollover treated as normal, snapshot polling as fallback), the kernel's REST API (/v1/health/sync,/v1/offers,/v1/known-tokens,/v1/pairs, and/v1/prices?tokens=when the node has it), and the relay's publicGET /tokens— into oneMonitorSnapshot, tracks solver reachability itself (never-reached / reachable-since / unreachable-since + last seen, last good snapshot kept), and keeps a bounded transition history. Routes:/, five whitelisted static files,/api/snapshot,/api/stream,/health; nothing else, no proxying. The status token goes to the solver as a bearer and never reaches/api/*, the page or logs (tested).The page answers "is the solver quoting, and if not, why" from one screen: a status pill (QUOTING / WITHDRAWN / DISCONNECTED / STARTING / DRY-RUN / SOLVER UNREACHABLE), a six-stage health strip (kernel sync → book cache → inventory → journal & DUST → relay socket → published ladder) with the solver's own reason strings, alarms, stat tiles, the published ladders with the maker offer behind every rung, every offer the solver did not publish with its real
LadderExclusionReason, the kernel book beside the solver's mirror, inventory, the journal tail, DUST admission, config, and an event log. Every block carries a?tooltip saying what the number means and where it comes from. Light + dark, responsive.Root
bun run start:solver-frontend(fail-fast env validation listing every problem at once, mirroringstart:solver).Deploy (
deploy/)solvergainsSOLVER_STATUS_HOST=0.0.0.0,SOLVER_STATUS_PORT=9100,SOLVER_STATUS_AUTH_TOKEN— the port is not published to the host (commented opt-in for loopback debugging).solver-frontendservice (kernel image, depends onkernelonly — never onsolver, so the page outlives it —restart: unless-stopped,bun -ehealthcheck,${BIND_ADDR}:${HOST_SOLVER_FRONTEND_PORT:-18080}:8080).register-minted-tokensone-shot: after the kernel is healthy it registers the faucet colours as TESTTOKENA/B/U fromminted-tokens.json(mint-test-tokens.ts's own registration posts to a dead/api/known-tokenspath and to its own loopback before the kernel exists — left for apackages/fix, organizer issue 00008). Non-fatal, idempotent.bootstrap.shgeneratesSOLVER_STATUS_AUTH_TOKEN;.env.example,deploy/README.md("Observing the solver"), root README "Monitoring".gates.sh: G1b (services rendered), G3g (short status token is a listed launch problem), G3h/G3i (site fail-fast), G8 (site assets in the image), and secret redaction in the rendered-model transcript.Existing
deploy/.envfiles needSOLVER_STATUS_AUTH_TOKEN(≥ 32 chars;./bootstrap.sh --forceregenerates, oropenssl rand -hex 32) — Compose refuses to start without it (:?-mandatory on two services).Two pre-existing deploy defects fixed here
mainsince the token price service (feat(prices): seeded asset prices, price-feed service (CoinGecko), /v1/prices, quote sources #54–feat(batcher): sponsorship gate — pay the Celestia fee only for offers priced below reference (re-land of #55 onto main) #56):packages/price-feed/package.jsonwas never copied into the install stage, sobun install --frozen-lockfilerefused the lockfile. One COPY line.gates.shnegative gates were vacuous for literal patterns since D1: the helper grepped its own "### expects:" header, so any literal pattern matched itself. The command output is now captured separately; with that fixed, G3b's pattern was wrong (Compose renders an unset variable as"", which the journal parser words differently) and is widened.Verification
bun test packages/solver-frontend: 96 pass / 0 fail; all gated suites 776 / 0;bun run typecheck:solver: 0 diagnostics over 113 strict roots (packages/solver-frontendis a listed gated package, compiled as its own program because its browser derivations are.js).completed0 → 3; teardown to zero resources verified twice.pushdiagnostic drowning the event ring (folded, in feat(solver): bearer-gated read-only status listener with a versioned snapshot contract #58's collector), and the journal stage counting SETTLED rows as open.Independently audited (organizer
audits/00007-cow-solver-frontend-final-gate.md): PASS WITH FINDINGS, 0 blockers; F-01/F-02/F-04/F-05/F-06 fixed in6b9a725.Not in this PR
mint-test-tokens.ts's broken registration (issue 00008); a duration threshold for the QUARANTINED alarm (it currently followsexecutor.stats.quarantined, which reads 1 during a settlement's normal evidence wait); host deployment beyond Compose (reverse-proxy note inpackages/solver-frontend/README.md).