Skip to content

feat(solver-frontend): read-only monitor site for the COW solver, as a Compose service (stacked on #58) - #59

Merged
acedward merged 7 commits into
mainfrom
codex/00007-cow-solver-frontend
Sep 3, 2026
Merged

feat(solver-frontend): read-only monitor site for the COW solver, as a Compose service (stacked on #58)#59
acedward merged 7 commits into
mainfrom
codex/00007-cow-solver-frontend

Conversation

@acedward

@acedward acedward commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

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 (one server.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 public GET /tokens — into one MonitorSnapshot, 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, mirroring start:solver).

Deploy (deploy/)

  • solver gains SOLVER_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).
  • New solver-frontend service (kernel image, depends on kernel only — never on solver, so the page outlives it — restart: unless-stopped, bun -e healthcheck, ${BIND_ADDR}:${HOST_SOLVER_FRONTEND_PORT:-18080}:8080).
  • New register-minted-tokens one-shot: after the kernel is healthy it registers the faucet colours as TESTTOKENA/B/U from minted-tokens.json (mint-test-tokens.ts's own registration posts to a dead /api/known-tokens path and to its own loopback before the kernel exists — left for a packages/ fix, organizer issue 00008). Non-fatal, idempotent.
  • bootstrap.sh generates SOLVER_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.

⚠️ Operator-facing change

Existing deploy/.env files need SOLVER_STATUS_AUTH_TOKEN (≥ 32 chars; ./bootstrap.sh --force regenerates, or openssl rand -hex 32) — Compose refuses to start without it (:?-mandatory on two services).

Two pre-existing deploy defects fixed here

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-frontend is a listed gated package, compiled as its own program because its browser derivations are .js).
  • Static gates 22/22 (six image builds, ten negative controls).
  • Full Compose proof on a clean stack: 10 services healthy + 4 one-shots exit 0 with TESTTOKENA/B/U registered; page QUOTING with the live maker offer's rung and provenance; status port 200 with the bearer / 401 without; relay stop → DISCONNECTED in 1.7 s, start → QUOTING in 7.3 s; solver stop → SOLVER UNREACHABLE in 0.2 s (kernel/relay panels still live), start → QUOTING in 16.7 s; the 00005 E2E driver (cases A–D) ALL ASSERTIONS PASSED with the Jobs panel tracking every settlement to its ledger tx and completed 0 → 3; teardown to zero resources verified twice.
  • Two defects found only on the live stack and fixed in this PR: the once-a-second push diagnostic 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 in 6b9a725.

Not in this PR

mint-test-tokens.ts's broken registration (issue 00008); a duration threshold for the QUARANTINED alarm (it currently follows executor.stats.quarantined, which reads 1 during a settlement's normal evidence wait); host deployment beyond Compose (reverse-proxy note in packages/solver-frontend/README.md).

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
acedward changed the base branch from codex/00007-solver-status-listener to main September 3, 2026 16:58
@acedward
acedward merged commit 8c7740b into main Sep 3, 2026
5 checks passed
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