Skip to content

v2.5.6 - #995

Merged
ajslater merged 406 commits into
mainfrom
develop
Oct 22, 2026
Merged

v2.5.6#995
ajslater merged 406 commits into
mainfrom
develop

Conversation

@ajslater

Copy link
Copy Markdown
Owner
  • Features
    • Filter, search, view, edit, and online tag by series years, series status,
      and series summary. End year, status and summary are saved in Codex only,
      not comic metadata.
    • Browser title shows series years and a Tags button.
    • Online tag match picker shows series and issue details to help
      disambiguate similar covers and titles.
  • Fixes
    • Browsing comics without embedded tags, or re-importing unchanged comics,
      no longer rebuilds their covers and reloads the page.
    • Searches for a universe designation no longer miss imported comics.

ajslater and others added 30 commits September 2, 2026 15:16
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>
* Adapt to comicbox 5.1 and @vueuse/core 15

comicbox 5.1 paces its own requests through a process-wide rate gate and,
near the end of the day's API quota, stops starting new searches — then
aborts outright at zero. The abort cancels the session, so every
remaining comic streams back cancelled. Codex would have marched through
those, counted a library as looked-up in seconds, and frozen a finished
snapshot over comics nobody searched for.

- A run comicbox stops now pauses the way an operator's Pause does: it
  stops at the first cancelled result, keeps the remainder resumable,
  and the Tagging tab says why, so Resume isn't an unexplained button on
  a scan nobody stopped. Comics the quota reserve declined stay queued
  rather than being recorded as misses (comicbox 5.1.1's
  SKIP_QUOTA_RESERVED is what makes those distinguishable).
- `client_name="codex"` reaches the outgoing User-Agent. Metron's
  operators could not tell a background tagging server from a CLI thread
  pool, which is the question comicbox#207 could not answer.
- The session logs its API cost per source: requests by endpoint,
  rejections, seconds paced, budget remaining. Real HTTP sends, so the
  numbers match the server's own logs.
- The status strip reads the burst limit the server reported instead of
  the documented default, which a donor tier or self-hosted Metron
  changes.

@vueuse/core 15 flipped `useThrottleFn`'s `trailing` default to true.
The vertical reader's scroll handler wanted that all along — a fling
ending inside a throttle window never delivered the final scrollTop, so
the end-of-book check missed the scroll that reached the end. Passed
explicitly so a future flip can't take it back.

Requires the unpublished comicbox 5.1.1; uv.lock needs regenerating once
it is on PyPI.

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

* update deps

* remove explanitory sentences from news items. claude was ignoring my news rules

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
ajslater and others added 29 commits October 18, 2026 20:06
A user behind a reverse proxy got CSRF 403s and worked around them by
removing CsrfViewMiddleware. That is unsafe and doesn't even work for the
API: DRF's SessionAuthentication enforces CSRF itself. Codex never set
Django's CSRF_TRUSTED_ORIGINS, so a proxy that serves Codex under another
scheme or host (usually one missing X-Forwarded-Proto) had no fix.

