Skip to content

v2.3.2 - #849

Merged
ajslater merged 182 commits into
mainfrom
develop
Sep 13, 2026
Merged

v2.3.2#849
ajslater merged 182 commits into
mainfrom
develop

Conversation

@ajslater

Copy link
Copy Markdown
Owner
  • Fixes
    • The nightly maintenance run happened dozens of times a night instead of
      once.

ajslater and others added 30 commits July 3, 2026 16:19
The Profile dialog's self-service password change posted only
oldPassword + password to /api/v4/auth/password/change, but that
endpoint is rest_registration's ChangePasswordView whose serializer
requires password_confirm (camelCased passwordConfirm) — so the
request 400'd with "passwordConfirm field is required".

The dialog already collects and validates passwordConfirm; forward it
in the changePassword payload, matching change-password-dialog.vue and
the register/reset flows. Add a regression test asserting the field is
sent.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ect.any

The @vitest/eslint-plugin valid-expect rule misclassifies expect.any() as
chai's `.any` flag chain and reports "unknown modifier". Disable the rule on
the one nested assertion with a documented reason rather than weakening it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
comicbox 4.0.5 no longer applies the effort knob to Metron tagging, and
Metron's search is now a flat two-step (series_list + issues_list) that
match mode does not change.

- Remove the vestigial `effort` option (serializer, task, resume params,
  and test). It was collected by the API but never passed to comicbox's
  OnlineSession.
- Count estimate calls-per-comic per source: Metron a flat 2, Comic Vine
  keeps its per-mode 2/3/5. First-match-wins bills the costliest single
  source; merge sums per-source calls. Mirrored in the launcher dialog.
- Resume view drops unknown persisted params so a pre-upgrade `effort`
  key in the file-based cache can't crash the task rebuild.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Two facilities codex hand-synced from comicbox now consume it directly:

- Source names: KNOWN_SOURCES and the task/serializer/frontend default
  lists derive from comicbox's canonical SOURCE_NAMES tuple instead of
  repeating {"metron","comicvine"} literals in four places. The frontend
  gets it through the tagging choices JSON (build-choices), so a new
  comicbox source propagates without hand-editing every site.

- Issue-id parsing: the two byte-identical trailing-int regex copies
  (stored_id_prepass, explicit_id) collapse into one
  issue_id.parse_issue_id built on comicbox's canonical PARSE_COMICVINE_RE.
  It honors the real Comic Vine 4-digit long-key rule instead of grabbing
  any trailing int; an unrecognized key returns None, which safely falls
  back to search / rejects the id rather than guessing wrong.

