`query.py flame '<TraceQL>' [since] [out.svg]` folds the matching spans of up to
100 traces into stacks of self time and draws them as a flame graph, with the
folded stacks beside it for speedscope or inferno. A wall-clock picture of where
a route's time goes, waiting included, which a CPU profiler would not show: ipx
waits on Postgres and the disk far more than it computes. The traces are fetched
in one container, a docker run apiece being a second each.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
pelgranepress.com is on WordPress.com, whose CDN answers /feed/, /feed/atom/ and ?feed=rss2
with HTTP 403 and "Checking your browser...", a JavaScript challenge, whatever the User-Agent.
The checks that hit it failed for hours (8 in the day before this), and the publisher has
not changed anything.
The API at public-api.wordpress.com/wp/v2/sites/<host>/posts has the same posts with no check,
and each post's guid is the feed's own (https://host/?p=<id>), so the items read from it are
the ones already stored. fetch recognises the challenge by its /__challenge/ script, asks the
API instead, and parse reads WordPress's posts JSON as a feed. It sends no title or image for
the feed, which record_feed keeps as they were.
shortcut: the API's default ten posts, as many as WordPress puts in a feed.
Co-Authored-By: Claude Sonnet 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>
resp.bytes() read any size whole: an episode's MP3 given as a feed was
held in memory at every check. Feeds here are a few MB at most.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
missing_files took every path that did not exist() as deleted by hand.
With /downloads out of reach (the array stopped, shfs dropped) that is
every file, and each would have been marked reaped at once.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Progress and DownloadDone went to every open page and the log with the
file's whole address; a Patreon file's carries its signature. Seen in
production's log for gcn-news-minute.
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>
hide_from_signals on a subscription, on an OPML or creator covering every
show in it, as hide_from_all does for All Subscriptions: the judge does
not put its items to Jev, and each Signal's list and counts leave them
out. shown_in() is the one SQL rule for both.
The settings dialog had grown one captioned setting at a time into a
single column, one part boxed and the rest not. It is in named parts now,
the name in a column beside the controls and above them on a phone, ruled
apart; All Subscriptions and Signals carry the sidebar's icons. The
address, the same for everyone, moves to the foot.
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>
The prod check of 2026-10-19 found /api/entries requests of 0.5-0.8 s
overnight beside others of 80-100 ms on an idle host, the time all in
entries_in, and nothing in the trace said which filter, sort or page
they were. The step now carries them, and whether a search, not its words.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Caddy named every span ipx-web, so Tempo's lists and trace view showed the
same name for each request. Caddy 2.11 takes placeholders in the span name:
now the method and path, as ipx names its own. The prod check's span list
shows a span's url.path when it has no route.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Check 5 listed traces over 5 s, which were all page traces holding minutes of
live updates or audio, so a slow request never showed. query.py's new `spans`
lists the matching spans with their own time; check 5 asks for ipx requests
over a second, steps over half a second, and says to set the hours beside
Tower's load: on 2026-10-18, 120 of 130 slow requests were the host stalling.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The pre-commit hook ran clippy on the whole workspace for any Rust change;
it now checks the crates touched and those built on them (ipx uses
ipx-directory, both use ipx-feed), and nothing for a change to the page
alone. CLAUDE.md says which tests go with which part.
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>
/api/art asked whether a feed or item names the image before anything else,
cache hits included, and entries.image had no index: a sequential scan of
entries, 571 ms on production, 50 at once for a page of images. A kept image
is served first now, since it was only kept once let through, and entries.image
has an index: hash on Postgres, whose btree refuses keys over about 2.7 KB.
The Directory lists podcasts nobody here subscribes to, and their artwork was
refused, so the page drew initials. ipx remembers the images the directory's
listings and pages carry and lets those through too; any other address is
still refused, so /api/art cannot be used to fetch arbitrary URLs.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A description with Markdown and no HTML tag is rendered to HTML with
pulldown-cmark, and an item with no text but RSS's <source:markdown>
(https://source.scripting.com/, as Scripting News sends beside its HTML) uses
that. HTML is left alone, and plain text without Markdown in it stays as it
was, so a line like "1984. ..." is not made a list. ammonia cleans the result
as any feed's HTML. Stored items keep what they have: the scanner does not
rewrite an item it has.
Reference: http://scripting.com/2026/10/06/005559.html
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A Claude Code hook formats each .rs file as it is edited, and the pre-commit
hook runs clippy as well as the format check. Clippy only checks, so with the
line-tables profile it takes under a minute (57 s after a change) at load 8.
rust-analyzer, which gave the same errors as clippy for 2.8 GB of memory, is
no longer used.
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>
Linking ipx's 431 MB test binary took Tower, 31 GB with no swap and 4 GB free
beside its containers, into page-fault thrashing for 54 minutes: load 160 with
the CPU idle and 130 processes blocked. Line tables for our crates and no debug
info for dependencies make it 109 MB, built in 2m14s with load at 20 and 6.5 GB
left. CLAUDE.md says to watch memory beside the load.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
cargo deny check found 10 RustSec advisories in Cargo.lock: ammonia
4.1.4 (three, XSS via SVG animation tags among them), rustls 0.23.44
(five), h2 0.4.15 (one) and ringbuf 0.4.8 (one). cargo update took ammonia
to 4.2.4, rustls to 0.23.46 and h2 to 0.4.20. ringbuf's fix is 0.5, which
librqbit 9.0.1 (the newest) cannot take; its double free needs an element
whose Drop panics and librqbit-utp's buffer holds u8, so deny.toml ignores
it with that reason.
deny.toml allows permissive licences only, crates.io only, and no
wildcard versions; ipx and ipx-directory say publish = false, as feed did.
cargo machete found crates/ipx still listing atom_syndication, quick-xml
and rss, which only crates/feed uses since parsing moved there.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
cargo clippy --workspace --all-targets had 24 warnings; its own fixes took
most, the rest by hand: two type aliases for long types, a struct built
whole, a checked division, a doc comment's stray blank line. Its fix for
the stale-feed fold was wrong: `any` stops at the first feed it removes,
where the fold removed every one, so that is a loop instead.
A pre-commit hook in .githooks refuses unformatted Rust; clippy, being a
build, is in CLAUDE.md's checks with -D warnings.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The code was formatted by hand, and `cargo fmt --check` differed in 866
places. rustfmt.toml keeps the house's long lines (120) and short
expressions on one line, which halves what changes; this commit is only
what rustfmt does with it, and is named in .git-blame-ignore-revs.
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>
Spotify's CloudFront serves some covers as application/octet-stream and
pippa.io sends no Content-Type at all; both are JPEGs, which browsers sniff
and show, but /api/art refused them with a 502 and the podcast showed its
initials. An untyped or octet-stream answer is now read and checked for
JPEG, PNG, GIF or WebP magic; anything else, SVG included, is still refused.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
With the server paying for everyone's Signals, its bill had no names.
ipx_signals_judged_total, ipx_signals_hits_total and ipx_jev_tokens_total
now carry user (the account's name) and key (server or own); a call
shared on the server's key is split by each person's share of its
questions. Icon picks bill the person on the server's key, key checks on
their own. The dashboard has tokens and items by person and a Who used
what table over its range.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
TypeSafe allows 80 requests and 100,000 input tokens a second. Six calls
in flight was a cap on concurrency, not rate: at 74 ms a call it is 81 a
second. Every call to Jev now takes a turn, a 60th of a second or its
guessed tokens' share of 80,000, so up to ten can be in flight. A 429
holds every call back for Retry-After and ends the pass. Each jev span
records paced_ms, /metrics ipx_jev_paced_seconds_total and a rate_limited
result, and the dashboard draws calls and tokens a second against the
limit.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Deployed, the judge's query still read every entry: an expression index
has no statistics until its table is analysed, and Postgres, guessing a
third of entries were recent, chose a scan (898 ms) over entries_recent.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The judge's query read all of entries (605 MB, 566 ms) on every wake; an
index on each feed's items by date, as the query asks, and the rechecks
in a half of their own. Calls ran one at a time, about 150 ms each, so a
new Signal's month of about 8,000 items took twenty minutes: six in flight
now. On the server's key everyone's Signals on an item share one call.
TypeSafe's token counts go to /metrics and each jev span.
An item's text is cut at 1,000 characters instead of 2,000: on 55 real
items of eight podcast feeds and six Signals, on the development key,
1,000 changed none of 330 verdicts (nor did the same request twice) and
saved 12% of the input tokens, 1,266 to 1,112 a call; 500 changed one.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
docs/signals.md says what the admin's setting does and that a key is
checked before it is saved; the install guide lists TYPESAFE_KEY; the API
reference has Signals' routes and types and the new fields of Me and
Settings; the home page says an admin may provide the key.
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>
The judge had spans but nothing in Prometheus: no count of items judged or
of how calls to Jev ended, nor whether the judge was still passing. The jev
span recorded an error field without the span's status, so Tempo's failed
traces left Jev out, and a timeout recorded nothing. Calls now send
traceparent like every other call out of ipx.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>