[server] csrf_trusted_origins (TOML array) or CODEX_CSRF_TRUSTED_ORIGINS
(comma-separated) now feeds CSRF_TRUSTED_ORIGINS. Entries lose a trailing
slash and are deduped; a scheme-less entry, which could never match an
Origin header, is dropped with a warning that points at the
X-Forwarded-* headers first. The default is empty, so existing installs
are unchanged.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…ins (#979)

The setting is a fallback for a proxy that can't send X-Forwarded-Proto
and X-Forwarded-Host. Say so next to that advice in the codex.toml
comments and the README's "CSRF 403 behind a proxy" section, so users fix
the proxy first instead of reaching for the setting.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
#910 pinned dynamic covers, custom covers and finish-on-last-page on and
read-RTL-in-reverse off, and removed their checkboxes, but kept the columns,
serializer fields, defaults, telemetry buckets and sidecar export so the
toggles could come back with a revert. Nobody needs them back, so they go:

- Migration 0057 drops SettingsBrowser.custom_covers/dynamic_covers and
  SettingsReader.finish_on_last_page/read_rtl_in_reverse. They shipped in
  0038, so this is a real RemoveField migration, not a fold edit.
- BROWSER_DEFAULTS/READER_DEFAULTS, the browser, reader and session
  serializers, the telemetry and admin stats buckets, and the user-data
  sidecar schema/dump/restore lose the keys. The sidecar SCHEMA_VERSION is
  unchanged: the store only adds columns and restore reads named columns,
  so a pre-removal sidecar still restores (the legacy restore fixture keeps
  the two keys as the regression guard).
- Dead code the pin left behind goes too: the sort-name fuzzy cover match
  and parent_route, the custom-cover early return, the unused
  BrowserCoverInputSerializer, the coverSettings getter and its key lists,
  setReadRTLInReverse, and the parked browser-settings-covers component.
  BrowserCoverInputSerializerBase folds into BrowserSettingsSerializerBase,
  and get_reader_defaults now iterates READER_DEFAULTS.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* Give each pytest process its own config dir

Every test run shared ./config: the developer's real codex.toml, secret
keys and, above all, the file-based Django caches that cachalot fills.
A -p plugin now points CODEX_CONFIG_DIR at a per-process directory
under the run's tmp root before pytest-django imports the settings, so
serial runs, parallel worktrees and (next) xdist workers never share a
cache. An explicit CODEX_CONFIG_DIR still wins in a serial run.

With that isolation the SerializeMixin file lock on BaseTestImporter
only serialized importer classes across processes, so it goes.

--durations=25 --durations-min=1.0 makes slow tests visible in every
run's output.

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

* Mock only the abort event's wait in second-look tests

The delete-backstop and custom-cover cleanup tests patched
threading.Event.wait for every Event in the process. Thread.start()
waits on an Event too, and the first multiprocessing.Queue put in a
process starts the queue's feeder thread, so a stray wait() call landed
on the mock whenever that test was the first in its process to put on
LIBRARIAN_QUEUE. TestSecondLookBeforeDeleting then failed its
assert_called_once_with(5), alone or on a fresh xdist worker, and the
janitor test's assert_called() could pass without the second look ever
waiting.

Both now patch wait on the abort event the importer or janitor is
built with, which is the wait the code under test calls.

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

* Run the Python suite in parallel with pytest-xdist

--numprocesses=auto --dist=loadscope spreads test classes across every
core and keeps each class on one worker, so setUpTestData runs once
per class. Each worker gets its own in-memory test database and, from
the previous commit's plugin, its own config dir and file caches.

--no-loadscope-reorder keeps pytest-django's TestCase-before-
TransactionTestCase order on every worker. loadscope's default sorts
scopes by size, which put BookmarkDedupeMigrationTests mid-run; its
flush then stripped the migration-seeded rows (ComicboxTaggingDefaults,
AgeRatingMetron) and left the comic sequence at 14 for the TestCases
that followed on that worker: 13 failures.

Locally on 12 cores: 239 s serial -> 75 s; -n 4 (the CI runner's
vCPUs) 87 s. Same 1668 passed, 1 xfailed and the same coverage TOTAL
as the serial run. -n0 runs serially.

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

* Stop vitest linting and rewriting its snapshot

vite-plugin-checker ran a full eslint_d pass inside every vitest run.
The Lint job already lints the frontend, so the checker now loads only
outside the test environment.

reader-nav-button's only assertion on its initial markup was
toMatchSnapshot() against a gitignored file that --update rewrote on
every run, so it could never fail. It now asserts the link's tag, href,
title, disabled state and classes. --update leaves test and test:ci;
test:update regenerates snapshots on purpose.

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

* update devevnv

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Move the raw ciphertext read out of check_credentials into
_stored_credentials, taking check_credentials from C to A (5).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Show series and issue detail under each match candidate

Online-tag candidates often share a near-identical cover and an
identical title ("Batman #1 (2016)"): a reprint volume, a foreign or a
collected edition. comicbox 5.4 carries what tells them apart, and the
dialog now shows it in two subdued lines under the title:

    DC Comics · Limited Series · Series began 2016 · 6 issues
    "I Am Gotham, Part One" — <deck, clamped to two lines>

serialize_candidate passes the six new summary fields through by name,
so an older comicbox and prompts cached before them degrade to absent
fields. PROMPT_VERSION is unchanged; it gates fingerprints, not payload
shape. The serializer test uses a stand-in summary because the pinned
comicbox predates the fields.

The publisher moves off the title line into the detail line
(series_publisher, falling back to the scored publisher, which neither
source fills today). The score chip and Pick button no longer shrink,
so long detail text wraps instead of clipping them.

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

* Give match-candidate text the row's width on phones

At phone width a candidate row held a 48px cover, the text, the score
chip and the Pick button side by side, leaving the text about 100px:
series titles wrapped a word per line and the new series and issue
detail lines showed a few words each.

The chip and Pick now sit in one non-shrinking group. Below Vuetify's
sm breakpoint the row wraps and that group takes its own right-aligned
line, while the text, given a zero flex basis, keeps the cover's line
and its full width. Wider layouts are unchanged.

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

* Shrink the candidate deck and score caveat a step

The issue title/deck line and the "Cover not compared" / score
breakdown line drop from 0.8125rem to 0.75rem, the smallest size codex
already uses. They are the least needed text in a candidate row; the
series line and a.k.a. names keep 0.8125rem.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Series gains start_year, end_year, status (choices from comicbox's
SeriesStatusEnum) and summary, read from comicbox's series block.
The metadata pane shows the year range and status after the series
name and the series summary below the comic's. The tag editor gets a
MetronInfo-only Start Year beside Volume Count.

The importer's single count slot per collection becomes a tuple of
attributes. Comics of one series in a batch now merge per attribute
(earliest start, latest end, largest count, first status and text).
The old max_none merge for counts never ran: it looked proposals up
with an unhashable list and suppress(Exception) hid the TypeError, so
disagreeing comics resolved by set order.

Series.summary collides with the comic summary annotation on
collection panes, so that field now takes the conflict_ prefix path.

Needs the comicbox release that ships SeriesStatusEnum; the pin is
not bumped yet.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
#985)

The first cut of the series fields put the series year range and status
inline in the xx-large series row, beside the volume's own parenthesised
year, where they read like part of the name. The series row now names
things only, and a subdued facts line under it carries the run
(1963–1996, 1963–, –1996 or 1963) and the status, joined by a middle dot.
The series summary is labelled "Series Summary" so it still reads
right in a series' own pane, where the comic summary is usually empty.

The browser title appends the same year range on a series page, from
two new nullable title fields (collection_start_year/_end_year). A
plain Tags button at the title bar's right edge opens the browsed
collection's own metadata, like the one on browser cards; the root
listing has none. It is positioned absolutely so the title stays
centred, and the title gets symmetric padding so a long name never runs
under it. MetadataDialog/MetadataActivator take optional size and
density props for it; cards and the reader keep their defaults.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…986)

