The cargo tests put web.rs at 17% and daemon.rs at 10%, because the
browser suite, which drives the API, ran a daemon nobody measured.
`sh scripts/coverage.sh api` builds an instrumented ipx and
ipx-directory in the capped coverage container and runs the browser
suite here against them (IPX_API_COVERAGE): web.rs is at 80% and
daemon.rs at 77%. What it does not reach: access.rs (Cloudflare Access,
0%; its unit tests 73%) and the directory's public pages (site.rs, 4%).
The coverage image is Ubuntu 24.04 now, as code-server is: built on
Debian trixie, the binaries needed glibc 2.41 and code-server has 2.39.
In that mode the suite starts those binaries rather than building its
own, its tests' `ipx` commands too (global-setup's IPX_BIN), passes
LLVM_PROFILE_FILE to both servers, and stops them with SIGTERM, since
an instrumented binary writes its profile as it exits and Playwright
otherwise kills them outright.
Reports go to target/coverage/cargo, api and page. Each run ends with
scripts/coverage-metrics.ts, which writes every suite's lines covered
and in all, by crate, to node-exporter's textfile directory; the
monitoring project's node-exporter now reads it (one flag, its compose
file backed up beside it), so Prometheus keeps them as
ipx_coverage_lines, and the iPX dashboard's Coverage row draws them:
each suite now, over time, and by part. The cargo tests' totals hold
their own code, which runs whole, so they read higher than the code
alone; the panels say so.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Every browser test signed in with the token link, so the form was never
run: login.ts at 0% in the page coverage (#390). login.spec.ts, on the
suite's page before anything signs it in: signed out, the app sends you
to the form, its name field focused; a wrong password shows the
server's "wrong name or password" and leaves you signed out; the right
one, submitted with Enter, opens the app as admin. With the form
changed to show only its fallback message, the second fails, as it
should. login.ts is at 87% of lines now.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The page and the service worker were strict TypeScript; the tests, the
build, the home page's generator, the load tests, boot.js and the
directory's site.js were plain JavaScript that nothing checked. All of
it is TypeScript now, in five programs, each with its own globals, all
checked by npm run typecheck: the page (tsconfig.json, with boot.ts and
the directory's site.ts), the worker, Node's (tsconfig.tools.json: the
Node tests, the browser suite, the build, the site generator, the load
harness), and k6's (tsconfig.k6.json, with @types/k6).
Node 24 runs the .ts files by stripping their types, so they hold only
TypeScript that erases (erasableSyntaxOnly) and import each other by
.ts name; package.json says "type": "module" for it, and Dockerfile.web
moves to Node 24 to run build.mts. k6 2.3 strips its scripts' types
itself.
The browser suite's page.evaluate calls reach the page's globals; the
page's tsc run now also writes its declarations to target/page-types,
which the suite's program includes, so those calls are typed by the
page's own code rather than by hand-written copies.
boot.ts is compiled by build.mts with swc, as the worker is, and named
by the hash of what is served. The directory's build has no Node, so
site.js is compiled from site.ts and committed (node web/build.mts
--directory-script), and tests/directory-script.ts fails if they drift.
Checked: the site generator's 139 pages are byte for byte what the
JavaScript made; the page build's app.js is identical and only boot.js
(now minified) and its hash differ; contrast, page-smoke, page-unit,
native-bridge, the browser suite (96 passed, 2 skipped) and the
directory's and ipx's Rust tests pass; k6 parses each load script. The
load tests themselves did not run: they build a release binary outside
the capped builder. .agents/skills/security-audit is an installed
skill's, not ours, and is left as it is.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Nothing measured what the tests reach. scripts/coverage.sh runs the
cargo tests under cargo-llvm-cov in the ipx-coverage image
(Dockerfile.coverage, built by build-image.sh coverage), in a container
held to the image builder's 6 GB and six threads, one crate at a time,
with the same room and load checks: an instrumented build rebuilds every
dependency, the kind of build that took Tower down on 2026-11-04. The
first run stayed under load 8 with 11 GB free; 69% of lines. web.rs and
daemon.rs read low because the browser suite drives them against a
daemon that is not instrumented.
npm run coverage:page runs the browser suite on a build of the page
(IPX_COVERAGE=1) whose script keeps a source map to each web/src file:
web/concat-map.mts maps the joined files line by line and swc takes it
as its input map. Each test's page records V8's coverage
(tests/ui/coverage.ts, which the specs now take test and expect from),
and monocart-coverage-reports writes it file by file: 73% of lines.
build.mjs requires the map helper only for that build, since
Dockerfile.web's Node does not run TypeScript, and not with a top-level
await, which stopped the CommonJS tests requiring build.mjs.
The offline tests move to offline.spec.ts, in TypeScript, and stand
alone (they add their feed and set the read state they need), with what
#391 found untested: another account signing in clears what was kept, a
position saved offline waits and is sent, and pictures past the budget
go oldest first (keepPictures takes the budget, and trims whether or
not anything new came). The fixture JPEGs were a few bytes no browser
could draw; they are real now, and the artwork test checks one draws.
tsconfig.tools.json type-checks the tests' and build's TypeScript, in
npm run typecheck.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
With no network the home-screen app showed nothing: no service worker,
and the lists the page had fetched, full text included, lived only in
its memory.
A service worker (web/sw/sw.ts, TypeScript checked against a worker's
globals by tsconfig.sw.json, built by swc to /sw.js) goes to the network
first for the page and every API read and keeps the answers, so online
nothing is an old copy and #388's version check works as before;
offline the kept copy answers. Scripts named by hash come from what is
kept. Reads, flags and places in an episode that cannot reach the
server wait in an outbox, sent in order when the connection is back
(and new ones queue behind them meanwhile, so none lands out of turn);
a kept list served offline shows the reads waiting there.
Pictures: a worker cannot keep another site's pictures sensibly, since
browsers count each opaque answer as megabytes. ipx now remembers the
picture addresses in item text it sends (bounded, in memory) and
/api/art fetches those as it does artwork, through the guarded client;
the worker keeps them, for unread items, under a 200 MB budget, and
serves them offline in place of the publisher's. Online, pictures still
come from their publisher.
Sign-out, or another account signing in on the device, clears what was
kept. The browser suite blocks the worker except in the offline tests,
since it answers before page.route can.
The fixture JPEGs are placeholders a browser cannot draw; the offline
test uses a real PNG.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The live stream already came back after a deploy and the lists caught
up, but the page kept running the script it had loaded, in a tab or the
home-screen app, until reloaded by hand. Back after a drop, the page now
asks for / again without the cache; another app.js named there is a new
version. With nothing to lose it reloads, and the item that was open,
carried across in sessionStorage whole since a read one is off the
Unread list, is opened again. While an episode plays, a dialog is open,
a search is set or a field is typed in, it offers a reload in a toast.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
hasMore() compared nextOffset(), which on Unread counts only the loaded
items still unread, with S.total from the answer: once they were read it
was 0 against that total, so the page asked for a page that came back
empty and took its total, 0, with the items still on screen. Whether
more is left now comes from the last answer itself, its offset and items
against its total; reading takes an item off the server's list and off
nextOffset() alike, so what that answer said is left stays true.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A swipe that only bounces sends no request and throws nothing, so
#383's stop at the 50th item left nothing in the log or the traces, and
the reader that would not scroll in the same report was never found.
The page now reports each swipe in the reader (next, prev, list, end,
short; a tap is not one) with where the reader stood, under the tap's
trace, and scroll_stuck when a drag up or down with room to move the
reader left it where it was. That one is watched by where the finger
was, not by what it landed on, so a layer lying over the reader is
caught, and its state names what is under the finger, what is inert,
the dialog, the reader's transform, a #dpeek left behind, and how long
since the app came back. ipx logs swipe as info and scroll_stuck as a
warning, in a new free-text state field, and the dashboard counts both.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The item list is fetched 50 at a time, and only the list's Load more
button asked for more. A swipe or j stops at the last item loaded, so
after 50 items the reader bounced on every swipe until a reload brought
a fresh page of unread ones.
Turning to within five of the end now fetches the next page behind. Its
offset counts what is still unread on the Unread tab: the items read
here have left the server's unread list, and Load more's flat 50 a page
skipped as many unread items as had been read. A refresh (a return from
the background, a live update) always asks for the first page, as the
kept list it fills is one, where it used to ask for whatever page Load
more had last reached. A page appended after new items arrived no longer
repeats ones already loaded.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
traceparent keeps one trace for any request within a minute of the last,
and the tap span ran to the last answer in that trace, so a live update's
refresh 13 s later made a tap answered in 0.9 s read as 15 s (trace
a895551d20626fb6dc071d336262b772). Now a request counts towards the tap
only when one of the tap's own is in flight or it begins within two
seconds of the tap or its last answer; it still joins the trace.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
endTap reported the tap before clearing it; report's traceparent starts a
new trace when the last is over a minute old, and newTrace ends the open tap
again, so hiding the page (switching apps on the phone) a minute after using
it recursed until the stack ran out. Timings are never throttled, so nothing
broke the loop. Introduced in #376.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Reviewing what production's traces and metrics leave out turned up
twelve gaps; each is an issue.
Metrics. A Prometheus histogram for ipx and the directory both
(ipx_directory::metrics::Histograms), which ipx's request histogram
(#364) now uses too.
- ipx: each statement's time once it has a connection, by its first
word, from SeaORM's metric callback; and the wait for one, for
hand-written SQL in db.rs, measured as the call less the statement
(the callback runs in the poll that returns the result, so the time
read after the await is that statement's) (#367). SeaORM's own queries
are timed but their wait is not seen: a shortcut, noted.
- ipx: counters of what the events say, counted in emit, which every
event passes once: feed failures by error.type, new items, downloads
saved and failed, DeadAir's seconds found (#368).
- ipx: the page's list_shown, play_start and stall timings (#369).
- directory: requests by method, route (a route layer puts the matched
route on the response, or each feed's page was a row) and status;
feeds checked and failed; snapshots taken (#370).
- ipx-web: Caddy's own metrics on METRICS_BIND (:9180), served apart from
the port the tunnel reaches. `servers { metrics }`, which Caddy 2.11
calls deprecated, because the browser tests' Caddy is 2.6 (#371).
Traces.
- A list's sync is list_sync with steps, an OPML upload opml_import
(#372). Doing so showed sync_group reading every account and its
subscription to the list once per listed feed; read once now (#377).
- Each command the worker runs, from the socket, the CLI or the page,
is a command span with its job inside (#373).
- The daemon's start is a trace, from opening the database through the
catalogue's repairs to "daemon started" (#374).
- The directory's lines carry trace_id and span_id: WithTrace moves to
ipx_directory::otel and both use it (#375).
- A tap or key is its trace's root span (#376). The page names a root
span id in each request's traceparent, where the parent was random and
pointed at nothing, and reports the tap (ids, start, its last answer)
when the next begins or the page is hidden. opentelemetry 0.33 gives a
span builder no way to take ids, so the shared tracer provider has an
id generator that takes given ids inside with_ids, on one thread.
Alerts. grafana/alerts.py, Grafana alerting provisioning beside the
dashboard (#365): ipx, the directory or ipx-web down, scans stopped, a
job over 30 minutes, the pool full, 5xx, 502s, Jev refused, Signals
stopped, the directory's refresher stopped, Tower's load and memory, the
monitoring down, Tempo not reading its blocks, iPX's lines not reaching
Loki. No contact point yet: firing shows in Grafana. Prometheus now
scrapes Tempo, Loki, Alloy and itself (#366), in the monitoring project's
config, not this repository's.
The dashboard has database and page-timing panels; the prod check knows
the new metrics, spans and alerts.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Asked for as a share of the window. The pane is the window less the
feed list, so a share of the window would never bind; a share of the
pane does. On a phone the pane is the screen, and keeps all of it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Across a wide window a line ran past a hundred characters. On trial:
reverted if it does not suit.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The font chosen before is the interface's, under the same cookie, so a
choice already made stays as it was. The reading font, ipx_rfont, sets
an item's title and text and is the interface's until chosen. Text read
in a serif gets a line height of 1.75 for 1.65, whichever setting put
it there. Each face's stack is a custom property, used by both.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
All under the SIL Open Font License, taken from Fontsource's npm
packages with their licences beside them in web/fonts. Atkinson is the
Braille Institute's 2025 Next, one variable file. Each is its Latin
subset only, 33-118 KB, fetched once a browser chooses it; anything
outside Latin falls to the next font of the list.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Kept as the theme is, in a cookie each browser its own, and put on the
page by boot.js before the first frame. Theme's own, the default, adds
no rule, so Modern's Inter, Classic's Lucida Grande and Glass's system
face are as they were. The others are on the device already or served
by ipx (Inter), so choosing one downloads nothing. Their rules are
last in app.css, where they tie with each theme's own body font and win.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
patch_feed let private = true through for anyone signed in, as the
field's doc comment meant for subscribers, but never checked that the
caller subscribed. Ids are slugs of titles, so anyone could mark
another person's feed, or a whole OPML (whose shows go with it),
private, keeping it out of the directory, and promote a derived feed
they did not read out of its OPML; only an admin could undo either.
A non-admin now needs a subscription to the feed, or gets 404, as the
per-person settings above already do.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
3bfd8f8 gave the refresher the guard with no exceptions, which broke
the browser suite: its directory refreshes fixture feeds on 127.0.0.1,
and five Directory tests waited on listings that never filled. The
cargo tests passed because none of them fetches over HTTP.
directory.toml takes fetch_internal as config.toml does, empty by
default; the suite's lists 127.0.0.1. Production's has none.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
ipx's clients had no destination policy and followed redirects
anywhere. Adding a feed returned the status or first line of whatever
answered at the address; an enclosure declared as audio was downloaded
whole from any URL and served back through /media; /api/art fetched
any image a feed named. Feeds come from publishers and anyone signed
in, so loopback, the Docker network and the LAN were all reachable.
ipx-feed's guard is a connector layer: after the name resolves and the
connection opens, before the request is written, it looks at the
peer's address and refuses anything off the public internet unless
config.toml's [general] fetch_internal lists it. Being on the
connection, it covers every request on ctx.client and feed_client,
each redirect hop, IP literals (which never reach a DNS resolver) and
a name that resolves differently the second time. reqwest's rustls
connection reports the peer address; a test run by hand checks HTTPS
gets through, since were it lost every https fetch would be refused.
The directory is on the server's own network by design
(http://ipx-directory:8100), so it moves to local_client, unguarded,
set by an admin. The browser suite and load tests serve their feeds on
loopback and list 127.0.0.1.
Not covered: librqbit's own connections (trackers, peers), which do
not go through these clients.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
An item's link and an enclosure's URL are the publisher's, stored as
written, and the page put them in href and window.open through esc(),
which escapes characters, not schemes: a javascript: or data: link
survived as Open the original, View in a new tab (for a file not on
disk), the Directory's item links and the v key. Show notes already
lose those schemes to ammonia; these four sinks were the exception.
webUrl() passes http(s) only, and each sink goes through it. A link
that fails it shows no link, as an item with none does.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The header was drawn once, on opening the feed. feed_row put the new row in S.feeds and redrew the
feed list but not the open page, so a check that cleared an error left the error showing, with the
old "checked" time, until the feed was opened again. Seen on Eli Bendersky's website: a connect
timeout at 15:13, a clean forced check at 15:28, and the timeout still on the page.
Only the stat line and the error banner are redrawn: the whole page would close the open item.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Fixes from the UI-polish review of the main window (2026-10-19).
Reduced motion took every animation away, and a turning icon was the only sign a check was
running. The refresh buttons now carry aria-busy and a "Checking for new items" label while
checking, and under reduced motion spinners pulse and toasts and sheets fade.
Every :hover rule is behind @media (hover:hover), since Safari keeps :hover on the last thing
tapped; the two phone resets that undid it for .btn and .ep had nothing left to do and are gone.
An item row's actions still show on hover without the query, so a tablet with no pointer can
reach them by tapping the row.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Found reviewing the main window, its dialogs and the Directory in all 25 palettes (13 themes,
light and dark), in Playwright against the test daemon.
- #298: an item's title is a button, so Tab and a screen reader can open it; the feed list's
rows, places, Signals and the Directory's shows are links to a screen reader (asLink), named
by what they show, the open one aria-current. Not role=button, which would flatten a folder's
triangle inside it.
- #299: .ep:focus-within shows the row's actions; Tab reached Play and Delete at opacity 0.
- #300: the read toggle is named Mark read / Mark unread, the dot hidden from screen readers.
- #301: the seek slider is named Position, its value read as "12:30 of 45:00".
- #302: ::placeholder in --faint; the browser's grey was 2.7-4.4:1 in 20 palettes. The search
and filter boxes have names that do not depend on their placeholder.
- #303: #toasts is role=status; an error toast stays until dismissed, at most four at once.
- #304: api() says what to do when iPX cannot be reached or answers 500 (whose text is the
daemon's internals, already in its log); play() failures are put in words and reported, an
AbortError ignored; a failed download names its item.
- #305, #308: --field, the theme's --fg mixed into --bg in OKLab (--fieldmix, 60% and up to 80%
for Catppuccin light), edges text boxes, selects, chips, the OPML box and switch tracks,
phones included, and rings a light theme's switch knob. tests/contrast.js holds it to 3:1 on
bg, panel, panel2 and raise in every palette. --line stays for rules. Fields lose outline:0,
so they take the same focus ring as everything else.
- #306: openModal makes the card role=dialog aria-modal, labelled by its heading, focuses it,
and makes the rest of the body inert until closeModal, which gives focus back to what opened
it. Not <dialog>: its top layer would cover the toasts and their Dismiss.
- #307: the labels in Add a feed, Settings, Download latest and OPML point at their boxes.
- #309: Settings shows iPX's and the page's versions at its foot.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Once something is typed: at the box's right end, inside it. One click
handler for every box, which clears it and fires its input event, so each
box's own handler brings its list back whole. The browser's own x, drawn
by some browsers and not others, gives way to it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A switch in the feed's settings, In All Subscriptions, on its
subscription as hide_from_all. All Subscriptions' list and count leave
out a feed hidden on its own or through the OPML it is in, and the page's
unread count does the same. Signals read every feed as before, and
Currently Listening, which is All Subscriptions' in-progress filter, keeps
an episode under way whatever the feed's setting.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Rendered Markdown is the one wanted beside the publisher's HTML. A browser
that had chosen the source view shows the HTML.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A feed that sends <source:markdown> beside its HTML (Scripting News) has its
Markdown kept in entries.markdown, added to existing databases by an ALTER.
A stored item gets it when a scan finds it told differently, the Markdown
counting as a change, the way corrections arrive (#141). The API sends it as
written and made HTML, cleaned by ammonia like the description.
The item's page has a choice between its meta line and its buttons: HTML,
Markdown or Markdown source, remembered in the browser.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
GET /api/feeds/{id}/entries for an OPML or a Patreon creator now lists the
items of the shows in it the person subscribes to, as All Subscriptions does
every feed's. The shows are looked up first and queried by list: an OR with a
subquery, or an IN over one, made a single feed's page a sequential scan of
entries (clarkesworld 10 ms to 26 ms or a second, EXPLAIN on production), so a
single feed keeps its `feed_id = $1`.
The page draws the OPML with its own header and the item list, tabs, search
and sort of any feed, with the feed column showing which show each is from.
A show's scan or change refreshes its OPML's list.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A show inside a subscribed OPML went when the OPML stopped listing it
(orphaned only if it had downloads), and went for you when you left the
OPML. Keep, in its settings, is a per-person flag on the subscription:
when the OPML drops a kept show, or nobody subscribes to the OPML any more,
it becomes a catalogue entry outside the OPML, taking the OPML's settings
while it is there, and stays on the lists of those who kept it. Whoever had
it only through the OPML loses it as before. Leaving the OPML yourself
leaves what you kept on your list.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
/src/.envrc's TYPESAFE_KEY is a development key, so the test daemon uses it
and the server paying for Signals is tested end to end. Signals' tests spend
it, so each runs only when the Signals code or the test itself has changed
since it last passed (a hash kept under target/signals-tests);
IPX_SIGNALS_TESTS=1 runs them anyway.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
With signals_on_server set and TYPESAFE_KEY present, the judge asks Jev
about everyone's Signals on the server's key; otherwise each person turns
Signals on in Settings with a key of their own, which is now checked with
TypeSafe before it is saved. The Signals folder shows only when they are on.
The browser suite's daemon inherited the shell's TYPESAFE_KEY, so fixture
feeds were sent to the real TypeSafe to be categorised; it is blanked now,
and an empty key counts as none.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Font Awesome's Mandalorian icon has "disney plus" among its search terms, the only icon with
"disney" in them, so a search for Disney Parks' words offered it and Jev picked a Star Wars helmet.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Jev chooses among options and cannot name one, so the page searches Font Awesome's index for each
word of the Signal's name, words and sentence, the best six each, and offers the likeliest 40.
POST /api/signals/icon asks a choice question on the server's TYPESAFE_KEY and answers with one
of those offered, never another. "Let Jev pick" in the icon picker, and a Signal made without an
icon gets one a moment after. Tried against TypeSafe: for AI Development, laptop-code 0.45 just
ahead of robot 0.40.
Each Signal's row says "12 items · 3 unread", as a feed's row counts its own, in place of its
words; /api/signals gives `items` beside `unread`, by the same rules as its list.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Jev was asked "Is this item about any of these: ai, development, programming?", so any one broad
word was enough and the Signal's name, which says what is meant, was never sent. AI Development
held 54 items: Sega pulling a game remaster (0.90), a Surface Laptop review (0.95), a C++ tutorial
(0.99). It is now asked about one subject, called by the Signal's name, described by its words
and, when those are not enough, its owner's sentence. Scored against TypeSafe over those 54 with
"AI tools for writing software: coding agents, AI dev tools, vibe coding, AI in code review.":
5 stay, Linux kernel AI code review highest at 0.94; the remaster 0.06, the C++ tutorial 0.02.
signals.description is new, and its first start deletes every verdict, which answered the old
question, so everything in the window is asked again. Any change to a Signal's name, words or
sentence drops its verdicts, since each is part of the question. The queue query names the
Signal's sentence apart from the item's description, which share a row; the test caught the
item's winning.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The page fetched the Signals' unread counts only with the feed list, so reading an item, here or
in its own feed, left its Signal's badge as it was, and so did Jev adding one. The Apple Signal said
2 unread over a page with none. The page now fetches them again after a read or unread lands, and
on a new signal_hits event, which the judge sends once a pass for each person it found items for.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Every call was refused with 422: a noul question's `criteria` is an object of what true and false
mean, and it was sent a sentence. The error kept only the status, from error_for_status, so the log
and the trace said 422 and not why; TypeSafe's body said "Input should be a valid dictionary"
at questions.3.noul.criteria. The body is now in the error and on the jev span, with the status.
A request refused as malformed (400, 422) ends the pass, since every other item's would be refused
the same way: the 50 warnings a pass were the same failure 50 times. The state is text now, as
TypeSafe's docs show it, rather than an object that sent null for a feed with no title, and the
questions are keyed signal_<id>. Checked against TypeSafe with the corrected request: 0.96 for an
item about the keywords, 0.02 for one that was not.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A Signal is a name and keywords. A task beside DeadAir's asks TypeSafe's Jev, on the owner's own
key, about each item of their subscribed feeds from the last 30 days and every new one: one call
an item, one noul question for each of their Signals still to hear about it, 0.5 or more a fit.
Every verdict is kept, fitting or not, in signal_verdicts, so nothing is asked twice, and new
keywords drop a Signal's verdicts to be judged afresh. A key TypeSafe refuses (401/402/403) is
passed over until its owner saves another, so a bad key is not sent the whole queue.
A Signal's items come through entries_where with an EXISTS on its hits, so filters, search, sort
and read state are an ordinary list's, and reading an item in a Signal reads it in its feed.
The page shows Signals under the places, with New signal, and Settings takes the TypeSafe key,
which is never sent back. The browser suite stubs Jev in the fixture server (IPX_TYPESAFE_URL).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Measured 2026-10-16, uncompressed until now: the directory's answers (the feed list ipx reads,
603 KB of JSON, ~134 ms of a 159 ms Directory request), ipx's snapshot upload (~100 KB each half
hour), both /metrics (4.7 and 0.9 KB every 15 s) and every trace export to Tempo. Already
compressed: ipx-web's answers (zstd, #239), the home page and the directory's public pages
through Cloudflare (brotli, zstd).
- The directory compresses with brotli, zstd or gzip as asked, and unpacks a gzipped request.
- ipx sends its snapshot gzipped; the directory is deployed first.
- Both /metrics answer gzipped, as Prometheus asks.
- opentelemetry-otlp's gzip-http: OTEL_EXPORTER_OTLP_COMPRESSION=gzip compresses the export, set
for all three in production's compose file; Caddy's exporter reads the same variable.
- web/build.mjs writes .br (quality 11) and .gz (level 9) beside each text file, and Caddy serves
them in place (file_server precompressed): app.js 30 KB where zstd on the fly gave 37, app.css
11 where 14. A .br or .gz asked for by name is refused, or /admin.js.br would be the admin
page's script without the check that keeps it to admins; in a handle of its own, since a bare
respond runs after the handles.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Both fetched on every visit and drew only when the answer came: Currently Listening took 1.3 s.
fetchPage keeps their answers by address, as the item lists are kept, sharing a request already
on its way; both draw from what is kept at once and again only if the refresh changed them, and
show the spinner with nothing kept. They are fetched ahead with the lists when the page loads,
and when the pointer or a finger reaches their row. Removing an episode from Currently Listening
forgets its kept copy, which would otherwise show it again until the refresh. The preload loop
takes a failed fetch as done instead of leaving an unhandled rejection.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A click on a feed, All Subscriptions or a filter fetched the list and only then drew it, so
nothing answered the click until the server did. The page now keeps the first page of each list
(the newest 40, by view, filter, sort and search), draws a kept one at once and refreshes it
behind, drawing again only if it changed. It fetches ahead: All Subscriptions and the twenty
feeds with the most unread once the page is idle, two at a time, and a feed or filter when the
pointer or a finger reaches it. A feed_row or a read forgets that feed's lists and All
Subscriptions'; the forgotten are fetched again behind. A list with nothing kept shows a
spinner if its answer takes more than 120 ms.
list_shown (#246) is reported when the list first shows, so a kept one reports its few ms.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
#241: on production, All Subscriptions' query runs in 31 ms, unread oldest-first in 30, the
feed list's count in 5, generic plans included (EXPLAIN ANALYZE, BUFFERS, read only), against
0.3-1.6 s in ipx. A new Postgres connection takes 80-330 ms to open, and a page load asks for
more than are idle: four of six slow loads came as the pool grew (3 to 6, 2 to 6, 3 to 5, 6 to
8), and 20 requests at once took a median 414 ms while the pool grew from 8 to 20. The daemon
and the directory's server now keep their whole pool open (min = max); a command such as `ipx
status`, and the tests, which share the server's 100 connections, open as they need.
#246: the page reports list_shown, the ms from asking for a list to drawing it, with the view
and filter, under the click's trace; background refreshes are not timed. Timings skip report()'s
once-a-minute limit, which is for errors in a loop. A dashboard panel and table, and the prod
check, read it.
#245: the load test's browse scenario subscribes by OPML and leaves the Directory, which is
ipx-directory's since #204, to it.
CLAUDE.md records the owner's leave to EXPLAIN (ANALYZE, BUFFERS) any query on production,
read only.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>