No user-visible behavior change.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The "~N requests/comic" tail in the match-mode hints only describes Comic
Vine, whose calls scale with match mode; Metron is a flat two-step search
regardless of mode. Drop the tail from the base hints and append a
"~N Comic Vine requests/comic" suffix only when Comic Vine is an active
source, so a Metron-only run no longer shows a count that doesn't apply.
The number derives from the existing COMICVINE_CALLS_BY_MODE constant.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The poller's DiskSnapshot._walk() called os.scandir() with no guard
around the directory open, so a single permission-denied folder (e.g.
a Synology /comics/#recycle bin) raised PermissionError that propagated
up and killed the LibraryPollerThread, aborting the scan of every other
folder (issue #795).

- Wrap os.scandir so an unreadable/vanished directory is logged and
  skipped instead of aborting the whole poll, and widen the per-entry
  guard to cover entry.is_dir(), which can also raise PermissionError.
  This matches the os.walk default-onerror behavior the watcher relies
  on.
- Register the OS/NAS metadata basenames the filters module already
  documented but never populated (@eadir, #recycle, __MACOSX,
  Thumbs.db, desktop.ini), so the walker skips the recycle bin entirely
  and NAS/OS junk never enters the library.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
commit 9db6fe273622635700defb5c9015bf540630e40a
Merge: c8984b9ed ed38cb5
Author: AJ Slater <aj@slater.net>
Date:   Sat Jul 4 15:57:53 2026 -0700

    Merge branch 'develop' into online-estimate-consume-comicbox

commit c8984b9ede16f82f398501adb49c58d5428c168d
Merge: 2b2e63a35 cd84ed9
Author: AJ Slater <aj@slater.net>
Date:   Sat Jul 4 13:22:52 2026 -0700

    Merge branch 'develop' into online-estimate-consume-comicbox

commit 2b2e63a35013de0cd5593c8c5cadb360ffbd23ab
Author: AJ Slater <aj@slater.net>
Date:   Fri Jul 3 20:36:09 2026 -0700

    feat(onlinetag): consume comicbox 4.1.0 estimate; drop the codex copy

    Pin comicbox ~=4.1.0 and move the online-tag run estimate onto its
    comicbox.online_estimate.estimate_run() home:

    - estimate.py becomes a thin seam over comicbox: estimate_seconds()
      forwards to estimate_run().seconds and re-exports SOURCE_RATE_PER_MINUTE.
      The request/rate constants and math are deleted -- comicbox owns and
      tests them now.
    - The launcher dialog's per-source rates and per-comic request model derive
      from comicbox via a new tagging-estimate.json (choices/onlinetag.py,
      build-choices); only display labels stay in the component, so the JS
      estimate can no longer drift from the backend.
    - The codex estimate test slims to an adapter / re-export guard.

    Prep branch: the ~=4.1.0 pin does not resolve until comicbox 4.1.0 is
    published, so uv.lock is untouched and CI targets that shell out to `uv`
    will fail until then. Post-publish, run `uv lock`; the change was validated
    locally with the 4.1.0 modules installed into the venv.

    Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
AdminOnlineTagResumeView.post crossed radon's C threshold once the resume
descriptor sanitization landed. Move that logic (sources tuple coercion +
dropping keys no task field accepts) into a module-level helper; the view
falls to rank B and reads more directly. No behavior change.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
matchModeHint read an undefined COMICVINE_CALLS_BY_MODE, throwing a
ReferenceError on every launcher-dialog render (and failing
tests/unit/launcher-dialog.test.js). Point at the real
TAGGING_ESTIMATE.comicvineRequestsByMode map that callsForSource
already uses.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
An un-nested overlay is silently ignored by confuse.
…y failures

Answering a deferred prompt fetched the chosen issue against the path
serialized into the prompt at scan time. When an earlier write for the
same comic ran with rename enabled (the comic's other source's prompt,
or a stored-id prefetch), that path was stale and the apply died with an
uncaught FileNotFoundError — after the prompt was already consumed, so
the admin's pick vanished with no feedback.

- _apply_resolution now re-reads the comic's path from the DB by pk;
  a missing row reports to the Tagging error panel instead of fetching
  a dead path.
- fetch/replay failures (ComicboxError, OSError) and non-resolving
  explicit ids now land on the Tagging error panel instead of only the
  log, since the pick can no longer be re-prompted.
- stored-id prefetch and tag_by_id also catch OSError so a vanished
  file degrades gracefully.
- regression test for the COMICBOX_CONFIG general-section overlay
  (un-nested loglevel/delete_keys were silently ignored, letting
  comicfn2dict remainders like "(0000)" leak into rename targets).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(auth): native OIDC login via django-allauth

Codex becomes an OIDC Relying Party (Authentik/Authelia) with a
config-gated login flow:

- [auth.oidc] TOML section + CODEX_AUTH_OIDC_* env overrides
- allauth apps installed unconditionally; behavior gated on
  AUTH_OIDC_ENABLED (all OIDC paths 404 when off)
- CodexSocialAccountAdapter: username linking (superusers included,
  documented trust boundary), optional email linking, claim-chain
  username mapping with sub-hash collision suffix, groups-claim sync
  to existing Django groups, admin-group grant/revoke, error
  redirects to the SPA (never an allauth template)
- Branded throttled init endpoint /api/v4/auth/oidc/login; allauth
  login/callback mounted at /sso/ (outside the namespaced API tree so
  allauth's internal reverses work)
- RP-initiated logout URL via cached discovery document using the
  spec's client_id parameter (no stored tokens needed)
- /session payload gains public oidcEnabled/oidcProviderName/
  oidcLoginUrl and authenticated oidcLogoutUrl
- Profile username locks per-user when an OIDC identity is linked
- OIDC failures reuse the failed-login log line format for fail2ban

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(frontend): SSO login button, RP logout, and sso-error page

- auth store: oidc admin flags, loginSSO() full-page navigation,
  logout() follows oidcLogoutUrl for RP-initiated logout
- SsoLoginButton shared by the login dialog (with divider) and the
  unauthorized lock screen
- /auth/sso-error route + page mapping backend error codes to human
  messages, with retry hidden for non-retryable codes

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* docs(auth): OIDC setup guide + complete tinyauth forward-auth recipe

- README: native OIDC section (config table, redirect URI with prefix,
  Authentik/Authelia walkthroughs, identity-mapping and admin-linking
  trust warning, session-lifetime and OPDS caveats)
- README: full nginx auth_request recipe for tinyauth with header
  override hardening, Traefik/Caddy equivalents, and a forward-auth
  deployment checklist (OPDS + WebSocket gating, spoof test)
- schema test: allauth views stay out of the OpenAPI schema
- test typing fixes surfaced by basedpyright

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* update deps

* fix(settings): nest comicbox loglevel/delete_keys under general section

An un-nested overlay is silently ignored by confuse.

* fix(onlinetag): resolve prompts against current DB path, surface apply failures

Answering a deferred prompt fetched the chosen issue against the path
serialized into the prompt at scan time. When an earlier write for the
same comic ran with rename enabled (the comic's other source's prompt,
or a stored-id prefetch), that path was stale and the apply died with an
uncaught FileNotFoundError — after the prompt was already consumed, so
the admin's pick vanished with no feedback.

- _apply_resolution now re-reads the comic's path from the DB by pk;
  a missing row reports to the Tagging error panel instead of fetching
  a dead path.
- fetch/replay failures (ComicboxError, OSError) and non-resolving
  explicit ids now land on the Tagging error panel instead of only the
  log, since the pick can no longer be re-prompted.
- stored-id prefetch and tag_by_id also catch OSError so a vanished
  file degrades gracefully.
- regression test for the COMICBOX_CONFIG general-section overlay
  (un-nested loglevel/delete_keys were silently ignored, letting
  comicfn2dict remainders like "(0000)" leak into rename targets).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(auth): move OIDC config from codex.toml to the Admin UI Auth tab

OIDCSettings DB singleton (EmailSettings pattern) becomes the sole
config source, read at request time:

- OIDCSettings model + migration 0047 (seeds pk=1, one-time courtesy
  import of any pre-GUI [auth.oidc] TOML values); client_secret
  encrypted at rest via EncryptedCharField
- get_oidc_settings()/oidc_enabled() in settings.db; cachalot makes
  admin edits live on the next request, no restart
- codex/oidc.py rewired to request-time reads; new adapter
  list_apps override builds an unsaved SocialApp from the row
  (per-app settings['scope'] wins in allauth's get_scope), so
  disabled state keeps allauth's own DoesNotExist -> 404 gating
- RP-initiated logout and session flags read the row
- AdminOIDCSettingsView GET/PUT (write-only secret + clientSecretSet
  mirror, discovery-cache invalidation on save) and AdminOIDCTestView
  (discovery-document probe) at /api/v4/admin/oidc-settings[/test]
- New Admin UI Auth tab mirroring the Email tab: draft/dirty
  tracking, never-echoed secret with Clear Credential, redirect-URI
  display, Test Connection endpoint report
- [auth.oidc] TOML section and CODEX_AUTH_OIDC_* env overrides
  removed; README updated
- Tests now seed the DB row instead of patching module constants

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* format

* update deps and fix

* fix(admin): gate the OIDC enable switch on server URL + client ID

The Auth tab's Enable OIDC Login checkbox is disabled until a valid
server URL and a client ID are entered (it can always be unchecked so
clearing a field never strands the switch). The serializer enforces
the same invariant for API clients and partial updates that blank a
prerequisite while enabled — previously such a save produced a
silently inert enabled=true row.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(admin): Auth tab gains Account & Access flags, tinyauth note, name gate

- Move the Account & Access flag cards (Registration, Verify New User
  Email, Non-Users) from the Users tab to the Auth tab — they govern
  how people get in, which is that tab's subject
- Auth tab prose explains that forward-auth gateways like tinyauth are
  not OIDC providers and points them at Remote-User header auth, which
  coexists with OIDC
- Provider name joins server URL and client ID as an enable
  prerequisite, in the UI switch gate and the serializer invariant

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* style(admin): visually nest the OIDC subsections under their header

AdminSection gains a sub variant: a small uppercase overline title (h4,
$text-meta) and an indented left rule, with tighter sibling rhythm than
top-level sections. The Auth tab wraps the whole OIDC block — prose,
Identity Provider, User Mapping, Logout, and Test Connection — in one
parent 'OIDC Single Sign-On' AdminSection with the config groups as sub
sections, so their subordination to the OIDC header is unmistakable
next to the sibling Account & Access section. Documented in DESIGN.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* style(admin): collapse the OIDC section when OIDC is disabled

Most admins never configure OIDC, so the section body — prose, config
sub-sections, and Test Connection — hides behind an AdminExpandToggle
disclosure. It starts expanded only when OIDC is already enabled;
otherwise a one-line hint summarizes what's inside next to a Configure
toggle. The disclosure is initialized once from the saved state so
saving a disable doesn't slam the panel shut mid-edit.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* docs(admin): plain-English hints for PKCE and other OIDC jargon fields

PKCE, Client ID, Username Claim, and Groups Claim now carry hints an
admin who has never touched OIDC can act on — including what a claim
is and why PKCE should stay on.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* test(sso): authentik + tinyauth manual test harness in test-proxy/

Adds a docker-compose IdP stack and nginx wiring so SSO can be manually
verified before release:

- compose.yaml: authentik (OIDC provider, :9010) + tinyauth (forward
  auth, :3232), everything bound to localhost with throwaway creds
- authentik/blueprints/codex-test.yaml: auto-applied fixtures — readers
  and codex-admins groups, testuser/testadmin, and the codex-test OIDC
  client with callback URIs for proxied and direct, prefixed and bare
- forwardauth.conf: nginx :8081 gating Codex behind tinyauth
  auth_request with an overriding Remote-User header
- README.md: step-by-step test matrix covering native OIDC (login,
  group sync, admin mapping, RP logout, linking, error page, disabled
  404) and forward-auth (login, gating, spoof-proofing, coexistence)

tinyauth DB path pinned to the writable /data volume (workdir is
root-owned). test-proxy/ excluded from eslint: authentik !Find tags and
compose healthcheck arrays require flow-style YAML the yml plugin bans.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* test(sso): run the harness nginx as a compose service

nginx joins authentik + tinyauth in compose.yaml, so only Codex runs on
the host. The native bin/run-test-proxy.sh path still works — both share
server.conf/forwardauth.conf, with the sole native-vs-container
difference (backend addresses) isolated into named upstreams:

- upstreams-native.conf: localhost backends (host nginx)
- upstreams-docker.conf: host.docker.internal + tinyauth service name
- connection-upgrade.conf: the ws-upgrade map, now shared
- ssl-listen.conf / ssl-listen-none.conf: SSL/QUIC listeners split out so
  the container serves plain HTTP (native keeps the 8443 listeners)

The compose nginx mounts these into the stock image's conf.d and reaches
host Codex via host.docker.internal (extra_hosts host-gateway for Linux).

Also fixes a latent harness bug that would break OIDC through the proxy:
X-Forwarded-Host used $host (strips the port), so Django's
build_absolute_uri produced a portless redirect_uri that couldn't match
the registered callback. Now $http_host, port included.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* test(sso): fix tinyauth boot crash on localhost app URL

tinyauth v5 derives a cookie domain from its app URL at startup and
rejects single-label hosts and IPs ('invalid app url, must be at least
second level domain'), so http://localhost:3232 crash-looped. Move the
forward-auth path onto *.localtest.me (all subdomains resolve to
127.0.0.1 via public DNS, every browser, no /etc/hosts):

- tinyauth app url -> http://tinyauth.localtest.me:3232
- gated Codex      -> http://codex.localtest.me:8081
- shared cookie    -> .localtest.me (spans both)

The @tinyauth_login redirect and README Test 2 follow. OIDC/authentik
stay on localhost (no cross-host cookie needed there).

Also documents in README Troubleshooting that the harness publishes only
9010/8080/8081/3232 and never binds Vite's 5173 — a blocked HMR is a
stale vite process, and 8080/8081 clashes come from running native
make dev-reverse-proxy alongside the compose nginx.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* test(sso): set authentik provider grant_types; drop IPv6 host-gateway

Two issues from the first live OIDC run:

- 'Login with Authentik' failed with authentik logging 'Invalid
  grant_type for provider'. authentik 2026.x added an explicit
  grant_types model field that defaults to an EMPTY list, so a blueprint
  that omits it creates a provider allowing no grants and the authorize
  step returns invalid_request. Set grant_types: [authorization_code,
  refresh_token] on the provider.

- nginx logged 'connect() to [fd..::254]:9810 Network unreachable' then
  fell back to IPv4. The IPv6 came from extra_hosts host-gateway (a
  Docker Desktop IPv6 ULA gateway Granian doesn't listen on). Comment it
  out — Docker Desktop provides an IPv4 host.docker.internal built-in;
  Linux users uncomment it.

README troubleshooting covers both, including re-applying the blueprint
to an already-running authentik.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(auth): refresh public flags when OIDC is toggled or on logout

The 'Login with <provider>' button (adminFlags.oidcEnabled) went stale
after disabling OIDC: OIDCSettings is a singleton with no
admin.flags.changed websocket broadcast, and logout() left adminFlags
untouched, so the button lingered on the login screen until a manual
page reload.

- admin.updateOidcSettings now calls auth.loadAdminFlags after a save,
  resyncing the public OIDC flags immediately.
- auth.logout now re-fetches public flags (except when doing an
  RP-initiated full-page redirect, which reloads anyway), so the
  logged-out login screen always reflects current settings.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* update deps

* chore(lint): clear radon CC/MI and remark warnings

- codex/oidc.py: extract CodexSocialAccountAdapter._sync_admin from
  _sync_user (rank C -> B); keyword-only bool arg for FBT001.
- tests: split the 940-line test_onlinetag_session_manager (MI rank B,
  pre-existing on develop) — move the TagPassRunner and stored-id-map
  classes into test_onlinetag_tag_pass.py, importing the shared doubles
  from the session-manager module (as test_opds_schema already does).
  Both files now MI rank A.
- test-proxy/README.md: wrap the bare http://localhost autolink in <>
  and the [fd..::254] nginx error in backticks so remark-lint stops
  reading it as a link reference.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
simyan 3.0 removed the cache= constructor kwarg. The credential check
now passes cache_expiry=DO_NOT_CACHE with the cache/ratelimit sqlite
files in a throwaway temp dir, so validation always hits the network
(api_key is excluded from simyan's cache key) and leaves no files
behind. Also note comicbox 4.1.1's ComicVine improvements in the
v2.2.0 news.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
ajslater and others added 29 commits September 2, 2026 00:21
d88f4f1 split the "User Settings" table into "Settings by Session" and
"Settings by User", but the spec kept its own copy of the section titles and
still asserted the old name, so it failed.

Rather than retype the new names into the test, the tab now declares its
tables once as a SECTIONS list the template loops over, and exports the
titles. The spec compares the rendered captions to that export, so a rename
cannot leave a stale copy behind again.

The assertion is the whole ordered caption list rather than a containment
check per title. With the titles derived, containment would pass vacuously;
comparing the list still catches a section that stops rendering, one rendered
twice, or an empty title.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
* Unclip the settings button's librarian progress ring

Vuetify 4.2.0 added `overflow: hidden` to `.v-btn` (fix(variant) #22992),
which makes the button both the containing block and the clip box for the
admin overlays inside it. The 32px progress ring does not fit the 28px
compact icon button, so on xs it lost its left and bottom arcs; the
safe-area padding clips it on notched phones in landscape too.

Restore the pre-4.2 behavior for this one button. The scoped id selector is
unlayered so it wins without `!important`, and nothing escapes that the
Vuetify clip was meant to contain: `variant="plain"` hides the button
overlay, the ripple container clips itself, and inside `v-toolbar-items` the
button has no border radius.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Keep the filter menu's extra rows keyboard reachable

Vuetify 4.2.0 moved select keyboard navigation into `useScrolling`, whose
list keydown capture wraps from either end of the item rows and calls
`stopImmediatePropagation()`. That pre-empts VList's own focus walk, which
was what used to carry the user from the bookmark rows into the rows this
menu contributes through the prepend/append slots, so "Clear All Filters",
"Favorites Only" and the filter sub-menus became mouse-only.

A capture listener on the overlay content runs before the list's, so
stepping off either end of the bookmark rows now lands on the adjacent slot
row instead of wrapping. Every other key and row is left alone and Vuetify
still owns the navigation. The neighbour is found by walking the content's
focusable rows rather than by naming the slot rows, so it follows the rows
that are actually rendered: logged out, there is no "Favorites Only" and the
step lands on the first filter sub-menu.

Also cover the other 4.2 delta in this menu: `closeOnSelect()` now bails
when `menuProps` carries `closeOnContentClick: false`, which leaves
`onSubMenuSelected` as the only thing closing the menu after a pick.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Drop the book change drawer's conditional width

The drawer is 33vw rather than Vuetify's 256px default. Vuetify 4.1 parked
an inactive layout item at `translateX(-(width prop + 1)px)`, computed from
the prop and not the rendered box, so a 33vw drawer stayed partly on screen
when closed. 24f8d1b worked around that by applying the width only while
the drawer was open, which also made closing shrink it from 33vw to 256px
mid-slide.

Vuetify 4.2.0 parks it at `calc(+/-100% +/- 1px)` of its own rendered box,
so the width can be unconditional and the close is a plain slide. Tests pin
the offscreen transform that makes this safe.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
The vertical pager hands `v-virtual-scroll` a list of page numbers and
reverses that list for bottom-to-top reading, so page 0 becomes the last
item. `scrollToIndex` takes an index into that list, but `scrollToPage`
passed the page number straight through, so every jump landed on the
mirrored page: opening a comic at page 0 scrolled to the last page.

Look the page up in `items` rather than repeating the reversal, so this
keeps following however that list is built. Top to Bottom is unaffected,
where a page number is already its own index; a test pins that too.

Page tracking was never wrong. The intersection observer reads the real page
number off the element, so only jumping to a page was affected.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
* fix(comicbox): adopt comicbox 5.0.0's moved and renamed APIs

Installing comicbox 5.0.0 broke 27 test modules at import: MatchMode and
its neighbours moved to comicbox.config.online, WriteMode became
MergeMode, and ID_URL_KEY and NUMBER_TO_KEY are gone. Those cascade
through the URL conf, so almost nothing ran.

Identifiers no longer carry a url. Comicbox derives links from the key
rather than storing a copy that could disagree with it, so codex derives
its Identifier.url column the same way, through comicbox's own helpers.
The bridge between the two type vocabularies -- codex names a type after
the table it points at, comicbox after the thing itself -- is the
IdentifierType enum's own member names, so neither side can drift.

The online cache directory travelled as COMICBOX_ONLINE_CACHE_DIR, a name
comicbox 5.0 no longer reads. It now rides a settings object handed to
each session, which is not a name comicbox is free to change. The online
settings deliberately carry no delete_keys: that set is the read side's
parse-skip list, and online tagging writes what a database returns
straight back to the archive.

The fixtures become v3.0 documents. Two of their entries were malformed
in a way 4.8.7 dropped silently and 5.0 now names in a warning: a null
team voided the whole teams field, and a credit role's identifier was
keyed by "key" instead of by its source. Both are repaired, which
restores the coverage they were quietly costing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* feat(identifiers): file an identifier under the type it states

A comicbox 5 identifier names its type whenever that type isn't the one
its position implies, so a comic can carry an id for its series or its
volume among its own. Codex filed every comic-level identifier as an
issue id and built the wrong url for the ones that weren't.

Codex and comicbox name these types differently -- codex after the table
the id points at, comicbox after the thing itself -- and the bridge is
derived from the IdentifierType member names rather than written out a
second time. A drift guard holds the two vocabularies together, and it
found the first gap immediately: comicbox has a volume type codex had no
member for. Migration 0054 adds it.

The tag editor now speaks comicbox's names throughout. It used to offer
codex's, glue the type onto the front of the key, and send an always
empty url, while the add-by-URL endpoint beside it returned comicbox's
names -- so a URL-added identifier landed on a type its own select could
not display.

A reprint keeps the name the file gave it, which is what stands in when
comicbox parsed no series out of that name. MetronInfo's alternative
names, the reason the old series sort_name fallback existed, no longer
arrive as reprints at all.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* feat(metadata): store the facts comicbox 5 separates out

Comicbox 5 reports several things codex had no column for, and the
importer's field whitelist is derived from comicbox's own schema, so
every one of them was being skipped in silence.

manga and reading direction are now two facts rather than ComicInfo's
one compound YesAndRightToLeft value, and each gets a column and an
editor field. MetronInfo's MangaVolume gets one too.

A comic's web links used to ride along inside its identifiers. An
identifier is now only a key -- codex derives its link from that key
rather than storing a copy that could disagree -- so the links the file
itself wrote down needed somewhere else to live.

The primary credit flag moved onto each role, which is the point of it:
the primary writer is not thereby also the primary inker. Codex stores
it on the person-and-role pairing, and since credit rows are shared
between comics, the flag has to be part of what identifies one.

The series' other names are no longer reprints upstream, but they still
mean what a reprint means -- another edition of this book -- so they
keep sharing the table, flagged. The flag is what the editor consults to
put each row back in the list it came from, and it splits the metadata
panel's one row into the two things a reader is actually looking at.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* feat(onlinetag): spend API budget by effort, and price the run by it

Comicbox 5 bounds Comic Vine's per-candidate fan-out by default and
takes an effort setting to widen or narrow it, which codex had no way to
reach: effort is not an OnlineSession keyword, it lives in the settings
tree the session layers over. Codex now hands those settings across, so
the same channel carries the cache directory and the scan's effort.

The run estimate moves onto that axis too. It was keyed on match mode,
which changes no request count at all -- match mode decides how a verdict
is applied once the calls are already spent. The default projection
roughly triples as a result. That is the estimate becoming honest about
a cold search, not the run getting slower: a real scan batches by series,
so one search answers for a whole series and beats the projection.

Two identifier workarounds come out, now that comicbox files both cases
correctly. A series' other names state that their ids name a series, so
codex stops injecting that. A reprint's id is the reprinted issue's id,
so it resolves to an issue page instead of to nothing.

The sources strip learns that Comic Vine meters per endpoint pool rather
than per account. It shows the pool with the least left, since that is
the one that will stop the run, and says which window the number covers.

A prompt is replayed by handing its fingerprint back to comicbox, and
comicbox 5 changed what goes into one, so prompts stored by comicbox 4
can never match again. They are dropped at daemon start and nightly
rather than by a migration: the tagging cache lives under the config
directory, where a test run's migrations would reach real prompts.

Comic Vine credential validation starts working. Simyan 3 returned a
rejected key's error body verbatim, so the empty result read as success;
simyan 4 raises, which the existing handler already caught.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* feat(migrations): ask for one full re-read under comicbox 5

Comicbox 5 changes what existing files mean without changing the files:
age ratings collapse to MetronInfo's seven names, credit roles
canonicalize, ComicInfo's AlternateSeries reads as a story arc, a manga
tag now also sets a reading direction, and a stated title stops being
rebuilt from the story list.

Codex re-reads a comic only when its file changed, so left alone a
library would hold both readings at once -- the old one everywhere, the
new one on whatever happened to be re-tagged -- and its browser facets
would list Everyone beside Everyone 10+ as though they were different
things.

Clearing the stored stat is how the re-read is asked for: the poller
reads a missing stat as "stat the file and call it modified", and the
importer's prefilter skips only on a stat that matches or an
unadvanced metadata mtime. It is resumable by construction, since a
comic gets its stat back only once it has been imported.

The import marker is deliberately left alone. It drives the "not
imported" hint, and clearing it would blank that hint library-wide for
the length of the re-read, which reads as data loss rather than as work
in progress.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs(news): record the comicbox 5 work under the unreleased 2.3.0

Folded into 2.3.0 rather than opening a 2.4.0: 2.3.0 has never been
tagged, so there is no release anyone has seen for this to come after.

The Dev section carries the two things an upgrading admin has to know
before they see them happen -- every comic is read again on the first
scan, and saved tagging prompts are discarded -- along with what the
re-read will visibly change about their tags.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* add script

* refactor(migrations): one migration, and no forced re-read

Squashes the three comicbox 5 migrations into 0054, and drops the one
that asked for a full re-read.

Re-reading a library is an admin's call, not an upgrade's. The new
columns can only be filled from the files, so filling them means opening
every archive, and doing that unprompted on the first scan after an
upgrade spends hours of someone's disk on a decision they were never
asked about. Force Update Tags already does it, on whatever scope the
admin picks, and the release notes now say so.

The cost of not re-reading is a library that reads its tags two ways at
once: age ratings and credit roles keep comicbox 4's spellings until a
comic is next read, so a filter can offer both Everyone 10+ and
Everyone. That is untidy rather than harmful -- age-restricted access
derives from the Metron scale, which both spellings still map onto
identically.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(importer): key a credit that names no role

A file may credit someone without saying what they did, and comicbox
passes that through as a person with an empty role list. Codex built a
key with only the person in it, and a short key is padded with nulls on
its way to the database. That was survivable while the padded column was
the role, which is nullable. The primary flag is not, so the first
role-less credit in a library aborted the whole import with an
IntegrityError -- taking every other comic in the batch with it.

Such a credit now keys as (person, no role, not primary), which is what
it means: there is no role for the person to be the primary holder of.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* update deps

* refactor(migrations): fold the comicbox 5 columns into 0053

0053 is unreleased too -- v2.2.11 ships 0052 -- so everything here lands
in v2.3.0 together and a database has no reason to walk through it in
two steps. Renamed to cover what it now does.

This is the same fold as "Combine the two unreleased migrations into
0053", one release later.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* feat(telemeter): count the comicbox 5 tags, and show them honestly

Comicbox 5 gave codex several new facts about a library and nothing counted
any of them. The stats report now says how many comics are manga, carry a
manga volume or carry web links, how many credits mark a primary holder, and
how many reprints are a series' other names. It also reports the Effort
default, which decides how much API budget a scan spends per comic.

Counts only, and every key is a name fixed in this source. manga is three
scalar counts rather than a bucket for exactly that reason: the payload's
shape then cannot be invented by a value read out of a comic file, and a new
comicbox manga value fails the serializer-registration test instead of quietly
becoming a new key. Anything outside the vocabulary is folded into unknown, so
the three still partition the library. Never the volume string, never the
links, never a person or a role.

Two of the columns needed their own counter. The existing one asks whether a
column is NULL, and manga_volume and urls are NOT NULL with empty defaults, so
it would have reported the whole library for both. There is a regression test
for that, and the privacy suite now seeds a comic carrying both columns --
without one, the sentinel walk could not tell a count of them from a report of
them.

None of these can be backfilled, since they can only be filled by reading a
file, so they all read zero until an install re-reads. That is in the release
notes rather than left to be discovered.

Then the Admin Stats tab, which is the visible contract for what leaves an
install, and which had been misreporting it. The whole Settings by User table
was empty: it read stats.per_user while the API renders the payload camelCased
and ships perUser, so the only telemeter addition since 2.2.11 was invisible
on the one page that exists to disclose it. Order By read "sortName" and Fit
To read "W", because the labels were looked up with a case conversion applied
to keys the choices files already emit camelCased. And four rows that detail
the row above them rendered flat, because the indent set held plural spellings
that matched no payload key. Boolean buckets read the wire's "true" and
"false"; they now say On and Off, the same words chronicle's dashboard uses
for those numbers.

The tab's test fixture is now camelCased like the wire it stands in for, which
is what let all three of those pass, and a new assertion requires every
populated section to render at least one row.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* trim news

* refactor(onlinetag): follow comicbox 5's OnlineSession rename

comicbox 5 renames the session facade's mode= to match= and
unattended= to prompts=, so the public API says what the CLI and the
config keys have always said (ajslater/comicbox#205).

Codex only ever passed mode=, so that is all that moves here, at both
construction sites, plus the DeferredPrompt attribute the prompt
serializer reads. The serialized prompt's own "mode" key stays: it is
codex's cache format, versioned by PROMPT_VERSION, and renaming it
would strand every prompt already persisted.

Nothing else in the rename reaches codex. It never used unattended=,
never called set_mode/set_unattended, and the only PromptResponse it
builds is a plain "skip" — so the set_unattended -> set_prompts action
rename, the one break that fails quietly rather than loudly, has no
call site here.

FakeDP carries .match now. The fake standing in for a comicbox object
has to track the real attribute name or it hides exactly this kind of
break.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* update deps

* fix(covers): serve conditional GETs instead of caching response bodies

The per-pk cover routes carried `cache_page`, which stored a copy of every
cover response in the default cache. Because the page-cache key hashes the
Vary header values, and these routes vary on Cookie (plus Authorization for
OPDS), that meant one stored copy per user of bytes that already exist once
on disk as a webp. Session-cookie rotation orphaned entries, a 100-cover page
could populate 200+ of them, and a single pk could never be invalidated --
only a clear() of the whole cache, which import finish, tag writes, and
Library/Group CRUD all do. Covers were collateral on every scan.

Serve ETag and Last-Modified derived from the thumb's size and mtime instead.
A revalidating client gets a 304 answered from the stat alone, without the
image bytes being read, and a regenerated cover invalidates itself.

Also:

- /api/v4/covers/<source>/<pk>, the route the web client uses for every cover
  on a browse page, had neither Cache-Control nor Vary. It now has both; the
  ?ts=<mtime> cache-buster already made it safe to cache.
- Comic covers are private rather than public. They are ACL-gated, so a shared
  proxy must not store them. Custom covers have no ACL and stay public.
- Cover and reader-page views are exempt from throttling. `cache_page` used to
  short-circuit before DRF dispatched, so cover hits never reached the
  throttles; without it a browse page would spend 100 requests against a
  configured throttle.user rate for one user action.
- BROWSER_TIMEOUT and READER_TIMEOUT are removed. No browser or reader route
  has ever been cache_page'd, so the MAX_ENTRIES comment was partly justifying
  a cache population that does not exist.

Adds tests/test_cover_views.py, which had no predecessor -- the cover
endpoints were previously untested.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(onlinetag): let comicbox spare a large scan, and add a way back up

Two things, found checking codex against the comicbox 5.0.0 release.

Effort now defaults to Auto rather than Balanced. Comicbox reads any
named effort as a decision to leave alone, and only while none is named
is it free to drop a large unattended scan to minimal -- the protection
it added precisely because balanced fans out enough Comic Vine calls to
blow past the hourly cap and stretch a big library into hours. Codex
named balanced on every scan, so nobody ever got that. Naming one is
still available and still honored; it just isn't the default.

The release also renamed the session's mode= to match= and unattended=
to prompts=. Production code already followed that; one test fake had
not.

Separately: a group whose parents have gone missing yields a single
breadcrumb, which is the current view and so no link at all, leaving a
deep route with nothing to climb and no way out but the back button. It
now offers the top, which is the one destination always there.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(reader): close a book to a route that exists

The close button's last-resort fallback read a key the browser defaults
do not have, so the one branch meant to guarantee the reader stays
closable would have thrown a TypeError and taken the render down with
it. The defaults call that route lastRoute, which is what the router
itself already falls back to.

Unreachable today: the server always injects a last route, its own
fallback being the admin default. It was a landmine rather than a live
crash, and nothing exercised it, so a test now covers both the ordinary
close and the fallback.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Stop leaking a DB connection per ASGI request, and pool the workers that hold them (#838)

* fix(db): stop leaking a database connection per ASGI request

Every DB-touching request orphaned an open SQLite connection until the
garbage collector emitted "ResourceWarning: unclosed database". A page
of covers leaked dozens, three file descriptors apiece under WAL, which
is what codex/run.py's RLIMIT_NOFILE bump was papering over.

Django's ASGIHandler wraps each request in an asgiref
ThreadSensitiveContext, which is backed by a private one-thread executor
that is shut down when the request ends. django.db.connections is
thread-local, so CONN_MAX_AGE=600 kept a connection alive on a thread
about to be retired: never reusable, never closed. Django documents this
("When using ASGI, persistent connections should be disabled") and
closed #33497 as documentation only, so this is by design, not a bug.

Take the documented configuration: DATABASES leaves CONN_MAX_AGE at
Django's default of 0, and the processes whose threads actually persist
opt back in for themselves. The librarian does so as the first statement
of its run loop, before it spawns the workers that reuse a connection
across tasks.

That covers every path Django controls, but not one it doesn't:
send_response runs inside handle()'s TaskGroup, and if it raises, the
exception group is re-raised before either request_finished or
response.close(). A streaming body is consumed there, outside the
middleware chain, so an iterator that raises is never converted to a
500 -- and codex streams comic archives off a user's library, where the
librarian can move a file mid-download. ConnectionClosingApplication
stays as the backstop for that exit, now documented as such.

Two more connections nothing was closing: the admin restore endpoint's
sidecar sqlite handle, and the admin stats view's off-loop query, which
runs on an event-loop executor thread no request lifecycle cleans up.

Also corrects six comments estimating the reconnect cost at "~5-20 ms".
It measures ~1 ms, of which the PRAGMAs everyone worried about are
~0.01 ms; ~90% is SQLite re-parsing the schema.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* perf(db): serve requests on pooled workers that keep a warm connection

Django gives every ASGI request a private worker thread and retires it
at the end of the request, so each DB-touching request opened a fresh
SQLite connection. Measured on this codebase that is ~0.76 ms, of which
~90% is SQLite re-parsing the schema (84 tables + 266 indexes); with
per-connection statement re-preparation a five-query request pays
~1.26 ms, 53% of its database time. It also means cache_size=64MB and
the 256MB mmap window can never warm on the HTTP path, and that a cold
cover grid opens ~100 connections at three file descriptors each.

Django's answer to this is pooling, but its pool is PostgreSQL-only and
a SQLite equivalent is unsafe: a handle returned with an unexhausted
cursor pins a WAL read snapshot that rollback() does not release, so the
next borrower reads stale rows and its first write fails "database is
locked". Pool the threads instead.

WorkerPool owns a fixed set of ThreadSensitiveContext objects that are
never entered, pre-seeds each with its own one-thread executor, and
lends one to each request. asgiref's context manager is re-entrant, so
Django's inner "async with" is a no-op and the worker -- with its
thread-local connection -- outlives the request. That restores the
stable-thread model CONN_MAX_AGE was written for, so the pool opts this
process into persistent connections and Django's own request_started /
request_finished hygiene recycles them exactly as under WSGI. Nine
requests that opened six connections now open one.

The pool reaches into two of asgiref's class attributes, which have been
stable since 3.3.2 but are not a documented contract. So it checks them
at startup rather than trusting them: if thread-sensitive work no longer
lands on the seeded worker, the pool logs and disables itself, and every
request takes the per-request path from the previous commit. asgiref is
now a direct dependency so a major bump is a conscious upgrade, and a
test pins the behavior so it fails loudly rather than silently
degrading.

Requests that would hold a worker for a client-speed transfer bypass the
pool: the file downloads, and the health check, whose whole job is to
answer even when the pool is saturated.

Size it with db_workers under [server]; 0 restores per-request
connections. Each worker can grow its own page cache, so lower it on a
memory-constrained host.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
typecheck (basedpyright), 3 warnings -> 0:
- workers.py, librariand.py: build log messages before the call, since
  ruff's ISC003 rejects the explicit `+` form.
- test_asgi_db_connections.py: mark SlowOrmApplication._query @OverRide.

ty, 6 warnings -> 0. All were dead conditions; one hid a real bug:
- search/sync.py: _update_search_index_clean was typed `-> None` and
  discarded the count from remove_stale_records, so `elif cleaned_count:`
  never fired and the search index summary never reported cleaned
  entries. Return the count (0 on the rebuild path, reported separately).
- crond.py: `not self._task_times[0]` on a 2-tuple.
- search/parse.py: `if match else` on a finditer result.
- opds metadata/publications/manifest: `not OPDS_M2M_MODELS` guards
  against a 7-element module constant.

complexity (radon cc), 3 C-rank functions -> A/B:
- _build_browser_defaults C(11) -> B(6): extract _rename_table_columns,
  fold the repeated `row[x] or ""` / `bool(row[x])` lines into column
  tuples, and reuse _rename_sort_key for order_by.
- test_new_sections_have_content -> A(5): assert list becomes a table.
- test_facet_ua_gets_facet_links_no_fake_entries -> A(3): body extracted
  to _assert_facet_feed, three-way title check becomes one regex.

Both restructured tests were mutation-checked to confirm they still fail
when their invariant breaks. make fix/lint clean, 1094 tests pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…tatus, live status rail (#840)

* Default Auto Update on outside Docker

Installing a new codex over the running one is what a pip, pipx or uv
install wants, so Auto Update now seeds on there. A container is an
immutable deployment — an in-place upgrade only lives until the next
restart brings the image's version back — so docker keeps seeding it
off and updates by image pull.

Seeding is still get_or_create, so an existing install (and an admin
who turned the flag off) is untouched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Report a source that ran and found nothing as No match

Comicbox emits a terminal event only for the resolutions its matcher
reaches. A search that comes back with zero candidates — routine for
Metron, whose database is much smaller than Comic Vine's — ends that
source's turn with a log line and no event at all, as does a search that
raises and a comic with nothing to search on. The status table had no
cell to render and fell back to an em-dash, which reads as "this source
was never consulted": the one thing that had not happened.

FileFinished now closes the comic. Every source that announced itself
with SourceStarted (emitted after comicbox's first-wins skip, so a source
that sat out never appears) and reported nothing settles as no_match, and
an empty cell again means only what it looks like.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Move the status rail in step with the online lookup

The admin drawer's rail renders the LibrarianStatus row, which an online
tagging scan only touched when a comic *completed* — a minute of network
per comic during which the rail sat still while the Tagging tab's table,
fed by its own snapshot notification, plainly showed work happening.

The live-lookup marker now names the source it is consulting in the
status subtitle, so the rail gets the same heartbeat, paced by the live
publish's existing one-second floor.

Two supporting fixes in StatusController:

- update() takes force, for a state change rather than a progress tick.
  Dropping one of those into the five-second coalescing window loses it
  instead of deferring it, since it *is* the whole update. The rate-limit
  path was already doing this by reaching in and backdating the status's
  own clock; it asks now.
- The subtitle is written on every update, including an empty one. It was
  skipped as "nothing to write", so a phase's subtitle outlived it: a
  recovered rate limit kept describing the task as rate limited, and the
  importer's own subtitle clear never took.

Also drops a verbatim duplicate of tagWriteErrorsNotified in the socket
store.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Both of the previous PR's fixes hooked the event-emitting search session.
Neither one fires for a re-tag, because comics codex already holds an
issue id for never reach that session: the stored-id prepass fetches them
by explicit id outside it, and on a re-tag that prepass is the whole run.

Two consequences, both visible in the admin Tagging tab:

- A merged multi-source refresh was credited to the primary source alone.
  Under merge-all the prepass pins every stored id and comicbox fetches
  and merges them all, so a comic refreshed from Metron *and* Comic Vine
  showed Matched in one column and an em-dash in the other — the cell that
  reads as "this source was never consulted". Each source whose requested
  id comes back in the merged record is now credited; the primary is
  proven by the fetch itself, which returns nothing unless its own id
  landed. A source the scan never pinned (first-wins, or no credentials)
  still reports nothing, which is what the em-dash is for.

- Only the search pass opened a librarian status row, so a scan that
  resolved every comic from a stored id ran start to finish with an empty
  status rail, while the Tagging tab's table — published from the same
  loop — showed each lookup as it happened. The prepass now opens the row,
  totalled over the whole batch, advances it per comic, and the search
  pass adopts it instead of starting a second one, so the rail shows one
  continuous job whose elapsed time counts the prepass. The live marker's
  "looking up on <source>" subtitle lands there too, which is what it was
  missing to reach the rail at all.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
mkdocs refuses a site_dir inside docs_dir, so `make docs` has aborted
with a configuration error since the target was written. Drop
`--site-dir docs/site` and use mkdocs' default `site/` at the repo
root, which every Python tool in pyproject.toml already excludes.
Ignore `site/` in git, the Docker build context, and ESLint so a local
docs build can't leak into commits, images, or `make lint`.

readthedocs is unaffected: it passes its own --site-dir.

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
…to ghcr.io (#844)

* Republish a deprecated Docker Hub mirror and warn its admins to move to ghcr.io

Most installs reporting to chronicle are stuck on the 1.9.24 Docker Hub image:
the Hub repo has no tags, and 1.9.24 never shows an outdated notice. Give
those installs a pull that works and a loud reason to switch.

- hub.Dockerfile: FROM the ghcr.io image plus one ENV and a deprecated label.
  Same layers, only the config blob differs; no RUN steps so no QEMU.
- ci.yml deploy-hub job: builds and pushes docker.io/ajslater/codex:<v> and
  :latest for final versions, gated on DOCKERHUB_TOKEN so forks skip it.
- DOCKER_IMAGE_DEPRECATED is now a boolean exposed as docker_hub in
  /api/v4/version and the session payload (the free-text warning is gone),
  and as platform.docker_hub in telemetry.
- Admin-only top snackbar telling admins to change the image, dismissible
  for a day per browser, plus a permanent link in the settings footer.
  ghcr.io and native installs render nothing.
- docs/DOCKER.md gains a "Migrating from Docker Hub" section the UI links to.
- bin/lint-docker.sh and bin/fix-docker.sh now lint every top-level
  Dockerfile instead of only the first one found.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* Use the existing DOCKER_USER / DOCKER_PASS secrets for the Docker Hub push

The repo already holds these two secrets from the v1.10 registry switch and
bin/docker-tag-latest.sh logs in with the same names, so the deploy-hub job
should not ask for a second pair.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
Every snackbar rendered on the app background instead of its own color.
``.v-snackbar__wrapper`` is a ``.v-overlay__content``, and app.vue painted
all of those the theme background with ``!important`` from outside any
cascade layer. Vuetify's ``bg-error`` / ``bg-warning`` classes live inside
``vuetify-utilities.theme-background``, so they never stood a chance: an
unlayered declaration outranks a layered one whatever the specificity.

Split the rule. The radius still applies to every overlay; the background
now skips snackbar wrappers, so ``color`` reaches the element it is meant
to paint. Menus, dialogs and tooltips keep the background they had.

Give VSnackbar a default of ``color: "background"`` as well. Without it an
uncolored snackbar would fall back to Vuetify's Material inverse surface,
a light bar in this dark theme, and to unreadable dark-on-dark text. The
default keeps today's look and explicit colors still win.

The docker-hub snackbar no longer needs its local two-class override, so
drop that and the marker class it existed for.

Verified in the dev server: session-error paints rgb(220,20,60),
filter-warning and docker-hub rgb(230,189,13), an uncolored snackbar
rgb(18,18,18); menu, dialog and tooltip content boxes are unchanged at
rgb(18,18,18) with the 5px radius and the tooltip's dimmed text. The
production bundle keeps the layer order and leaves the app rules
unlayered.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
…mage (#846)

* Warn the logs, not just the web UI, about the deprecated Docker Hub image

An admin who runs codex headless and never opens the settings drawer had
no way to learn that their image had stopped being the real one. Say it
where they are actually looking.

The warning names the registry to move to and links to the migration
docs, and only the Docker Hub image ever emits it. It fires at startup
beside the other lines about how this install is configured, and once a
day from the janitor's version check rather than from a new job of its
own. That call sits inside the fetch gate: while the version cache is
empty every API hit queues a check, and only the one that wins the lock
should nag.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* update deps

* more explicit simyan version

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
basedpyright no longer reports decorated pytest fixtures as unused, so
the two reportUnusedFunction suppressions on the autouse fixtures were
themselves flagged as unnecessary. Drop them.

Split OnlineTagOutcomeStats._record_source_status (radon C, 11) along
the seam it already had: the two lifecycle events that bracket a
source's turn stay put, and the terminal verdicts move to
_record_source_verdict, which now names the status once per event kind
and makes the single _set_source_status call at the end. The
SourceStarted body becomes _add_started_source, mirroring the existing
_add_matched_source. The event classes are disjoint siblings, so case
order carries no meaning and the dispatch is unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The cron loop asked for a timeout truncated to whole seconds, so every
wait ended up to a second before the slot it waited for. It queued the
job anyway, then recomputed a schedule off the same short clock, which
handed back the slot just run: a timeout of zero, a wait that returns at
once, and the whole nightly fan-out queued again on every pass until the
clock caught up. Roughly thirty to fifty runs of the search-index
optimize, vacuum, database optimize, backup and user-data snapshot every
night. The weekly telemeter send shared the window.

Wait out the real remainder instead, and run a job only once the clock
has reached its slot. An early wake now costs one more trip around the
loop rather than a night's work.

Also compute the nightly slot as tomorrow's calendar date rather than
now plus twenty four hours. On the night daylight saving time ends the
local day is 25 hours long, so the hour after midnight resolved to the
midnight that had just gone by, which would have spun for that whole
hour.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
@ajslater
ajslater merged commit 7c4322a into main Sep 13, 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