No format codex writes can hold a series' end year, status or summary:
MetronInfo has only StartYear and ComicInfo none of them, and only
comicbox's own comicbox.json/yaml carries them, which codex won't embed
in comics. So the tag editor saves these three straight to the Series
row through a new admin endpoint, POST /admin/series-attrs, and says so
under the inputs: they are saved in Codex only, not written to the comic
files. Re-imports leave them standing, since the importer ignores a
series attribute a file doesn't carry.

The inputs seed from the series and no write format gates them. They
lock, with the reason shown, unless exactly one series is being edited
and its name is unchanged, because the values land on one Series row and
a rename would leave them on the row the comics are moving off. Only
changed attributes are sent; a clear sends null or "". A save with
nothing else skips the preflight and the tag write, and on the browser
route the page reloads so the title's series years refresh. The status
choices derive from SeriesStatusChoices into series-statuses.json.

NEWS also gains the line PR 983 lacked for the online tag match picker.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
A Metron match brings the series' end year, status and summary, and
Comic Vine its start year and summary. ComicInfo holds none of them and
MetronInfo only the start year, so the re-read after the write lost
them.

The tag writer now pulls those series attributes out of each comic's
patch and hands them to the re-read ImportTask, and the importer lays
them over the series the file names. Doing it during the import, rather
than updating the comic's Series row when the write is queued, puts the
values on the row the comic lands on: a match that names a new
publisher or series (the usual first tag of a comic whose series came
from its filename) moves the comic to a different row on re-import.

Comics of one series that disagree within a batch settle by the
importer's existing rules: earliest start year, latest end year, first
comic's status and summary. A later write replaces a stored row's
values. Empty values are dropped, so online data never clears one, and
a file-only re-import leaves them standing.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
QueryForeignKeysQueryImporter.query_existing_mds called
select_related(*MODEL_SELECT_RELATED.get(model, ())). For models with no
entry that is select_related() with no arguments, which Django 6
deprecates, so every importer test emitted RemovedInDjango2028Warning.

The call never did anything: values_list() ends in Query.set_values(),
which sets select_related = False. The joins come from the __name
lookups in the values_list fields, so the SQL is unchanged. Remove the
call and the now-unused MODEL_SELECT_RELATED and
_IDENTIFIED_SELECT_RELATED constants.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Browser filters for the series start year, end year and status (with a
None row for a blank status), and search-box support for all four:
start_year:/end_year:/status: are ORM columns like year:, and the series
summary becomes a ComicFTS column so bare terms match it.

Migration 0059 adds the filter-settings columns and rebuilds the FTS5
table with series_summary, so the first boot re-indexes every comic.
The importer prunes unchanged series links, so a series whose summary
changes now re-indexes all its comics through the FK re-sync path, and
the tag editor's database-only summary edit updates the index rows in
place.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
read -> query -> create_and_update -> link runs once per chunk of paths,
but the search index syncs once afterwards. _bulk_update_models assigned
each chunk's updated universes to FTS_UPDATED_M2MS, so a later chunk
replaced an earlier one's and only the last chunk's universes had their
comics re-indexed. Universes now accumulate like series summaries do,
through one helper.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
The importer's inline search writer took designations only from a map of
universes created in this import. It popped each entry, so only the first
comic linked to a new universe got it; it keyed the map on a comic's whole
tuple of universe names, so a comic with two universes got none; it never
covered existing or kept universes; and it was replaced per chunk.

Look the designations up instead, one batched Universe query per search
pass over every universe name the pass indexes, new, existing or kept,
and drop the created-universes map.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
… page (#992) (#993)

* fix: stamp metadata_imported_at on tag-reading library scans (#992)

Hovering a browser card's tag button lazy-imports a comic whose
has_metadata is false, i.e. metadata_imported_at and metadata_mtime are
both NULL. metadata_mtime stays NULL for any archive with no embedded
tags file. metadata_imported_at was only stamped by forced imports, so
a library scan with "Import Tags on Library Scan" on (the default) read
every comic but stamped none. On any install scanned since 0043, every
comic without embedded tags read as un-imported. Each hover then forced
an import that found nothing but still rewrote the row, purged and
rebuilt the cover and broadcast LIBRARY_CHANGED, reloading the browser
once per comic.

- Stamp at finish whenever the pass read tags: forced, or the
  IMPORT_METADATA flag on. A flag-off scan still leaves rows NULL for
  the lazy importer. The flag read moves to FinishImporter so extract
  and finish share it.
- Batch the stamp's path__in by IMPORTER_LINK_FK_BATCH_SIZE. First
  scans of large libraries now reach it, and a single IN list would
  exceed SQLite's default 32766-variable cap.
- Migration 0060 backfills NULL rows with 0043's seed when the
  IMPORT_METADATA flag is on (a missing row counts as on).

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

* fix: skip the row write for a re-import that changes nothing (#992)

A forced or lazy import of an unchanged comic, typically one with no
embedded tags, still rewrote the row. The query phase kept it in
UPDATE_COMICS with an empty diff "to set stat", so update_comics wrote
the six always-update fields, purged and rebuilt the cover and bumped
updated_at. TimestampUpdater then restamped its collections and finish
broadcast LIBRARY_CHANGED, reloading every open browser.

update_comics now skips a comic when the update would write nothing new:
- no scalar diff and no FK link change remain after the query phase's
  pruning, so Force Update still relinks a wrong publisher;
- no m2m link to create or delete, so a stray genre still gets the row
  write that re-syncs its search entry and timestamp;
- no TagWriter series_attrs ride along;
- presave derives the same stat, size, date, decade and age-rating
  index from the file on disk, so a replaced file with the same tags
  still updates and gets a new cover;
- the chunk's update_all_fks changed no rows. FK bulk updates don't
  write updated_at, and the FK search re-sync only runs beside
  per-comic FTS entries. A corrected tag or collection row reaches the
  browser and the index only through the comics the import rewrites,
  so such a chunk keeps the old full update.

With nothing written, counts stay zero, so finish neither clears caches
nor broadcasts. The metadata_imported_at stamp is path-scoped and still
lands. An import that rewrote no comics left the empty FTS inputs
behind, because an operate with no entries returns before consuming
them; import_search_index now drains them on exit.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
v2.5.5 shipped through 0057, so 0058 through 0060 have never reached a
release and no install in the wild has applied them. Fold them into
0058_post_v2_5_5_schema, as #909 did for the post-v2.3.3 chain, so an
upgrading install applies one migration.

Operations keep their order: the series fields, the series filter
columns, the FTS drop and recreate with series_summary, then the
metadata_imported_at backfill. The FTS SQL and the backfill were copied
verbatim from the old files.

Verified on scratch databases: a fresh migrate of the fold and of the
old chain give identical sqlite_master schemas, as does an upgrade from
0057. Rolling the fold back gives the 0057 schema. makemigrations
--check reports no changes.

tests/test_lazy_import_marker.py imported the backfill by module path
and is repointed at the folded module.

A dev database that already applied the old 0058-0060 needs to be
rebuilt or `migrate codex 0058_post_v2_5_5_schema --fake`. A renamed
migration is one the recorder has never seen, so it would re-apply and
fail on a duplicate column.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
@ajslater
ajslater merged commit ddcd645 into main Oct 22, 2026
13 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