48108a356923ba4795b37c35cd4906a46abb8745
100
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
48108a3569 |
fix: a lane that is OFF was not attributable to itself (4295)
CI and images / frontend-build (push) Successful in 25s
CI and images / lint (push) Successful in 3s
CI and images / extension-version (push) Successful in 3s
CI and images / sign-extension (push) Successful in 3s
CI and images / build-agent (push) Successful in 6s
CI and images / backend-lint-and-test (push) Successful in 36s
CI and images / integration (push) Successful in 2m26s
CI and images / build-web (push) Successful in 1m39s
CI and images / smoke-web (push) Successful in 56s
CI and images / promote (push) Skipped
Operator, 2026-09-23: *"clean up the stale service_seen rows"*.
**They were not stale.** They were phantoms, written on purpose, and they will
come back on every install that turns a lane off — so the rows are the smaller
half of this.
A celery worker was attributed to its lane by the queues it was CONSUMING. A
lane at cap 0 has its consumers cancelled, so it answers `active_queues()`
with an empty list, matches no lane, and is dropped. Three consequences, all
on the operator's screen at once:
1. The lanes table reported the lane **not answering** — the signal for a
crashed worker, not for one the operator turned off.
2. The roster grew a phantom row named **`Worker ()`** — the empty queue set
rendered as a display name — shown "running" beside the real lane's row
going stale, because nothing updated it any more.
3. **The container went unhealthy.** `healthcheck._lanes_ok` requires every
lane present. ML ships at cap 0, so a fresh install was permanently
unhealthy and Swarm restarts an unhealthy task forever.
That third one is the severe one, and its docstring asserted the opposite of
what the code did — *"a disabled lane still runs its process with its
consumers cancelled, so it answers inspect and is healthy"*. It answers. It
was not attributed. A comment can be right about the intent and wrong about
the program, and this one had been wrong since the consolidated container
shipped.
`worker_lanes.lane_for_node` attributes by NODE NAME instead: identity travels
with the process rather than with what it happens to be doing.
`gen_supervisord` already sets `CELERY_NODENAME={lane.name}` per program — the
information was there and nothing read it. Falls back to the queue set for a
deployment that names no node, and `docker-compose.yml` now sets one per
service so the multi-service stack gets it too.
The roster keys on the LANE's queue set when the node resolves, which is the
same string the row already had while it was consuming — so an existing row
keeps updating rather than a second one appearing.
Migration 0106 deletes the one key the bug produced, `celery:`. Deliberately
NOT a retention sweep: the roster never forgets on purpose, so a quiet row is
what it is FOR, and only a row that cannot correspond to anything real is safe
to remove. An `agent:agent` row, if one exists, is left alone — nothing here
can tell an abandoned agent id from a second agent that is genuinely down, and
hiding a dead GPU agent is the one thing the roster must not do.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LVjrnpQjRgHdvq95rASoiR
|
||
|
|
a4c66601db |
fix: the heartbeat guard grepped its own explanation (4295)
CI and images / lint (push) Successful in 3s
CI and images / extension-version (push) Successful in 3s
CI and images / frontend-build (push) Successful in 24s
CI and images / backend-lint-and-test (push) Successful in 32s
CI and images / integration (push) Successful in 2m9s
CI and images / sign-extension (push) Successful in 3s
CI and images / build-web (push) Successful in 1m48s
CI and images / smoke-web (push) Successful in 55s
CI and images / build-agent (push) Successful in 8m56s
CI and images / promote (push) Skipped
Run 7375: the test that asserts the agent's heartbeat is not gated on holding
leases failed — on the docstring of the fix, which quotes the construct the
fix removed, because that is what a docstring explaining a fix does.
assert "if ids:" not in loop
A source-TEXT assertion cannot tell code from prose about code. Parsed now:
the function's AST body, unparsed with the docstring node dropped, so the
guard reads only what executes.
Worth stating as the general shape, since this repo writes long explanatory
comments on purpose: any check that greps source for the absence of a pattern
is in tension with documenting why that pattern is gone. Either it excludes
the prose, or the next person to explain the fix breaks the guard that
protects it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LVjrnpQjRgHdvq95rASoiR
|
||
|
|
693759f2bb |
fix: an idle GPU agent could not check in, so the roster called it stopped
CI and images / lint (push) Successful in 3s
CI and images / extension-version (push) Successful in 2s
CI and images / frontend-build (push) Successful in 21s
CI and images / backend-lint-and-test (push) Successful in 31s
CI and images / integration (push) Failing after 2m10s
CI and images / sign-extension (push) Skipped
CI and images / build-web (push) Skipped
CI and images / smoke-web (push) Skipped
CI and images / promote (push) Skipped
CI and images / build-agent (push) Skipped
Operator, 2026-09-23: *"I'm running the gpu agent on my device and it
currently reads as 'offline' but it's running and has checked in recently."*
It had checked in — twelve minutes ago. Two cadences that never agreed:
idle lease poll ceiling 900s agent/fc_agent/worker.py (sleep mode)
heartbeat while idle never gated on holding leases
roster "stopped" after 300s api/system_health.py
The roster records an agent check-in on `lease` and `heartbeat`. The heartbeat
loop was gated on `if ids:`, so an agent holding no leases sent nothing at
all — leaving the lease poll as the only check-in, and sleep mode backs that
off exponentially to a 900s ceiling. 900 against 300: an IDLE agent was
structurally guaranteed to read as stopped. Nothing was broken; nothing was
misconfigured; the two halves simply disagreed.
Not a recent regression. Sleep mode landed 2026-07-02; the roster adopted the
lease as its check-in on 2026-09-02 — *"A lease IS the check-in … Recorded on
the call that was already happening"* — without noticing that the call it was
piggybacking on had been deliberately slowed ten weeks earlier.
The heartbeat now sends whether or not it holds leases. An empty one extends
nothing (`id.in_([])` matches no rows) and costs one small POST every 45s —
against the 6/min lease poll sleep mode exists to avoid, that is not a cadence
worth protecting, and it is what makes "is the agent alive" answerable at all.
Still gated on `self._running`: a worker that has been stopped is not checking
in for work, and reporting it as present would be a different lie.
Two things I could NOT determine from the code, both needing the live table:
whether a stale `agent:agent` row exists from an older build that omitted
`agent_id` (the server defaults it), and whether changing `AGENT_ID` has ever
stranded an abandoned row — nothing prunes `service_seen`, so either would sit
there reading "stopped" forever.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LVjrnpQjRgHdvq95rASoiR
|
||
|
|
5b6f2ba526 |
fix: a Postgres connection was held across every celery round trip (4295)
Operator, 2026-09-23: *"something about changing the cap number is blocking to the website... it shouldn't be"*. Nothing here was slow in itself. A database connection was held across work that is slow, and that is why it surfaced as the whole site stalling rather than as one slow page. `lane_view` took the session and kept it open through a celery inspect whose budget is 11s. The System tab polls that endpoint every 15s — and with a lane not answering, every inspect runs to nearly its full budget, so each poll pinned a connection for most of the interval. SQLAlchemy's default pool is 5 plus 10 overflow. Two browser tabs, `/api/system/health` doing the same thing, and a cap change adding two more inspects exhausts it, and every OTHER request then waits for a connection. Split so the database work finishes before the broker work starts: - `lane_settings(session)` reads the caps and the oldest running task, then the session closes. `lane_view(settings)` does the inspect with none held. - `store_lane_cap(session, …)` validates and commits, then the session closes. `push_lane_cap(lane, …)` does the live push with none held. And a second finding while measuring it: **raising a cap now costs no broker round trip at all.** The first cut only knew on/off, so it inspected on every raise to find out whether the pool needed lowering — the control meant to be instant still waited out an inspect. `store_lane_cap` returns the PREVIOUS cap so the push knows the direction; only a lowering needs to say anything. The guard is structural, not timed: `lane_view` and `push_lane_cap` must not ACCEPT a session. A timing test would be flaky, and a call-order test would pass against a version that took the session and merely used it early. `/api/system/health` has the same shape and is NOT fixed here — it is rate-limited by `refresh_if_stale` so it does not inspect on every request. Worth doing, separately. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LVjrnpQjRgHdvq95rASoiR |
||
|
|
c09ebd6639 |
fix: the race fix's temp name assumed one bootstrap per process (4295)
CI and images / smoke-web (push) Successful in 55s
CI and images / promote (push) Skipped
CI and images / lint (push) Successful in 3s
CI and images / extension-version (push) Successful in 3s
CI and images / frontend-build (push) Successful in 22s
CI and images / backend-lint-and-test (push) Successful in 30s
CI and images / integration (push) Successful in 2m9s
CI and images / sign-extension (push) Successful in 3s
CI and images / build-agent (push) Successful in 6s
CI and images / build-web (push) Successful in 1m48s
Run 7370: six of the eight threads in the new test died with
FileNotFoundError(2, 'No such file or directory')
The first cut named the temp file `.credential_key.b64.<pid>.tmp`, which
assumes one bootstrap per PROCESS. Eight threads share one pid, so all eight
raced the same filename and six found it already unlinked by another.
The assumption held for hypercorn's workers, which are processes, and would
have held in production. It was still an assumption the code did not need to
make, and the test found it in one run — which is the test doing its job
rather than the test being wrong, so `tempfile.mkstemp` replaces the guess.
Verified against the SHIPPED file, loaded by path so the package `__init__`
(which wants quart) stays out of it: 25 consecutive attempts, eight threads
through a barrier each time, one key, no leftover temp files. My first check
of this reimplemented the logic in a scratch script, which measures a copy —
rule 10 names that exact failure, and it is not evidence about the code that
ships.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LVjrnpQjRgHdvq95rASoiR
|
||
|
|
895589a578 |
fix: first boot raced itself for the credential key (4295)
CI and images / lint (push) Successful in 4s
CI and images / extension-version (push) Successful in 4s
CI and images / frontend-build (push) Successful in 26s
CI and images / backend-lint-and-test (push) Successful in 33s
CI and images / integration (push) Failing after 2m27s
CI and images / sign-extension (push) Skipped
CI and images / build-web (push) Skipped
CI and images / smoke-web (push) Skipped
CI and images / promote (push) Skipped
CI and images / build-agent (push) Skipped
Run 7368's smoke, on an image whose five verification lanes were all green:
WARNING Generating NEW Fernet credential key at
/images/secrets/credential_key.b64
...
ValueError: Fernet key must be 32 url-safe base64-encoded bytes.
Nothing to do with this batch's changes — it is a first-boot race that has
been there since the key file existed, and it is a RACE rather than a
certainty: the same code booted cleanly on the three runs before it.
hypercorn starts several worker processes and each one builds the app, so on a
first boot they all reach the bootstrap together. `write_bytes` creates the
file at size zero and fills it a moment later, which gives the second process
an `exists()` of True and a `read_bytes()` of `b""`.
A first boot that fails one time in five is worse than one that fails every
time, because it looks like the deployment rather than the code — and this is
the very first thing a new install does.
The key is now written to a temp file and `os.link`ed into place. `os.link` is
the atomic part: it either creates the name or raises FileExistsError, and it
cannot expose a half-written file. Deliberately NOT `os.replace`, which would
succeed — two processes that both generated a key would each think they had
won, and the loser's key would overwrite the one the winner had already handed
to Fernet. The losing branch reads the winner's key back rather than returning
its own, which is what keeps every worker on ONE key.
Tested for AGREEMENT, not for the absence of a crash: eight threads through a
barrier, and all eight must end up holding the same key. A race that left each
worker with its own would pass a "did it raise" check and produce a system
where a credential written by one worker cannot be read by the next.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LVjrnpQjRgHdvq95rASoiR
|
||
|
|
61641fbba7 |
fix: a test still described the control the cap replaced (4295)
CI and images / lint (push) Successful in 3s
CI and images / extension-version (push) Successful in 3s
CI and images / frontend-build (push) Successful in 22s
CI and images / backend-lint-and-test (push) Successful in 32s
CI and images / integration (push) Successful in 2m11s
CI and images / sign-extension (push) Successful in 3s
CI and images / build-agent (push) Successful in 5s
CI and images / build-web (push) Successful in 1m47s
CI and images / smoke-web (push) Failing after 40s
CI and images / promote (push) Skipped
Run 7367, integration lane:
FAILED test_a_cap_is_stored_even_when_it_cannot_be_pushed
assert True is False
The code was right. Raising a cap is PERMISSION, not a request — it
deliberately does not grow the pool, because that would put workers on a lane
with nothing to do, and the sizing pass spends the permission on its next tick
if there is work. So nothing is pushed and `applied` is vacuously true.
The test was carried over from when the number meant "run this many", where
every write pushed. It asserted the old control's behaviour against the new
one — lesson #4338's shape again: an assertion encoding the thing that
changed, failing on the change rather than on a defect.
Split into the two cases that actually exist now:
- raising a cap stores it and pushes nothing, reporting applied;
- turning a lane OFF does push, because consumers follow the cap immediately
in both directions — off must take effect when it is asked for — so with
nothing answering it reports `applied: false` with a reason, and the value
is still stored for the sizing pass to carry.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LVjrnpQjRgHdvq95rASoiR
|
||
|
|
364e050632 |
fix: a spliced-out test left its tail behind (4295)
CI and images / backend-lint-and-test (push) Successful in 30s
CI and images / integration (push) Failing after 2m9s
CI and images / lint (push) Successful in 3s
CI and images / extension-version (push) Successful in 3s
CI and images / frontend-build (push) Successful in 22s
CI and images / sign-extension (push) Skipped
CI and images / build-web (push) Skipped
CI and images / smoke-web (push) Skipped
CI and images / promote (push) Skipped
CI and images / build-agent (push) Skipped
Run 7366, in both the lint and unit lanes — one bug wearing two hats:
NameError: name 'slots' is not defined
tests/test_worker_lanes.py:278
Rewriting `test_worker_lane_check_constraints` — parametrised over (slots,
cap, ok) — I cut the parametrize decorator and the assertion but left the two
lines after it, which still referenced all three names. `ast.parse` is happy
with that; only running it is not.
Both failures were the same fragment, which is the tell: the lint lane's F821
and the unit lane's NameError are the same fact reported twice.
Checked the rest of today's splices for the same seam rather than fixing only
the one CI named — a module-level undefined-name sweep over all four test
files I rewrote today comes back clean.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LVjrnpQjRgHdvq95rASoiR
|
||
|
|
830394ed5e |
fix: the migration's DROP CONSTRAINT names doubled their own prefix (4295)
CI and images / lint (push) Failing after 3s
CI and images / extension-version (push) Successful in 3s
CI and images / frontend-build (push) Successful in 19s
CI and images / integration (push) Failing after 2m15s
CI and images / sign-extension (push) Skipped
CI and images / backend-lint-and-test (push) Failing after 30s
CI and images / build-web (push) Skipped
CI and images / smoke-web (push) Skipped
CI and images / promote (push) Skipped
CI and images / build-agent (push) Skipped
Run 7365, integration lane:
(psycopg.errors.UndefinedObject) constraint
"ck_worker_lane_ck_worker_lane_slots_within_cap" of relation
"worker_lane" does not exist
That is #3275 exactly, from the other direction. alembic 0088 had to RENAME
four constraints CREATED with a doubled prefix; this one tried to DROP two
with the same doubling. `op.drop_constraint` runs its name through
Base.metadata's naming convention, which prepends `ck_worker_lane_` to a
string that already carries it — `op.f()` is what marks a name as final, and
0103 used it on the way in.
The model test also went red, correctly: `test_worker_lane_check_constraints`
was parametrised over (slots, cap) pairs and asserted all three constraints,
and two of them went with the `slots` column. It is one unparametrised test
now, asserting the whole remaining set rather than a membership — a constraint
left behind naming a dropped column is not a harmless leftover, it is a table
the migration cannot have produced.
Worth recording: **the gate worked.** Run 7365 skipped `sign-extension`,
`build-web`, `smoke-web`, `promote` and `build-agent`, and `:dev` still names
the previous digest. That is the red-direction verification #4339 owed, and it
arrived by accident rather than by a forced failure — which is the better
evidence.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LVjrnpQjRgHdvq95rASoiR
|
||
|
|
445164c852 |
feat: one number per lane — the cap — and the autoscaler is the mechanism (4295)
CI and images / lint (push) Successful in 4s
CI and images / extension-version (push) Successful in 4s
CI and images / frontend-build (push) Successful in 24s
CI and images / integration (push) Failing after 24s
CI and images / backend-lint-and-test (push) Failing after 34s
CI and images / sign-extension (push) Skipped
CI and images / build-web (push) Skipped
CI and images / smoke-web (push) Skipped
CI and images / promote (push) Skipped
CI and images / build-agent (push) Skipped
Operator, 2026-09-23: *"auto should be always on, not a setting, so that idle
instances quiet down when not running. the number that is visible and
something the user can tweak and manage should be the cap itself the number of
running workers is handled by the autoscaling function which is always on."*
They are right, and the reason it was not built this way is worth stating: the
manual dial came first (steps 2-4) and the autoscaler came last (step 7), as
an opt-in BESIDE a control that already existed. Nothing ever asked whether
the dial should still exist once something could move it automatically. Each
step was defensible; the result was three operator settings over one number.
## `slots`, `enabled` and `autoscale` are gone
`slots` was a MEASUREMENT wearing a preference's clothes. How many workers a
lane runs is read live and moved every minute; storing it meant the operator
had to keep two numbers in agreement and the autoscaler had to be told it was
allowed to touch one of them.
`autoscale` gated the mechanism behind a choice, so a lane nobody opted in
never gave its workers back — which is why an idle instance never quieted
down.
`enabled` is derived: a cap of zero means no consumers. "Off" and "may use no
workers" were two spellings of one fact, stored separately, free to disagree.
## Two sweeps become one
`reconcile_lanes_sync` drove the pool to the stored `slots`; `autoscale_lanes_
sync` moved it away from that same number; and most of step 7's hardest
reasoning — a stored value that is a FLOOR, a target of `max(stored, current)`
— existed only to stop them fighting. Delete the stored number and the problem
is not solved, it is absent.
`size_lanes_sync` runs every minute and owns both consumers and pool size. It
also subsumes what the reconcile was for: a worker restarted at its ENV
concurrency is corrected on the next tick rather than after five.
Growth is immediate, shrink is one worker per tick. Deliberately asymmetric —
"always on" is only pleasant if the ramp keeps up, and +1/minute would take
four minutes to answer a burst. Being one worker too large for a minute costs
a sleeping process; being too small costs work not happening. For ML the
asymmetry matters most: every new slot reloads a multi-GB model, so the slow
shrink is what stops a quiet patch from paying that cost again a minute later.
## The caps ship at one, and zero for ML
Per the operator. Conservative on purpose — and a conservative default nobody
knows how to raise is just a slow product, which is the other half of what
they asked for:
"there needs to be something that tells the user to bump those numbers to
improve processing rate or they'd never know the controls exist."
So a lane running everything its cap allows while work piles up says so, in
its own row, with the headroom named: *"4,060 waiting and all 1 worker busy.
Raise the cap to run more at once — this machine allows up to 7."*
It fires only when raising the cap would actually help. Not when the lane is
keeping up, not when the sizing pass has room it has not taken, and not at the
machine ceiling — where "raise the cap" is advice nobody can take.
## Migration 0105 rewrites the caps rather than carrying them
The old defaults (4/2/2/1) bounded a manual control and were loose because
moving within them was the ordinary act. The number now means "the most
workers this lane may use", which is a different promise; carrying the old
figure over would quadruple the worker lane on every existing install at the
moment this deploys.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LVjrnpQjRgHdvq95rASoiR
|
||
|
|
abe16aa382 |
feat: the System tab is one bounded table, and the dial is the switch (4295)
CI and images / frontend-build (push) Successful in 20s
CI and images / backend-lint-and-test (push) Successful in 32s
CI and images / lint (push) Successful in 3s
CI and images / extension-version (push) Successful in 3s
CI and images / integration (push) Successful in 2m10s
CI and images / sign-extension (push) Successful in 3s
CI and images / build-agent (push) Successful in 6s
CI and images / build-web (push) Successful in 2m10s
CI and images / smoke-web (push) Successful in 52s
CI and images / promote (push) Skipped
Operator, 2026-09-23, on the screenshot: *"I feel that we can probably combine the two sections into a single table and to format it in such a way that it appears more bounded and less free-form or open. also there's nothing to describe what 'auto' means or why their needs to be or should be on/off toggles. almost all of it always needs to run there's only one optional piece and it is killed by moving the 'cap' to zero."* Three separate things, all correct. ## The four lanes were listed twice The roster (milestone 365) said "ML tagging is running", and four hundred pixels below it the lanes pane said "ML tagging · 1/1 busy". Two answers to one question from two endpoints, free to disagree on screen. I moved the second pane onto this tab yesterday and did not notice it duplicated the first. Now one row per part, with controls on the rows that have a lane and none on the rows that do not. The join is on the QUEUE SET, because that is what `service_roster` keys a celery part on — as a set, not as a string, so neither side has to agree about order. It lives in `utils/systemParts.js` rather than inline, and has a spec, because its failure is SILENT and is the exact thing it exists to prevent: a lane that stops matching its part does not throw, it grows a second row for the same worker. The duplication, returning through the code that removed it. ## Bounded, not free-form A real table — header, column rules, one bordered card — instead of dotted rows floating on the page background with nothing saying where the list began or what a column meant. ## The dial is the switch There was an `On` switch per lane beside the slots dial. Of four lanes, three must run for the application to work at all, so that switch offered a choice that was never real — and for the one lane that IS optional, "off" and "zero slots" were two ways of saying the same thing that could disagree with each other. So `enabled` is now DERIVED from the number: `set_lane` sets it from `slots > 0` when the caller did not say. It stays on the API and in the model — it is still the mechanism, and a drain-before-restart may still want a lane holding its process with consumers cancelled without destroying the operator's slot count to say so. Two things fell out that a test now pins: - The consumer command is sent on the CHANGE, not on the field being present. Otherwise every slots write re-sends a command that changes nothing — lesson #4183's churn, arriving through the new derivation. - The model fetch fires on the off→on TRANSITION. It used to test `enabled is True`, the field having been sent. The UI no longer sends it, so the download that makes the ML lane usable would simply never have fired and the lane would have come on to consume a queue it had no model for. ## And Auto now says what it is A legend under the table, in the operator's terms: what a slot is, that zero turns a lane off, that three of the four are not optional, what `of N` means, and that Auto lets a lane add slots by itself when its queue is backed up AND every slot is busy — with why it is off by default, since it is the only thing on the page that acts without being asked. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LVjrnpQjRgHdvq95rASoiR |
||
|
|
274f7ffe21 |
ci: the tests gate the publish — ci.yml folds into build.yml
CI and images / lint (push) Successful in 3s
CI and images / extension-version (push) Successful in 3s
extension / lint (push) Successful in 21s
CI and images / frontend-build (push) Successful in 21s
CI and images / backend-lint-and-test (push) Successful in 31s
CI and images / integration (push) Successful in 2m10s
CI and images / sign-extension (push) Successful in 3s
CI and images / build-agent (push) Successful in 6s
CI and images / build-web (push) Successful in 1m51s
CI and images / smoke-web (push) Successful in 57s
CI and images / promote (push) Skipped
Operator, 2026-09-23: *"tighten the gate so :dev can't publish on red tests"*, then *"I don't want failing builds to publish anywhere going forward."* Run 7348 is the worked example. The backend unit lane went red on `2f8f0bc` and `build-web` pushed `:dev` in the same minute, because the lanes and the build were SEPARATE WORKFLOWS on the same push trigger. Neither could see the other's verdict. `:dev` was a "it built" signal, never a "it passed" one, and nothing about that was visible from either run. Two workflows cannot express the gate. A `needs:` edge only exists inside one graph. So `ci.yml`'s five lanes move into `build.yml` and `ci.yml` is deleted; `sign-extension`, `build-web` and `build-agent` now need all five. Nothing here is a new mechanism — it is the same edge that has gated `promote` since milestone 362 step 4, and it keeps that step's hardest-won property: **not running is not the same as passing.** `needs` treats a SKIPPED dependency as unsatisfied, so a lane that silently skips itself blocks the publish exactly as a failing one does. Run 5290 is why that is worth stating. Scope, said plainly rather than implied: - Gated: every image tag (`:dev`, `:latest`, `:c-<sha>`), the weekly base refresh, and the `ext-<version>` signed-XPI release asset — `sign-extension` publishes too, so it is gated with the rest. - Not gated, deliberately: `extension.yml` publishes nothing, and `release.yml` runs on a `v*` tag, generates notes rather than an artifact, and its commit already went through main's gated build. - `pull_request` (Renovate bumps into `dev`) comes across with the lanes. Its runs are the lanes and nothing else, via an `if:` on each publishing job rather than an inference from the `needs` chain. The cost, accepted knowingly: this workflow queues per branch and never cancels, so on two pushes in quick succession the second's lint feedback waits out the first's build. A slower red beats a fast red that ships. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LVjrnpQjRgHdvq95rASoiR |
||
|
|
468ff71353 |
fix: two lane fixtures described a container that cannot exist (4295)
CI / frontend-build (push) Successful in 21s
CI / lint (push) Successful in 2s
CI / extension-version (push) Successful in 3s
CI / backend-lint-and-test (push) Successful in 32s
Build images / build-web (push) Successful in 5s
Build images / sign-extension (push) Successful in 3s
Build images / build-agent (push) Successful in 5s
CI / integration (push) Successful in 2m16s
Build images / smoke-web (push) Successful in 41s
Build images / promote (push) Skipped
`pools={"host-a": 0}` — a celery replica running an empty pool. billiard will
not run one, and `gen_supervisord` starts every lane at one process for
exactly that reason, so no container has ever looked like this.
It passed for as long as the code agreed with it. The floor landed, the
reconcile's target for a zero-slot lane became one, and the fixture's
impossible zero read as a real difference: `changed: ['ml']` on a settled
lane, and run 7346 red on the commit that fixed the live bug.
Both now say one, which is what ml actually runs. The two tests the fix added
that asserted the same properties from the other side are gone with them —
one assertion per fact, in the test that already named it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LVjrnpQjRgHdvq95rASoiR
|
||
|
|
2f8f0bc9eb |
feat: worker lanes move to the System tab, in the roster's own idiom (4295)
CI / lint (push) Successful in 2s
Build images / build-agent (push) Successful in 5s
CI / backend-lint-and-test (push) Failing after 32s
CI / extension-version (push) Successful in 2s
CI / frontend-build (push) Successful in 23s
Build images / sign-extension (push) Successful in 8s
CI / integration (push) Successful in 2m16s
Build images / build-web (push) Successful in 1m55s
Build images / smoke-web (push) Successful in 57s
Build images / promote (push) Skipped
Operator, 2026-09-23: *"where is the setting surface to set the number of each kind of worker? we have a system view under settings but no options to be set here"* — then *"move it to system tab and improve the view to be more inline with other UI elements."* They went looking under System and found a read-only roster. The card's own first cut argued that a control had no place on a tab about things being broken; that was wrong twice over. Half these lanes are optional and off by DESIGN, so "not running" is the normal state someone comes here to change — and a tab that only reports is a tab you leave in order to act somewhere else. So it is the roster's second half now, and built out of the same parts: the pane above says what is running, this one says how hard. Dot, name, detail, numbers on the right; the controls take the place the roster gives to latency. It was a bordered card wrapping a v-table, which is what made it read as bolted on. - The row styles move from SystemHealthTab's scoped block into app.css, so the second pane IS the first one's idiom rather than imitating it. A copy would have drifted the first time either was touched. - Lane state uses the roster's four dots. A lane that is off reads grey, never red: colouring an intended state as a fault is how an indicator stops being read. - The card owns its own polling. On Activity it rode that tab's 3s queue timer; System has none, so moving without this would have shown an empty pane to anyone who opened System first. 15s and `document.hidden`-guarded — it is a dial, not a live meter, and v-window keeps a hidden tab mounted. - The table header carried the column names, so each control now labels itself: `slots · cap N`, `auto`, `on`. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LVjrnpQjRgHdvq95rASoiR |
||
|
|
86d6509936 |
fix: a lane at zero slots tried to empty a pool billiard will not empty (4295)
CI / lint (push) Successful in 4s
CI / extension-version (push) Successful in 4s
Build images / sign-extension (push) Successful in 5s
Build images / build-agent (push) Successful in 7s
CI / frontend-build (push) Successful in 24s
CI / backend-lint-and-test (push) Failing after 40s
CI / integration (push) Successful in 2m11s
Build images / build-web (push) Successful in 2m1s
Build images / smoke-web (push) Successful in 1m0s
Build images / promote (push) Skipped
Found on the operator's live deploy, not in CI:
[scheduler] worker_control: ml reconciled 1 -> 0 slots
[ml] pidbox command error:
ValueError("Can't shrink pool. All processes busy!")
ML ships at 0 stored slots and disabled, and `gen_supervisord` starts every
lane at one process so `add_consumer` has something to reach. So the stored
value and the running pool disagreed by one, permanently: billiard will not
remove the last worker, and `set_lane_slots_sync` returns True on SENDING the
control message — the refusal happens later, on the worker. The reconcile
logged a successful correction and reported `changed: ['ml']` every tick,
forever, on the default configuration of every install.
Lesson #4183 in production: an enforcer whose target is unreachable re-does
its own work on every pass and says it worked.
The floor is now one PROCESS, in one place — `effective_slots()` — applied
wherever a target is COMPARED as well as wherever one is sent. Comparing
against the unclamped 0 sees a difference no control message can ever close,
which is the same non-convergence one layer up.
Zero slots still means zero WORK: the lane's consumers are cancelled, and the
idle process is what the enable switch lands on.
A cross-file guard ties the generator's starting concurrency to the same
function, so the two ends of the floor cannot drift apart again.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LVjrnpQjRgHdvq95rASoiR
|
||
|
|
a987ca41ca |
perf: the lane read is one broadcast and three targeted, not four broadcasts (4295)
CI / lint (push) Successful in 3s
CI / extension-version (push) Successful in 3s
Build images / sign-extension (push) Successful in 4s
Build images / build-agent (push) Successful in 7s
CI / frontend-build (push) Successful in 22s
CI / backend-lint-and-test (push) Successful in 32s
Build images / build-web (push) Successful in 2m2s
Build images / smoke-web (push) Successful in 55s
CI / integration (push) Successful in 2m11s
Build images / promote (push) Skipped
Found while fixing the roster's budget (
|
||
|
|
f23ab9f50e |
fix: the roster's inspect budget was exactly the work it waited for (4295)
CI / lint (push) Successful in 3s
CI / extension-version (push) Successful in 3s
Build images / sign-extension (push) Successful in 4s
Build images / build-agent (push) Successful in 7s
CI / frontend-build (push) Successful in 21s
CI / backend-lint-and-test (push) Successful in 33s
Build images / build-web (push) Successful in 2m4s
CI / integration (push) Successful in 2m11s
Build images / smoke-web (push) Successful in 1m3s
Build images / promote (push) Skipped
From the operator's first consolidated deploy, 2026-09-23. The app is serving
— showcase, thumbnails, a Patreon ingest tick, all five lanes in one
container — and this repeats in the log:
WARNING service roster: celery inspect failed; roster not refreshed
File "service_roster.py", line 138, in refresh_celery_roster
grouped = await asyncio.wait_for(...)
TimeoutError
The inspect calls were working. The budget was wrong.
`_inspect_celery_sync` makes TWO broadcasts — `active_queues()` and
`active()` — and a broadcast with no `destination` cannot know how many
replies to expect, so each waits out its full timeout rather than returning
on the last reply. The sync call costs ~2 x INSPECT_TIMEOUT_SECONDS.
The wrapper allowed `INSPECT_TIMEOUT_SECONDS * 2`. That reads like a safety
factor and is precisely the worst case with nothing left over — and this runs
on a web process that was serving ninety thumbnails a second at the time, so
the thread handing off through `asyncio.to_thread` need not even be scheduled
inside the budget. A budget equal to the work fails under any load at all.
Now derived: `INSPECT_TIMEOUT_SECONDS * INSPECT_ROUND_TRIPS + slack`, with
the round-trip count named beside the calls it counts. Both tests assert the
RELATION rather than the numbers, and one reads the source to check the count
still matches the calls actually made — a third inspect call added later is
exactly how this comes back silently.
Consequence while it was broken: the roster stopped advancing and the System
tab's rows went stale, with a traceback per attempt. Never an outage —
`refresh_celery_roster` catches and returns, `/api/system/health` kept
answering 200 throughout, which the same log shows.
## Observed, not fixed here
`worker_control.inspect_lanes_sync` makes FOUR of these broadcasts
(active_queues, stats, active, reserved) at 2.0s each — roughly 8s — and
`lane_view` awaits it with no deadline at all. That is the Settings ->
Activity -> Worker lanes card, so that card likely takes ~8s to load, and the
composite healthcheck carries the same cost against its 15s timeout. Reported
to the operator rather than changed: they are mid-deploy, and the fix is to
cut round trips rather than raise a number.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LVjrnpQjRgHdvq95rASoiR
|
||
|
|
b74a4c964b |
fix: the image brings its own PID 1 instead of asking for init: true (4295)
CI / lint (push) Successful in 3s
CI / extension-version (push) Successful in 3s
Build images / sign-extension (push) Successful in 4s
Build images / build-agent (push) Successful in 6s
extension / lint (push) Successful in 19s
CI / frontend-build (push) Successful in 22s
CI / backend-lint-and-test (push) Successful in 33s
CI / integration (push) Successful in 2m15s
Build images / build-web (push) Successful in 2m52s
Build images / smoke-web (push) Successful in 1m16s
Build images / promote (push) Skipped
Operator, 2026-09-23: *"it is out of the norm to require this init call we need to fix this."* Correct, and it is the same mistake as declaring the healthcheck per service — the image needing a deployment to remember a flag before it behaves correctly. PID 1 carries a duty no other process has: every orphaned process in the container reparents to it and must be reaped or it stays a zombie holding a PID slot. This app makes orphans in normal operation — six service modules shell out (gallery_dl, thumbnailer, backup_service, external_fetch, download_service, download_backends) and celery's prefork pool forks children that spawn them. Whatever the role, something that is not an init ends up as PID 1: supervisord for `all`, hypercorn for `web`, celery for a worker. `init: true` covered that, and cost correctness the moment it was forgotten or silently dropped — an older Swarm, a plain `docker run`, a compose file someone copied. No signal either way. So tini goes in the image and is the ENTRYPOINT. `docker run <image>` is correct on its own now, `init: true` comes out of docker-compose.single.yml, and nothing downstream has to know. The smoke asserts /proc/1/comm is tini, read from /proc because the runtime stage installs no `ps`. ## A correction to what I told the operator I justified `init: true` by saying supervisord "has no idea about orphans it never started". That is very likely wrong: supervisord's reaper calls waitpid(-1) and logs "reaped unknown pid" for children it did not spawn, so it does reap orphans. I asserted the mechanism without checking it, and could not check it here — `supervisor` is not installed in this environment. It does not change this commit. tini is correct whichever way that lands, and it covers the single-role containers too, where celery or hypercorn is PID 1 and the subprocess-spawning is heaviest. But the reason I gave was not a verified one and should not have been stated as fact. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LVjrnpQjRgHdvq95rASoiR |
||
|
|
22dcbcfb74 |
fix: the lane/program guard compared every section, not just programs (4295)
CI / lint (push) Successful in 2s
CI / extension-version (push) Successful in 3s
Build images / sign-extension (push) Successful in 3s
Build images / build-web (push) Successful in 5s
CI / frontend-build (push) Successful in 25s
CI / backend-lint-and-test (push) Successful in 33s
Build images / build-agent (push) Successful in 6s
Build images / promote (push) Skipped
CI / integration (push) Successful in 2m9s
Build images / smoke-web (push) Successful in 46s
`test_every_lane_gets_a_program` asserted
`set(cp.sections()) - {"supervisord"} == expected`, so it failed the moment
the generated config grew non-program sections.
|
||
|
|
b2da3acce9 |
feat: wait for Postgres and Redis before starting work (4295)
CI / lint (push) Successful in 2s
CI / extension-version (push) Successful in 2s
Build images / sign-extension (push) Successful in 3s
Build images / build-agent (push) Successful in 6s
CI / frontend-build (push) Successful in 23s
CI / backend-lint-and-test (push) Failing after 32s
Build images / build-web (push) Successful in 1m43s
CI / integration (push) Successful in 2m12s
Build images / smoke-web (push) Successful in 57s
Build images / promote (push) Skipped
Operator, 2026-09-23: *"it's a single container that need to connect successfully to redis and postgres before starting work shouldn't that simply be a check (with retries) at the start of the container."* Yes, and the consolidated layout makes it necessary rather than tidy. Swarm has no ordering primitive — it ignores `depends_on` outright — so every service in a stack starts at once and this container has always raced its own database on a cold deploy. The multi-service stack hid how sharp that is: a `web` task that failed `alembic upgrade head` against a still-initialising Postgres simply died, and Swarm restarted it until it worked. Nobody ever saw a problem worth naming. Consolidation removes that safety net. Each supervisord program gets `startretries=3`, so three quick failures put the program in FATAL and leave it there — supervisord keeps running, the container keeps running, and the application never starts. It would present as a permanently unhealthy container whose image was fine and whose database merely took twenty seconds to initialise, which is a miserable thing to debug on a first deploy. A TCP connect, not a query: the same probe ci.yml's integration lane and the build smoke already use. It answers the question actually being asked — is something listening — and cannot fail for a reason that retrying will never fix. A real query would be a stronger readiness signal and a worse gate, since a wrong password or a missing database is not transient, and a loop waiting for one to heal turns a five-second misconfiguration into a two-minute timeout with a misleading message. Those belong to alembic, which runs seconds later and says exactly what is wrong. Targets are derived from the same env the application reads, so the wait cannot drift from what the app will actually connect to — a gate checking a different host than the app uses is worse than no gate. Bounded at 120s (rule 156), reporting every few attempts so `docker logs` on a waiting container says what it is waiting for. Skipped for `shell`, which exists precisely for when something else is broken and you want a prompt rather than a gate. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LVjrnpQjRgHdvq95rASoiR |
||
|
|
b09ee87255 |
fix: supervisorctl could not reach supervisord inside the container (4295)
CI / lint (push) Successful in 2s
CI / extension-version (push) Successful in 2s
extension / lint (push) Successful in 17s
CI / frontend-build (push) Successful in 21s
Build images / sign-extension (push) Successful in 2s
Build images / build-agent (push) Successful in 5s
CI / backend-lint-and-test (push) Failing after 32s
Build images / build-web (push) Successful in 1m46s
CI / integration (push) Successful in 2m13s
Build images / smoke-web (push) Successful in 58s
Build images / promote (push) Skipped
The smoke's own diagnostic line printed this for a whole run and passed,
because it was behind `|| true`:
Error: .ini file does not include supervisorctl section
supervisord was fine. `supervisorctl` simply could not talk to it — the
generated config had no `[unix_http_server]`, `[supervisorctl]` or
`[rpcinterface:supervisor]`.
That is the first tool anyone reaches for when a lane misbehaves in the
consolidated container. `docker exec <c> supervisorctl status` to see which
processes are actually up; `restart ml` to bounce one without taking the
whole application down with it. Consolidation took `docker ps` away as the
way to see the lanes, and this is what replaces it — so shipping without it
would have left an operator with one container, five processes inside it, and
no way to ask about any of them. They are about to run this in production.
The test asserts the three sections AGREE on one socket path rather than
merely existing: a serverurl pointing where nothing listens fails in exactly
the same way and reads as configured.
The smoke's line loses its `|| true`. A diagnostic allowed to fail silently
is one that stops being true without telling anyone — which is precisely what
happened here. It still printed the evidence that something was wrong while
nothing depended on it, which is the argument for printing it at all.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LVjrnpQjRgHdvq95rASoiR
|
||
|
|
efde3b188f |
refactor: the image carries its own healthcheck and picks it by role (4295)
CI / extension-version (push) Successful in 4s
CI / lint (push) Successful in 4s
extension / lint (push) Successful in 18s
Build images / sign-extension (push) Successful in 5s
Build images / build-agent (push) Successful in 7s
CI / frontend-build (push) Successful in 24s
CI / backend-lint-and-test (push) Successful in 33s
Build images / build-web (push) Successful in 1m42s
CI / integration (push) Successful in 2m11s
Build images / smoke-web (push) Successful in 57s
Build images / promote (push) Skipped
Operator, 2026-09-23: *"why isn't the healthcheck built into the image or
base on what command runs if one is passed in. why is it manually declared in
the stack here."*
No good reason. The container is the only thing that knows what it was asked
to run, and every compose file, stack file and README had to restate it:
web -> urllib /api/health
worker -> celery inspect ping -d celery@$HOSTNAME
all -> both, for every lane
Three checks written by hand, once per service, in every file anyone ever
wrote — none of them wrong until a role changed, and all of them silently
wrong after. The same duplication the lane table exists to remove one level
down, and I built it without noticing.
`entrypoint.sh` now records the role it started. The Dockerfile declares ONE
`HEALTHCHECK` that reads it and asks the right question: HTTP for web, a
self-addressed celery ping for a worker lane, both-for-every-lane for `all`,
and nothing for shell/alembic, which are one-shot and have no liveness to
probe. `docker-compose.single.yml` and the consolidated stack declare none.
A service that wants something else can still declare its own; docker prefers
it, so the escape hatch is the default docker behaviour rather than a flag.
Two details that are load-bearing:
* The role is written ONCE, by the outermost invocation. `all` starts the
other roles through this same script under supervisord, and a child
overwriting the container's role would turn the composite check into a
web-only one — silently, and only on the consolidated path. FC_ROLE is
exported so a child sees it set and skips.
* The celery ping is addressed to THIS node, not a bare ping. A bare one is
answered by any worker on the broker, so in a stack with replicas a dead
container would report healthy for as long as a sibling lived — the check
would be measuring the cluster rather than the container it is inside.
`healthcheck_all.py` is deleted; its two probes moved into the dispatcher
rather than being a second copy beside it.
An unrecorded role PASSES. The entrypoint always writes the file, so the only
way to miss it is bypassing the entrypoint — a debugging shape, where a check
that cannot tell what it is looking at must not assert the thing is broken
(snippet #3969). Said on stdout rather than assumed.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LVjrnpQjRgHdvq95rASoiR
|
||
|
|
828c6a5ae3 |
fix: each lane needs its own celery node name, or three of four vanish (4295)
CI / lint (push) Successful in 3s
CI / extension-version (push) Successful in 2s
CI / frontend-build (push) Successful in 19s
CI / backend-lint-and-test (push) Successful in 31s
CI / integration (push) Successful in 2m6s
Build images / sign-extension (push) Successful in 3s
Build images / build-agent (push) Successful in 6s
Build images / build-web (push) Successful in 1m52s
Build images / smoke-web (push) Successful in 56s
Build images / promote (push) Skipped
Found by the all-role smoke on its very first execution (run 7319), which is
the whole argument for having added it one commit ago.
Celery's default node name is `celery@<hostname>`. In the single-container
layout all four lanes share one hostname, so all four registered as the SAME
node. Celery says so itself:
DuplicateNodenameWarning: Received multiple replies from node name:
celery@72adc5b706a7
`inspect` collapses four replies into one dict key and the last one wins, so
three lanes read as absent — and WHICH three varies between calls:
lanes not answering: maintenance_long, ml, worker
lanes not answering: maintenance_long, scheduler, worker
Fatal twice over:
* The composite healthcheck can never pass. In Swarm that is a container
that never goes healthy — restart loop, then an automatic rollback of a
deploy whose image was fine.
* `pool_grow`/`pool_shrink` take a `destination` of node names. The UI dial
and the autoscaler would have resized whichever lane happened to answer
rather than the one asked for — silently, and differently each time.
Every celery role now starts with `-n "${CELERY_NODENAME:-celery}@%h"`, and
the generated supervisord config sets that per lane. The lanes become
worker@<cid>, scheduler@<cid>, maintenance_long@<cid>, ml@<cid> — distinct,
so inspect keeps four entries and `destination` addresses what it names.
`inspect_lanes_sync` maps hostname to lane by QUEUES, so nothing there
changes; it just stops having three of its four entries overwritten.
Unset, it falls back to `celery` — exactly celery's own default — so every
service in the multi-service stack is byte-identical to before, including the
`celery@$HOSTNAME` healthcheck in docker-compose.yml and in the operator's
Swarm stack.
The test asserts DISTINCTNESS across the whole lane table rather than a fixed
string per lane. The property that broke is that no two collide, and stating
it that way keeps holding when a lane is added.
This is the bug I said a live deploy was needed to find, found in CI instead
for the price of one `docker run` — and it would have met the operator as a
rollback loop on their first consolidated deploy.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LVjrnpQjRgHdvq95rASoiR
|
||
|
|
43ac737516 |
feat: the whole application is what the image runs by default (4296)
CI / lint (push) Successful in 4s
CI / extension-version (push) Successful in 3s
extension / lint (push) Successful in 19s
CI / frontend-build (push) Successful in 24s
CI / backend-lint-and-test (push) Successful in 32s
Build images / sign-extension (push) Successful in 5s
Build images / build-agent (push) Successful in 7s
Build images / build-web (push) Successful in 1m41s
CI / integration (push) Successful in 2m7s
Build images / smoke-web (push) Failing after 12m36s
Build images / promote (push) Skipped
Operator, 2026-09-23: *"I also want to see that we remove the need for the command line of the configuration in the consolidated version."* `CMD` was `web`, so the single-container layout only worked if you knew to ask for it by name. A compose file that forgot `command: ["all"]` got a web server with nothing processing its queues — a gallery that loads, accepts an import, and never finishes one. Nothing errors; it just never progresses. Now `docker run fabledcurator` with no command starts hypercorn plus every lane under supervisord. `entrypoint.sh`'s own default moves with it, since the two are doors to the same decision and a disagreement would only show up as `--entrypoint` behaving differently from a plain run. `docker-compose.single.yml` drops its `command:` line; `["all"]` still works and still means the same thing. The multi-service stack is untouched — every service there names its role explicitly, which is what makes it the multi-service stack. ## And CI now actually boots it This is the gap I should have named when I reported milestone 422 at 7/7 and did not. Measured, not inferred: the smoke booted role `web` only (build.yml:1454), nothing in CI ran `all`, `docker-compose.single.yml` was read as TEXT by one test checking stop_grace_period and never run, and test_gen_supervisord asserts the generated config against the lane table without ever handing it to supervisord. So the shape this milestone is NAMED for had started nowhere. Steps 5-7 were marked done on evidence that did not cover it, and the operator is about to collapse their production stack onto exactly that. The smoke now boots the image with NO command — checking the Dockerfile CMD, the entrypoint default and the role together, the way an adopter gets it — and asserts `healthcheck_all`, which was itself never executed. That check passes only when hypercorn answers AND every lane in the table answers the broker; a web-only check goes green with every worker dead, which is the failure mode consolidation creates. It then prints `supervisorctl status`, so a lane that is merely restart-looping is visible rather than inferred. Cheap because ml ships at 0 slots and disabled: nothing loads a model, and the lane answers `inspect` with its consumers cancelled, which is what healthy means for a disabled lane. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LVjrnpQjRgHdvq95rASoiR |
||
|
|
2677ce020c |
fix: the ml artifact was in a second list I never grepped for (4311)
CI / lint (push) Successful in 3s
CI / extension-version (push) Successful in 3s
extension / lint (push) Successful in 18s
CI / frontend-build (push) Successful in 24s
CI / backend-lint-and-test (push) Successful in 32s
Build images / sign-extension (push) Successful in 2s
Build images / build-agent (push) Successful in 5s
Build images / build-web (push) Successful in 5s
Build images / smoke-web (push) Successful in 26s
Build images / promote (push) Skipped
CI / integration (push) Successful in 2m11s
|
||
|
|
ac70f2aadc |
refactor: retire the fabledcurator-ml image name entirely (4311)
CI / lint (push) Successful in 2s
CI / extension-version (push) Successful in 2s
Build images / sign-extension (push) Successful in 4s
Build images / build-agent (push) Successful in 7s
CI / frontend-build (push) Successful in 18s
extension / lint (push) Successful in 19s
CI / backend-lint-and-test (push) Failing after 32s
Build images / build-web (push) Successful in 1m37s
Build images / smoke-web (push) Successful in 42s
Build images / promote (push) Skipped
CI / integration (push) Successful in 2m11s
Operator, 2026-09-23: *"we don't need to keep this as I'll be collapsing my
stack to the same consolidated version that we're building here."*
That was the only thing holding the name up. `fabledcurator-ml` has been the
same bytes as `fabledcurator` since milestone 422 step 6, and
|
||
|
|
f0595e43e6 |
docs: record what the alias push actually costs — zero layer bytes (4311)
CI / lint (push) Successful in 2s
CI / extension-version (push) Successful in 2s
Build images / sign-extension (push) Successful in 3s
Build images / build-agent (push) Successful in 6s
Build images / build-web (push) Successful in 5s
extension / lint (push) Successful in 16s
Build images / build-ml (push) Successful in 3s
CI / frontend-build (push) Successful in 20s
CI / backend-lint-and-test (push) Successful in 30s
Build images / smoke-web (push) Successful in 25s
Build images / promote (push) Skipped
CI / integration (push) Successful in 2m9s
The comment said the first run would answer whether the registry shares blobs across two repositories of one owner. Run 7300 answered it: every layer came back "Layer already exists" or "Mounted from bvandeusen/fabledcurator". The push moves a manifest and nothing else. 22 seconds, against the ~2 minutes the duplicate build cost (run 7282: build-ml 1m55s for output identical to build-web's). Also noted: the pushed manifest gets its own digest, because docker re-serialises it — same layers, same config, same fc.revision, different manifest bytes. Nothing compares the two, but the digests differing is the first thing someone will notice and misread as a second build still happening. Leaving a "we do not know yet" in the file once the run has said would make it the kind of stale note that misleads the next person to read it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LVjrnpQjRgHdvq95rASoiR |
||
|
|
815268418e |
perf: stop building the same image twice under two names (4311)
CI / lint (push) Successful in 3s
CI / extension-version (push) Successful in 3s
Build images / sign-extension (push) Successful in 4s
Build images / build-agent (push) Successful in 6s
CI / frontend-build (push) Successful in 23s
Build images / build-web (push) Successful in 5s
extension / lint (push) Successful in 23s
CI / backend-lint-and-test (push) Successful in 32s
Build images / build-ml (push) Successful in 22s
Build images / smoke-web (push) Successful in 30s
Build images / promote (push) Skipped
CI / integration (push) Successful in 2m17s
`fabledcurator-ml` has been the SAME IMAGE as `fabledcurator` since milestone 422 step 6 merged the ML layers into the one Dockerfile. `build-ml` was still running `file: Dockerfile, context: .` — byte for byte what `build-web` builds — and publishing the result under a second name. So CI did the whole build twice. Measured on run 7282, cold cache: build-web 1m54s, build-ml 1m55s, for identical output, plus a second push of a few hundred MB. Operator, 2026-09-22: *"please fix the CI so it doesn't do this superfluous work."* build-ml now publishes nothing of its own. It pulls the manifest build-web just pushed, re-tags it, and pushes that — so the second name goes on working for the operator's Swarm stack, which still references it, while CI stops compiling anything twice. 437 lines of duplicated build machinery go with it: its own buildx setup, its own reuse check, its own version derivation, its own tag repoint. It now `needs: [build-web]` rather than running in parallel. That is the one cost, and it is seconds: there is nothing to copy until build-web has decided what this run publishes, and the layers are already on the runner. Deliberately NOT `imagetools create`, which would have been the obvious one-liner. That wraps its source in an INDEX and `.Image.Config.Labels` does not resolve through one (#3183, run 4751) — and it would bite harder here than it did there, because `promote` asks the registry for IMAGE manifest media types ONLY, so an index at :refresh-candidate would fail its `test -n "$CT"` and break the weekly refresh for this name. Plain pull/tag/push keeps a plain manifest, which rule 40 also asks for. The step reads the tag back and fails if `fc.revision` stops resolving, so a future change that reintroduces index-wrapping says so instead of quietly costing the refresh. The tag list mirrors build-web's exactly, refresh included: a refresh writes :refresh-candidate alone and `promote` moves it to :latest after smoke-web passes. So this name keeps the gate it has today and `promote` needs no change — it already loops over all three image names. Source is build-web's `digest` when it built, else its `published_digest` on a reuse hit — the digest its channel tag already names, which is what a hit MEANS. With neither, the job fails with a reason rather than publishing something arbitrary. This does not retire the name; #4311 still does that, and is still gated on the operator's stack pointing ml-worker at `fabledcurator:latest` with `command: ["ml-worker"]`. Until then nothing of theirs changes. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LVjrnpQjRgHdvq95rASoiR |
||
|
|
187b6d2cdf |
fix: the smoke always runs, so a change to the smoke can verify itself (4323)
CI / lint (push) Successful in 4s
CI / extension-version (push) Successful in 4s
Build images / sign-extension (push) Successful in 4s
Build images / build-ml (push) Successful in 7s
Build images / build-web (push) Successful in 5s
Build images / build-agent (push) Successful in 7s
CI / integration (push) Successful in 2m10s
extension / lint (push) Successful in 18s
CI / frontend-build (push) Successful in 18s
CI / backend-lint-and-test (push) Successful in 33s
Build images / smoke-web (push) Successful in 26s
Build images / promote (push) Skipped
This workflow file is in no artifact's path set — correctly, since editing it changes no shipped byte. But smoke-web was gated on build-web having published something, so a commit touching ONLY the smoke moved no revision, hit reuse, emitted no digest, and skipped the smoke. The one commit whose purpose is changing this check was the one commit that could not run it. Twice already: |
||
|
|
7175ace67a |
fix: the smoke's health probe has to run inside the sandbox it created (4319)
CI / lint (push) Successful in 3s
CI / extension-version (push) Successful in 3s
Build images / sign-extension (push) Successful in 5s
Build images / build-agent (push) Successful in 6s
Build images / build-ml (push) Successful in 6s
Build images / build-web (push) Successful in 5s
Build images / smoke-web (push) Skipped
Build images / promote (push) Skipped
CI / frontend-build (push) Successful in 19s
extension / lint (push) Successful in 21s
CI / backend-lint-and-test (push) Successful in 32s
CI / integration (push) Successful in 2m11s
The first real execution of the egress-blocked smoke (run 7282) failed with
"web is running but never answered /api/health" — and the application was
perfect. Its own log shows all four hypercorn workers serving three seconds
after start and still up seven minutes later, with no internet:
[entrypoint] Starting hypercorn on :8080
[14:08:58] [10] [INFO] Running on http://0.0.0.0:8080
[14:08:58] [11] [INFO] Running on http://0.0.0.0:8080
[14:08:58] [12] [INFO] Running on http://0.0.0.0:8080
[14:08:58] [13] [INFO] Running on http://0.0.0.0:8080
|
||
|
|
a01165365b |
feat: a saturated lane can grow itself, within the cap the operator set (4297)
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 2s
CI / extension-version (push) Successful in 3s
Build images / build-agent (push) Successful in 6s
CI / frontend-build (push) Successful in 22s
CI / backend-lint-and-test (push) Successful in 36s
Build images / build-ml (push) Successful in 1m55s
Build images / build-web (push) Successful in 1m54s
CI / integration (push) Successful in 2m16s
Build images / smoke-web (push) Failing after 7m48s
Build images / promote (push) Skipped
Milestone 422 step 7 — the one sweep in this milestone that decides rather than obeys, so it is off until a lane is opted in, bounded by the operator's cap, floored at the operator's value, and it reports every decision including the ones where it did nothing. Growth needs BOTH halves: all slots busy AND a backlog. Depth alone means celery is about to pick those up and growing would add idle children (#1253 is that bug in the GPU agent); saturation alone means the lane is busy with exactly as much work as exists. The backlog is depth PLUS reserved, because celery prefetches and LLEN reads 0 while a worker holds thirty tasks in memory — the case an LLEN-only autoscaler misses entirely, and the reason step 2 plumbed `reserved` through. The two sweeps had to be taught not to fight. The reconcile drives every lane to its stored slots every five minutes, which would have reverted each grow on the next tick: grow, revert, grow, revert, forever. For an autoscaling lane the stored value is now a FLOOR — restored when a lane falls below it, never taken back above it. The operator's "a task that runs for x concurrent time" idea stays a UI warning rather than a trigger: a long task does not finish sooner because the lane gained a slot, so scaling on it would spend memory to change nothing. Read from `task_run` on our own wall clock, not celery's `time_start`, which is the WORKER's monotonic clock and would produce a duration that is meaningless in the direction that matters — plausible. Caught while reading it back: the first version read the stored slots as the CURRENT pool. The autoscaler never writes that row, so every tick would have proposed floor+1 — resizing nothing, reporting `grew` anyway (a replica already past the target is issued no message and reports success), and capping the lane one slot above its floor forever while claiming otherwise. It now reads the live pool and keeps the stored value purely as the floor, and the tests fix the two to different numbers so an equal-fixture pass cannot hide it again. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LVjrnpQjRgHdvq95rASoiR |
||
|
|
5ca1058fb5 |
test: the smoke runs with egress blocked, on every build, and proves the block (4296)
CI / lint (push) Successful in 2s
CI / extension-version (push) Successful in 2s
Build images / sign-extension (push) Successful in 3s
Build images / build-ml (push) Successful in 6s
Build images / build-agent (push) Successful in 6s
Build images / build-web (push) Successful in 5s
Build images / smoke-web (push) Skipped
Build images / promote (push) Skipped
CI / frontend-build (push) Successful in 18s
CI / backend-lint-and-test (push) Successful in 31s
extension / lint (push) Successful in 22s
CI / integration (push) Successful in 2m16s
Milestone 422 step 6's rule-164 half. THE PROPERTY WAS NEVER TESTED, not even weekly. smoke-web already booted the built image against real Postgres and Redis — but on the runner's default network, with the internet one hop away. It proved the image WORKS; it never proved it works OFFLINE, which is the thing rule 164 is about and the thing step 6 put at risk by moving download_models out of boot. Now on an `--internal` docker network, which is the mechanism rule 164's own verify_with names. `--network none` is explicitly the wrong check here: it would only prove the app fails without a database, which says nothing about egress. Internal blocks the default route while leaving container-to-container traffic and embedded DNS intact, so Postgres and Redis stay reachable and nothing else does. The service containers are runner-created siblings, so they are ATTACHED to the network rather than created on it, and their addresses are re-read on it — the bridge IPs discovered earlier are not routable from a container that is only on the internal network. A STEP THAT PROVES THE SANDBOX IS SEALED, before anything depends on it. It tries to reach 1.1.1.1:443 from inside the candidate and fails the job if it succeeds. Without it the rest is theatre: if `--internal` silently stopped working, or the container picked up a second network, every check below would pass with the internet available and report an offline boot that never happened. A guard that cannot fail is not a guard (rule 167). IT RUNS ON EVERY BUILD, not just the weekly refresh. The egress property is broken by a code or Dockerfile change — a push — so checking it only on the refresh would test it on the one trigger that changes no source. Addressed by the DIGEST build-web published rather than by a tag: a tag can move between the build and the smoke, and then the check reports on bytes nobody built here. A reuse hit is skipped, because those bytes were smoked when built. WHAT THIS STILL IS NOT, filed as #4310: on a push it runs AFTER build-web has written the channel tag, so it detects rather than gates. Rule 164 asks for the check BETWEEN build and push. Closing that means giving the push path the candidate-then-promote shape the refresh already has — per-channel candidate tags, promote learning its channel, and the :c-<sha> repoint moving after the gate. That is a redesign of the path that publishes production and it is not something to fold into a test change. Also filed #4311: retiring the fabledcurator-ml image NAME, gated on the operator moving their Swarm stack file. Same two-phase shape #406 used for pixiv, for the same reason — dropping it would not break their deploy, it would freeze it silently. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LVjrnpQjRgHdvq95rASoiR |
||
|
|
0f98e46200 |
docs: the merged image's cost, measured — and it corrects my own estimate (4296)
CI / lint (push) Successful in 4s
CI / extension-version (push) Successful in 3s
Build images / sign-extension (push) Successful in 4s
Build images / build-agent (push) Successful in 7s
CI / frontend-build (push) Successful in 24s
CI / backend-lint-and-test (push) Successful in 32s
Build images / build-ml (push) Successful in 2m6s
Build images / build-web (push) Successful in 1m59s
Build images / smoke-web (push) Skipped
Build images / promote (push) Skipped
CI / integration (push) Successful in 2m16s
I wrote in ffcd130's Dockerfile comment that merging ML in means "everyone
pulls it, including the many who will never turn tagging on", framed as a
real cost the milestone accepted. My working estimate behind that was ~4GB.
Measured from run 7273's build-web log:
torch 2.12.1+cpu wheel 192.3 MB
torchvision 0.27.1+cpu 1.8 MB
transformers / onnxruntime / opencv / sklearn and friends
62.0, 35.3, 23.6, 16.7, 12.3, 9.2, 6.9 MB
largest newly-pushed layer 222.07 MB
The ML code adds a few HUNDRED MB, not gigabytes. The `--index-url` CPU
resolution is what makes that true — the default PyPI torch wheel carries the
CUDA runtime and is ~2GB by itself, and the log confirms 2.12.1+cpu resolved,
so it is working as intended rather than as intended-but-unverified.
Why this matters beyond a comment being wrong: it settles the trade this step
was explicitly asked to weigh and could not, and it reverses how close the
call looked. Baking the weights in adds ~3.5GB to every pull for a feature
many adopters never enable; shipping the code and fetching on demand adds
~350MB. An order of magnitude, where the estimate had them within 15% of each
other. Off-by-default is not a judgement call here, it is arithmetic.
The gigabytes were always in the MODEL, and the model is not in the image.
Two things NOT measured, still: the total image size (the push only transfers
layers the registry lacks, so a push log cannot give it) and the per-slot
resident RAM, which stays flagged `measured=False` in the lane table and
renders as "about" in the UI.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LVjrnpQjRgHdvq95rASoiR
|
||
|
|
f174981b07 |
fix: ml and web are one image, so they are one path set (4296)
CI / lint (push) Successful in 3s
CI / extension-version (push) Successful in 4s
Build images / sign-extension (push) Successful in 4s
Build images / build-agent (push) Successful in 7s
CI / frontend-build (push) Successful in 24s
CI / backend-lint-and-test (push) Successful in 33s
Build images / build-ml (push) Successful in 1m52s
Build images / build-web (push) Successful in 1m58s
Build images / smoke-web (push) Skipped
Build images / promote (push) Skipped
CI / integration (push) Successful in 2m24s
Run 7271. `test_every_copied_path_is_in_the_artifacts_path_set` — a guard I did not know this repo had — caught the merge's real consequence within minutes, and it is precisely #3202's class: ml: Dockerfile copies 'frontend/package.json' into the ml image, but no include in scripts/artifacts.sh covers it. web: Dockerfile copies 'requirements-ml.txt' into the web image, but no include covers it. Both follow from step 6 and neither was visible by reading my own diff. The merged Dockerfile carries the frontend-builder stage, so the ml image now copies frontend files; and the web image now installs the ML requirements. Left as they were, each artifact's derived version would have stopped moving when those files changed, and a pinned build would serve stale bytes — silently, since nothing fails when a version simply does not advance. The fix is structural rather than two more entries. `fabledcurator-ml` IS web's image now: same Dockerfile, same context, same bytes, published under a second name only because the operator's Swarm stack still references it. So ML_PATHS is WEB_PATHS by assignment, and `cmd_paths` gives ml the same deriver and extension append — the XPI is in those bytes too. Two lists describing one image is the duplication this milestone has been collapsing all day. It existed for about an hour and the guard found it first, which is the argument for the guard. I also predicted this failure would be the image build. It was not; the build passed and the unit lane failed. Worth noting because the prediction was confident and wrong, and reading the log took one call. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LVjrnpQjRgHdvq95rASoiR |
||
|
|
ffcd13096a |
feat: one image for every lane, with the model fetch gated on enabling (4296)
CI / lint (push) Successful in 2s
CI / extension-version (push) Successful in 2s
Build images / sign-extension (push) Successful in 3s
Build images / build-agent (push) Successful in 6s
CI / frontend-build (push) Successful in 20s
CI / backend-lint-and-test (push) Failing after 31s
extension / lint (push) Successful in 23s
CI / integration (push) Successful in 2m16s
Build images / build-ml (push) Successful in 3m8s
Build images / build-web (push) Successful in 3m16s
Build images / smoke-web (push) Skipped
Build images / promote (push) Skipped
Milestone 422 step 6. Dockerfile.ml is gone; the main image carries torch,
torchvision, transformers, onnxruntime and opencv, and serves every lane.
WHY IT HAD TO MERGE: step 5 runs every lane in one process tree, so a second
image would mean the `ml` lane could never be enabled from the UI — there
would be no worker in that container to enable. The switch needs something to
switch.
THE MODEL NO LONGER DOWNLOADS AT BOOT. `entrypoint.sh`'s ml-worker role ran
download_models before celery started, so every boot of that role reached
HuggingFace for ~3.5GB — a startup dependency on a third party for a feature
the operator may never use. Rule 164 permits a runtime fetch only for
something "optional and clearly off", so the fetch is now a TASK, enqueued
the moment the lane is ENABLED.
Being a task is what makes it visible: it gets a TaskRun row, so the download
shows in Activity with a duration and a status, and a failure is something an
operator can see and retry rather than a container that quietly never became
useful. Idempotent, so re-enabling a provisioned lane costs one no-op.
Enqueued only when the lane actually came ON (`enabled is True`, not the
resolved value) so re-saving slots does not re-fetch, and only when the
consumer change landed — a task queued onto a queue nothing consumes would
sit pending with no explanation.
`fabledcurator-ml` KEEPS PUBLISHING, from the merged Dockerfile. The
operator's Swarm stack references that name and lives outside this repo;
dropping it would not break their deploy, it would freeze it silently at the
last publish — the exact failure class this milestone keeps finding. Retiring
the NAME is its own task, gated on that stack moving. Same two-phase shape
#406 used for pixiv.
THREE LIVE BREAKAGES from deleting the file, found by grepping for it rather
than assuming the build was the only consumer:
- `docker-compose.override.yml` built the ml service from it (contributor
path would have failed at `docker compose build`).
- `tests/test_artifact_paths.py` pins the ml path set.
- `scripts/artifacts.sh` ML_PATHS named it. A path set naming a deleted file
silently stops contributing to the derived revision — which the reuse check
and the version string both read. That is #3202's recorded shape.
The `--with-ml` flag is gone from the generator and the healthcheck rather
than left defaulting to true. One image carries every lane now, so a flag
that can only be passed one way is a branch pretending to be a choice.
The advisory shipped in
|
||
|
|
ecbd325437 |
feat: an optional lane says it is optional, and what enabling it costs (4296)
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 2s
Build images / build-agent (push) Successful in 5s
CI / extension-version (push) Successful in 2s
CI / frontend-build (push) Successful in 23s
CI / backend-lint-and-test (push) Successful in 31s
Build images / build-web (push) Successful in 1m21s
Build images / smoke-web (push) Skipped
Build images / build-ml (push) Successful in 2m12s
Build images / promote (push) Skipped
CI / integration (push) Successful in 2m39s
Operator, 2026-09-22: "since the ml-worker is optional it should be shown as such in the UI and have a warning about what it does and that it pulls the models and what models and their projected size and ram requirements to run." The card previously said "a few GB, once" — a number sourced from nothing, which is exactly the hand-wave I had flagged in this step's own survey log as something that should be measured rather than asserted. ONE FACT CORRECTED WHILE WRITING THE COPY. I had named the lane "ML tagging". It downloads an EMBEDDER: google/siglip-so400m-patch14-384. WD14 tagging is the GPU agent's job — celery_app.py:5 still names both, but that has been stale since B3 (#1238), when the agent took over and this lane was left as the CPU embed fallback for stacks running no agent (see MLSettings.cpu_embed_enabled). Telling someone the lane "does tagging" would have been wrong in exactly the way this request exists to prevent. The facts are structured data on the lane, not prose in a component: ModelRequirement(repo, approx_download_bytes, approx_resident_bytes, measured). The API carries them; the card renders them. Numbers come from the system, wording from the UI. ML_BYTES_PER_SLOT IS NOW DERIVED from that requirement rather than stated separately. They have to be one number: the figure quoted to the operator before they enable the lane and the figure the cap enforces. Two copies could disagree, and the UI would promise a slot the cap then refuses. `measured=False` travels with the numbers and the card renders "about". They are estimates from the checkpoint's parameter count and dtype — ~877M params at fp32 is ~3.5GB of weights — not from a build. This decides whether someone's server survives, so it is labelled rather than rounded into something that reads like a fact. A test asserts the flag is false, to be flipped in the same commit that records a real measurement. The card now shows: an "optional" chip in the row itself (someone scanning the table should not have to enable a lane to learn it was never required), and before the switch, what the lane does, that you only need it if you are NOT running the GPU agent, the repo id, the download size, the per-slot RAM, and why the ceiling is what it is — including saying plainly when a box has too little memory to run it at all. Keyed on the lane's own `optional` flag, not on the name 'ml', so a second optional lane gets the same treatment without anyone remembering to add it. A test asserts no REQUIRED lane declares a model: if one ever needs a download, it stops being required. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LVjrnpQjRgHdvq95rASoiR |
||
|
|
e579333455 |
docs: record that the ml :ro loss is a ruled non-issue (4295)
CI / lint (push) Successful in 2s
CI / extension-version (push) Successful in 2s
Build images / sign-extension (push) Successful in 3s
Build images / build-agent (push) Successful in 6s
Build images / build-ml (push) Successful in 6s
CI / backend-lint-and-test (push) Successful in 32s
Build images / build-web (push) Successful in 5s
Build images / smoke-web (push) Skipped
Build images / promote (push) Skipped
CI / frontend-build (push) Successful in 23s
CI / integration (push) Successful in 2m16s
Operator, 2026-09-22: "I don't care about the :ro loss thank you for calling it out repeated but I don't care." Raised three times across the milestone body, this file's header and two reports. Recorded as settled at the point someone would rediscover it, so it is not raised a fourth time. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LVjrnpQjRgHdvq95rASoiR |
||
|
|
172e33de9a |
feat: run web and every worker lane in one container (4295)
CI / lint (push) Successful in 3s
CI / extension-version (push) Successful in 3s
Build images / sign-extension (push) Successful in 4s
CI / backend-lint-and-test (push) Successful in 36s
Build images / build-agent (push) Successful in 7s
CI / frontend-build (push) Successful in 23s
Build images / build-web (push) Successful in 1m9s
Build images / smoke-web (push) Skipped
Build images / build-ml (push) Successful in 2m8s
Build images / promote (push) Skipped
CI / integration (push) Successful in 2m37s
Milestone 422 step 5. `docker compose -f docker-compose.single.yml up -d` gives three containers — FabledCurator, Postgres, Redis — where the stack previously needed seven. THE MULTI-SERVICE STACK IS KEPT. docker-compose.yml still runs the five app services separately and remains the right shape for a Swarm deployment spread across hosts, where per-service rolling rollback and placement constraints matter. This adds a compose file; it deletes none. `entrypoint.sh all` GENERATES the supervisord config from worker_lanes.LANES and execs it as PID 1. Generated rather than checked in because a static .conf would spell out each lane's -Q list, making a FIFTH hand-kept copy of the queue names — after celery_app.task_routes and the three collapsed in steps 1, 2 and 4. Every one of those had already drifted when found. Generating gives a stronger guarantee than "they match today": a lane added to LANES gets a process, and a queue cannot end up with no consumer because someone missed a file. supervisord over s6-overlay: one pip dependency on an image already Python, with per-program stop timeouts and stopasgroup. The process-group part is not a detail — celery's prefork pool forks children, and a TERM reaching only the parent leaves them orphaned holding tasks. s6's advantage (PID-1 signal and zombie handling) comes from `init: true` instead. Nothing in FC talks to the supervisor, so the choice is reversible without touching product code. FOUR LANES, NOT FIVE. The ml lane is skipped: torch and the ML requirements live only in Dockerfile.ml until step 6 merges the images, so an `ml` program here would fail to import on every restart forever. `--with-ml` is the flag step 6 turns on. THREE BUGS FOUND BY READING IT BACK, none of which the first tests caught: 1. `environment=CELERY_QUEUES=default,import,thumbnail,download` — supervisord parses that key as a COMMA-separated list, so it reads as CELERY_QUEUES=default plus three malformed entries and the worker lane would have consumed only `default`. Silent: the worker starts, reports healthy, never picks up an import. Now quoted, and the test asserts the quoted form rather than the bare substring, which passed either way. 2. The generator emitted `entrypoint.sh <lane.name>`, but `maintenance_long` is not a role — compose runs it as the plain `worker` role with different queues. Lane now carries `entrypoint_role`, and a test reads entrypoint.sh to assert every role a lane names actually exists. 3. The `scheduler` role hardcoded --concurrency=1, ignoring CELERY_CONCURRENCY. Harmless while only compose started it and set none; with a generated value being passed, the lane would have sat at 1 until the reconcile noticed, with nothing saying why. The healthcheck asserts BOTH halves — hypercorn answers and every configured lane is answering the broker. That is the failure mode consolidation creates: docker can no longer see the lanes as separate services, so a web-only check would report a healthy container with every lane inside it dead. It deliberately ignores the `enabled` flag: a disabled lane still has a running process with its consumers cancelled, and marking the container unhealthy for turning tagging off would be wrong. stop_grace_period 200s, sized to the slowest lane (maintenance_long at 180s) rather than the average, with a test asserting no program's stopwaitsecs can exceed what compose allows. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LVjrnpQjRgHdvq95rASoiR |
||
|
|
da48edf7da |
feat: the worker-lanes card — see each lane, change its slots (4294)
CI / lint (push) Successful in 4s
CI / extension-version (push) Successful in 4s
Build images / sign-extension (push) Successful in 5s
Build images / build-agent (push) Successful in 7s
Build images / build-ml (push) Successful in 7s
CI / frontend-build (push) Successful in 20s
CI / backend-lint-and-test (push) Successful in 34s
Build images / build-web (push) Successful in 59s
Build images / smoke-web (push) Skipped
Build images / promote (push) Skipped
CI / integration (push) Successful in 2m19s
Milestone 422 step 4. Rule 27: no UI, no ship. This is where the previous three steps become usable. REACHABLE AT: Settings -> Activity -> Worker lanes, directly under the "Queues + workers" pane. Written from opening the view, not from memory of having built it — lesson #4282, and #3463 is the same trap landing inside milestone 365, where the System page shipped with no navigation to it. Under that pane deliberately, not in the System tab. System answers "is everything running", where every control would be about something broken. This is about something working that should work harder, and it belongs beside the backlog it reacts to: you watch a queue grow and give that lane another slot without leaving the pane. Per lane: queues, pending, busy, a stepper, an enable switch. - PENDING is depth + reserved, not LLEN. Celery prefetches, so LLEN alone reads 0 while a worker holds tasks in memory — the number that would make someone think a buried lane was idle. - NOT ANSWERING, never "stopped". present=false means nothing replied; saying stopped would send the operator looking for a crash that has not happened. - THE CEILING IS ON SCREEN, with "(memory)" on the ML lane. It is the one number here the operator cannot change, so it has to justify itself; a greyed stepper with no explanation reads as a bug. - `busy` is per lane, so adjusting one does not freeze the others. TWO OUTCOMES THAT MUST NOT COLLAPSE INTO ONE MESSAGE. A stored-but-unpushed change (applied:false — the lane is restarting) is information: the value is saved and the reconcile will carry it, so the card says so and invites waiting. A refused value (400) is an error and shows the endpoint's own sentence. Collapsing them would make one of the two invite the wrong action. A BUG CAUGHT BEFORE COMMIT: the card read `e.detail?.detail`, but ApiError puts the parsed body on `.body` and `.message` on the short `error` key. It would have shown the operator the bare word "refused" with no reason — the exact failure that line exists to prevent. The test would not have caught it either: `rejects.toThrow()` passes whether the sentence is reachable or not. It now asserts `err.body.detail` specifically. Also: queueOptions in SystemActivityTab was a fourth hand-kept copy of the queue list and had drifted — `maintenance_long` was missing, so activity on that lane could not be filtered for at all despite four task routes pointing there. Added, and DELIBERATELY left as a written-out list rather than derived like the other three were: this filters task_run HISTORY, so a derived list would hide the filter for any queue that has rows but no longer has a lane — precisely when someone is looking — and would be empty whenever the endpoint is down. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LVjrnpQjRgHdvq95rASoiR |
||
|
|
5f8c63f61b |
feat: reconcile every running lane back to its stored slots (4293)
CI / lint (push) Successful in 2s
CI / extension-version (push) Successful in 2s
CI / frontend-build (push) Successful in 24s
Build images / sign-extension (push) Successful in 4s
Build images / build-agent (push) Successful in 6s
CI / backend-lint-and-test (push) Successful in 32s
Build images / build-web (push) Successful in 1m1s
Build images / smoke-web (push) Skipped
Build images / build-ml (push) Successful in 1m53s
Build images / promote (push) Skipped
CI / integration (push) Successful in 2m23s
Milestone 422 step 3. `pool_grow` is not durable: a worker restarted by its supervisor comes back at its ENV concurrency, silently below whatever the operator set, and nothing on step 2's write path would ever notice. Storing the value made it survivable; this makes it survive. A BEAT TASK, NOT A HOOK IN WEB — a deliberate deviation from the step as written, for a reason already recorded in this codebase. Step 3 said "web applies the stored values after it starts". It cannot: service_roster.py documents that hypercorn runs --workers 4, so anything in before_serving becomes four concurrent loops per container hammering the broker forever. service_roster's own answer — refresh on demand from whichever request arrives — was also rejected, because the two solve different problems. A stale ROSTER only misleads someone looking at it, so recomputing when they look is exactly right. A lane running at the wrong size is doing less work than it was told to whether or not anyone is watching, and the case that matters is a deploy at 3am followed by a backlog nobody is awake to see. So: unattended, every 5 minutes, on the quick `maintenance` lane beside the other recovery sweeps. Accepted cost — a dead scheduler stops reconciliation, but a dead scheduler already stops every other sweep and the roster reports it, so this adds no new blind spot. A BUG I WROTE AND CAUGHT BEFORE COMMITTING. The first version called set_lane_enabled_sync unconditionally, so a settled system re-sent add_consumer for every queue on every tick — forever. Harmless per call (add_consumer on an already-consumed queue does nothing), unbounded in aggregate, and completely invisible. That is lesson #4183's failure mode exactly, in the very function whose docstring cites it. Worse, my test would not have caught it: it asserted only on grew/shrank. LaneLiveState now carries `consuming` — which queues a lane is actually serving, distinct from the queues it was configured with — so the reconcile compares before acting. The test now asserts ALL FOUR control families are silent on a settled tick, plus a new case for an already-disabled lane, which is the other half of the same fixed point. An absent lane is SKIPPED, not corrected. present=False means nothing answered, not zero slots; correcting it would be a conclusion from an unswept read (snippet #3969), and there would be nothing to send the message to. One lane failing does not stop the others. One inspect serves every lane: the two setters now take an optional pre-fetched LaneLiveState, so a tick costs one broker round trip rather than one per lane. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LVjrnpQjRgHdvq95rASoiR |
||
|
|
a9c1b421a7 |
feat: change a lane's slots on a running system, over the broker (4292)
CI / lint (push) Successful in 2s
CI / extension-version (push) Successful in 2s
Build images / build-agent (push) Successful in 6s
Build images / sign-extension (push) Successful in 4s
CI / frontend-build (push) Successful in 25s
CI / backend-lint-and-test (push) Successful in 31s
Build images / build-web (push) Successful in 55s
Build images / smoke-web (push) Skipped
Build images / build-ml (push) Successful in 1m41s
Build images / promote (push) Skipped
CI / integration (push) Successful in 2m8s
Milestone 422 step 2. `GET /api/system/workers` reports every lane joined to its live pool; `POST /api/system/workers/<name>` changes it. NO DOCKER SOCKET. Milestone 365 deferred "acting on the state" because restarting a dead worker needs a socket the web container deliberately does not have. That holds for restarting a CONTAINER; it does not hold for changing how much work a RUNNING worker does. celery's pool_grow / pool_shrink / add_consumer / cancel_consumer send a message over the Redis the app already uses, and the worker resizes itself. No new privilege, no new surface, and the security question that deferred this is never raised. PERSIST AND PUSH, in one call, in that order. pool_grow is not durable — a restart drops every lane to its env concurrency — so a UI that only pushed would lose the setting on the next deploy with nothing to show for it (lesson #4202). Storing alone would describe nothing until something restarted. A failed PUSH is not a failed setting: 200 with `applied: false` and a reason, so the UI says "saved, not yet live" rather than "that didn't work". Step 3's reconcile carries it when the lane answers again. PER-REPLICA DELTAS. `pool_grow(n, destination=[...])` adds n to EACH destination, so while `worker` runs `replicas: 2` a single delta from an aggregate is wrong for both. `slots` therefore means what CELERY_CONCURRENCY means — one process's pool — and each replica is driven to it from its OWN current size, so replicas that drifted apart converge rather than moving in lockstep. I wrote this wrong first: the docstring claimed per-replica while the code computed one delta from the max across replicas. LaneLiveState now carries `pools` per hostname and exposes `pool` as a property. A replica already at the target is sent nothing at all — the reachable fixed point step 3's periodic reconcile needs, or it re-issues a grow of zero every tick forever (lesson #4183). A replica that answered inspect but not stats is NAMED in the error rather than skipped silently, since otherwise it would run at a size the UI claims it does not. `present=False` is not "zero slots", it is "nothing answered" — kept distinct throughout, because step 3 skips an absent lane rather than correcting it. /workers now also reports pool size (from `insp.stats()`) and RESERVED count. Celery prefetches, so tasks that have left the Redis list but not started are invisible to LLEN: a lane can read depth 0 with thirty tasks held in worker memory. `pending` is depth + reserved. The UI is misleading without this and step 7's autoscaler would be simply wrong. Also kills the THIRD copy of the queue list: system_activity's _QUEUE_NAMES, whose own comment admitted the coupling ("must match celery_app.task_routes") and which sat alongside task_routes and the ROLE_NAMES copy step 1 collapsed. Now derived from LANES. The rendered order changes to lane grouping, which is the better shape for a lane-oriented UI. Separate blueprint rather than folding into system_activity, which states in its first line that it is read-only and answers a different question — its /workers is keyed on celery HOSTNAME and reports which nodes answered. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LVjrnpQjRgHdvq95rASoiR |
||
|
|
5974a1bfbc |
fix: two errors in the worker-lane tests (4291)
CI / lint (push) Successful in 3s
CI / extension-version (push) Successful in 4s
Build images / sign-extension (push) Successful in 4s
Build images / build-agent (push) Successful in 7s
CI / frontend-build (push) Successful in 23s
CI / backend-lint-and-test (push) Successful in 36s
Build images / build-web (push) Successful in 59s
Build images / smoke-web (push) Skipped
Build images / build-ml (push) Successful in 1m51s
Build images / promote (push) Skipped
CI / integration (push) Successful in 2m26s
Both mine, both in tests/test_worker_lanes.py, neither in the code under
test. Run 7242.
RUFF I001 — two blank lines between the import block and the first
module-level comment. Rule 102 names this exact trap ("exactly ONE blank line
between imports and a module-level constant/comment/pytestmark") and I was
pointed at that rule repeatedly before opening it.
SIX FAILURES in test_worker_lane_check_constraints — the test asserted bare
constraint names, but Base.metadata's naming_convention has already applied
the `ck_worker_lane_` prefix by the time __table__.constraints is read.
The failure output is worth keeping: it shows the model emits exactly the
three intended constraints, prefixed once —
ck_worker_lane_slots_non_negative slots >= 0
ck_worker_lane_cap_non_negative slots_cap >= 0
ck_worker_lane_slots_within_cap slots <= slots_cap
— which is the model behaving correctly, and confirms the migration's
op.f() names match what the ORM produces.
The assertion is now an equality against the prefixed names plus an explicit
check for a doubled prefix. That is strictly more valuable than what I wrote:
a bare-name assertion would have passed just as happily against
`ck_worker_lane_ck_worker_lane_slots_within_cap`, which is the defect alembic
0088 had to rename four constraints for (#3275).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LVjrnpQjRgHdvq95rASoiR
|
||
|
|
84f13135ce |
feat: worker lanes become rows — slots, a settable cap, a derived ceiling (4291)
CI / lint (push) Failing after 2s
CI / extension-version (push) Successful in 2s
Build images / sign-extension (push) Successful in 4s
Build images / build-agent (push) Successful in 5s
CI / frontend-build (push) Successful in 24s
CI / backend-lint-and-test (push) Failing after 32s
Build images / build-web (push) Successful in 58s
Build images / smoke-web (push) Skipped
Build images / build-ml (push) Successful in 1m45s
Build images / promote (push) Skipped
CI / integration (push) Successful in 2m13s
Milestone 422 step 1. The data model the rest of the milestone reads. No
behaviour change: nothing consumes these rows yet, and every lane still boots
at its CELERY_CONCURRENCY env value.
Three numbers, not two, per the operator's distinction — the derived value is
a cap ON the cap:
slots <= slots_cap <= derived_ceiling
(live) (operator) (computed)
They can always lower their own cap; they cannot raise it past what the
container can hold. The ceiling is never stored, so a row written on a 32GB
host and later run in a 4GB container is bounded by the 4GB.
`services/worker_lanes.py` is the one place that knows the lane set.
`models/worker_lane.py` holds only what an operator may change.
Two deviations from the step as written, both deliberate:
QUEUES ARE NOT A COLUMN. The step body said the row carries its `-Q` list,
but a lane's queues are decided by celery_app's task_routes, not by
preference — an operator cannot move a backup off maintenance_long. Storing
them would create a row that can contradict the routing table, with nothing
to notice until a queue had no consumer. So queues are code, slots are data.
`test_every_routed_queue_has_a_lane_that_serves_it` reads the real routing
table and fails if a route is ever added without a lane.
ROLE_NAMES IS NOW DERIVED, not left alone. It was a hand-kept second copy of
"queue set -> display name" and had already drifted: maintenance_long is a
live lane with four task routes and a dedicated worker in the operator's
stack, and the roster did not know its name — so the System tab labelled it
`Worker (maintenance_long)`. Adding a lane table beside it would have made
three copies.
The ceiling honours cgroup limits rather than the host's. `os.cpu_count()`
reports the HOST's cores from inside a container, so a 4-core quota on a
32-core host would otherwise offer 32 slots — and the operator's own stack
sets `cpus: '4.0'` on ml-worker, so that is real configuration, not a
hypothetical. Memory reads cgroup v2 then v1, and recognises v1's
PAGE_SIZE-aligned LONG_MAX sentinel by magnitude rather than treating it as
petabytes.
Every uncertain case fails LOW. An unreadable limit yields UNKNOWN_CEILING,
never unlimited — not knowing how much memory there is must not read as
plenty. A box too small to hold one model beside the web process gets an ML
ceiling of 0 rather than a floor of 1: offering a slot that OOMs the
container the first time it is used is exactly what this exists to prevent.
ML_BYTES_PER_SLOT is 4 GiB and is UNMEASURED — flagged as such in the code,
with the method for replacing it with a real figure. It decides whether a
stranger's server survives enabling tagging, so it errs toward refusing a
slot that would have fitted.
Seeded one-of-each with ml at 0 and disabled (alembic 0103). ML off is step
6's requirement arriving early: enabling the lane is what triggers the SigLIP
download, and rule 164 permits a runtime fetch only for a feature that is
optional and clearly off. The seed values are literals rather than an import
of LANES — a migration is a statement about one moment, and importing the
live defaults would silently change what this revision does on a fresh
database in 2027.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LVjrnpQjRgHdvq95rASoiR
|
||
|
|
aa2bb665b9 |
fix: serialise builds per branch, and pin :c-<sha> to the digest this run built (4290)
CI / lint (push) Successful in 2s
CI / extension-version (push) Successful in 2s
Build images / sign-extension (push) Successful in 4s
Build images / build-agent (push) Successful in 5s
Build images / build-ml (push) Successful in 7s
Build images / build-web (push) Successful in 6s
Build images / smoke-web (push) Skipped
Build images / promote (push) Skipped
CI / backend-lint-and-test (push) Successful in 34s
CI / frontend-build (push) Successful in 26s
extension / lint (push) Successful in 26s
CI / integration (push) Successful in 2m20s
extension / lint (pull_request) Successful in 23s
Two independent defects from one missing mechanism. `grep -rn concurrency
.forgejo/workflows/` returned nothing, so two pushes to a branch ran
build.yml in full parallel with no ordering.
Both jobs read `fc.revision` off the channel tag before either has pushed, so
both miss the reuse check and both build. Whichever finishes LAST owns the
tag.
Half 1 — the rolling tag. A slower older build leaves `:dev` carrying content
older than the commit that moved it. Family rule 146 says a rolling channel
refreshes itself; this is the case where it quietly does not. Self-healing on
the next push.
Half 2, and the reason this is not filed low — `:c-<sha>`. The repoint step
wrote every non-channel tag by copying the channel tag BY NAME, so the
immutable rollback tag named whatever `:latest` pointed at when that step
happened to run. Lose the race and `:c-<shaA>` names the OTHER run's bytes.
Rule 145 makes that tag the rollback unit and immutable, so this does not
break immutability — it makes the tag wrong from birth, and immutability then
guarantees nobody ever corrects it. Nothing goes red; it surfaces the day
someone rolls back and gets a commit they did not choose. Not self-healing.
Two fixes, deliberately both:
A. A workflow-level `concurrency` group keyed on `github.ref`, so dev and
main never block each other. `cancel-in-progress: false` — queue, never
cancel: cancelling could kill sign-extension mid-AMO-upload, leaving the
version registered at AMO with no cached asset, which is the unrecoverable
stuck state that job's rollback trap exists to prevent, reached by another
door. Not keyed on BUILD_REF because the group is evaluated before any job
starts and cannot read the `env` context.
B. Each build-push-action step gains `id: build`, and the repoint step copies
from `$IMAGE@${{ steps.build.outputs.digest }}` — the manifest THIS run
pushed — rather than from the channel tag by name. On a reuse hit there is no
digest and the channel tag remains correct by definition: "hit" means that
tag already carries this commit's fc.revision.
B is not redundant with A. A depends on this Gitea honouring a key whose
failure mode is silent, and this file has been burned by exactly that before
(the `format()` note records `true == 'true'` evaluating FALSE on run 5270,
every lane green, the feature simply not happening). B holds the :c-<sha>
correctness property whether or not A is honoured.
The loop's exclusion is now keyed on CHANNEL_REF rather than on SOURCE, and
that is load-bearing: SOURCE may now be a digest ref, which never equals a
tag string, so testing against it would stop excluding the channel tag.
imagetools would index-wrap it and `.Image.Config.Labels` would stop
resolving, killing the reuse label permanently — #3183 arriving again.
Neither half is verifiable on a dev push: dev's tag list is the channel tag
alone, so the repoint correctly does nothing there. B is observable on the
next merge to main (:c-<sha>'s digest must equal the build step's), and A by
pushing twice in quick succession and reading the run list for a queued
second run.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LVjrnpQjRgHdvq95rASoiR
|
||
|
|
bd92fb46b3 |
test: pin the JS<->Py artist-pattern mirror with a shared sample table (3093)
CI / lint (push) Successful in 2s
CI / extension-version (push) Successful in 2s
Build images / build-agent (push) Successful in 6s
Build images / sign-extension (push) Successful in 3s
Build images / build-ml (push) Successful in 6s
Build images / build-web (push) Successful in 5s
Build images / smoke-web (push) Skipped
Build images / promote (push) Skipped
extension / lint (push) Successful in 17s
CI / frontend-build (push) Successful in 22s
CI / backend-lint-and-test (push) Successful in 31s
CI / integration (push) Successful in 2m7s
`PLATFORM_ARTIST_PATTERNS` (extension/lib/platforms.js) and `_PLATFORM_PATTERNS` (extension_service.py) are two hand-kept copies of one table whose only guard was the comment "keep in sync by hand; reviewers catch drift" — the same guarantee manifest.json had before #3069, where deviantart sat in the manifest for seven weeks after the product dropped it. Drift here is worse than the manifest case, because the two copies gate opposite halves of ONE interaction: the JS copy decides whether the "Add to FC" button appears, the Python copy decides whether the resulting POST is accepted. JS looser than Py shows a button that 400s; Py looser than JS silently never offers a button for a URL the backend would take. #1485 (the Patreon /c/ and /cw/ shapes) was the second of those, and its fix had to be applied to both files by hand. The two-runtimes objection to a shared SOURCE file is fair, so this tests the invariant instead of the source. `extension/test/artist-url-samples.json` is one table of 25 URL samples — match (with the expected slug) and no_match, each with a `why` — read by BOTH suites and asserted against each one's own copy of the patterns. Neither runtime imports the other; a change to one copy alone turns the other runtime's suite red. Both halves also assert their own coverage: the sample platforms must equal the platforms that actually have an artist pattern, and every platform must have samples in both directions. Without that, deleting a platform's samples would make the guard pass by testing less. Discord is deliberately in neither table — it is channel-based and has no creator page to put a button on. The no_match half asserts `_derive` RAISES rather than merely missing the platform: it tries every pattern in turn, so a nav page some other platform's pattern happened to swallow would still be accepted by the backend — the same defect wearing a different platform name. Samples live under extension/test/ because that path is excluded from both the XPI file set and the extension version derivation (packaging.sh: NOT_PACKAGED_TRACKED and NOT_VERSION_RELEVANT both carry `test/**`), so adding samples ships no bytes and forces no re-sign. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LVjrnpQjRgHdvq95rASoiR |
||
|
|
24b10d0ffa |
feat: retire pixiv entirely — delete its code, its ledgers, its credential (3977, 3978, 3979)
Build images / build-agent (push) Successful in 7s
CI / frontend-build (push) Successful in 25s
CI / backend-lint-and-test (push) Successful in 34s
Build images / build-web (push) Successful in 1m5s
Build images / smoke-web (push) Skipped
Build images / build-ml (push) Successful in 1m54s
Build images / promote (push) Skipped
CI / integration (push) Successful in 2m19s
CI / lint (push) Successful in 3s
CI / extension-version (push) Successful in 4s
Build images / sign-extension (push) Successful in 4s
extension / lint (push) Successful in 26s
Milestone #406 phase 2, with issue #3980 folded in. Phase 1 (2026-09-13) unregistered pixiv so nothing could reach it; the code has sat in the tree uncalled since. DeviantArt is why the second half is not left for later — #3069 retired it in code on 2026-08-27 and its stored session was still in the database seven weeks on. Step 5 — the code. Deletes pixiv_client, pixiv_downloader, pixiv_ingester, platforms/pixiv and their three test modules and fixture, then edits out every remaining reference: the dispatch entry, the campaign-id and verify branches in download_backends, the display-name branch in extension_service, and the comments that still described pixiv as live. The consolidation check the step asked for comes back negative: native_ingest_common has seven non-pixiv callers (patreon, subscribestar, membership_reconcile, membership_roster, ingest_core), so nothing there drops to a single user. Step 6 — the data, alembic 0102. Drops pixiv_seen_media and pixiv_failed_media, and deletes credential rows whose platform is not registered. Written as "not registered" rather than "pixiv" at the step's explicit ask, which is what makes one migration cover two retirements: the pixiv OAuth refresh token and DeviantArt's leftover session (#3980). It is also the only way either row can go — the credentials UI renders one card per platform from /api/platforms and looks the credential up by key, so an unregistered platform's row has no card and no Remove button. Pixiv's Source rows are KEPT, changing the milestone's original data table on the operator's call. `platform` is stored only on Source; neither Post nor ImageRecord carries it. Both FKs are ON DELETE SET NULL, so a delete would not lose the art — but it would drop every pixiv image into the gallery's __unsourced__ bucket and strip the platform chip off every pixiv post. The rows stay disabled (0097) and unregistered, so nothing schedules or downloads through them. Keeping them costs nothing and keeps the attribution that "the art already downloaded from pixiv stays" is about. Step 7 — the guard. test_pixiv_code_and_tables_are_gone asserts absence from the module table and from Base.metadata, not from prose (snippet #3352's trap). The extension and registry negative assertions were already in place from phase 1. The final sweep found one real residue step 4 missed: extension/README.md still advertised pixiv support and carried a "Pixiv OAuth" manual-test item. Also replaces the two deleted dispatch tests with one over the whole NATIVE_INGESTER_PLATFORMS set, so adding a platform and forgetting its ingester class now fails at unit level rather than as a mid-download KeyError. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LVjrnpQjRgHdvq95rASoiR |
||
|
|
5aa8e3d81b |
fix: a stopped source is not a failing one, and cannot be deep-scanned (4279)
CI / lint (push) Successful in 2s
CI / extension-version (push) Successful in 2s
Build images / sign-extension (push) Successful in 3s
Build images / build-agent (push) Successful in 6s
CI / frontend-build (push) Successful in 20s
CI / backend-lint-and-test (push) Successful in 33s
Build images / build-web (push) Successful in 1m3s
Build images / smoke-web (push) Skipped
Build images / build-ml (push) Successful in 2m12s
Build images / promote (push) Skipped
CI / integration (push) Successful in 2m16s
Ebi77 sat in the "1 source is failing" banner for six days with no action available, reading `stranded by recovery sweep (no terminal status after time_limit)`. Four things lined up: 1. The membership sweep did its job — saw `former_patron`, disabled the source, cleared its failure state. Clean at 02:50. 2. Twenty minutes later a deep scan was armed on it. `/backfill` had a credential pre-flight but NO `enabled` guard, while `/check` has carried one all along. The two trigger endpoints disagreed, and the ungated one is the one that arms the long walk. 3. Without a membership the walk cannot finish, never reaches a terminal status, and the recovery sweep strands it with consecutive_failures = 1. 4. Nothing could clear that. A disabled source is never scheduled, so no successful run resets the count; `SourceService.update` clears only on an explicit disable and it was already disabled; and the banner's Retry routes to `/check`, which refuses a disabled source. The card offered a button structurally incapable of acting on the only source it was showing. `failing_sources_clause()` now means "enabled AND erroring". That also settles a disagreement its two callers already had: the scheduler's count paired it with `enabled.is_(True)` and `SourceService.list(failing=True)` did not, so one counted Ebi77 and the other did not — exactly the drift the note above that function warns about, which is why the test belongs IN the predicate rather than beside it. The scheduler's now-duplicate clause is dropped so one place decides. `/backfill` gains the guard for start/recover/recapture. `stop` stays open on a disabled source, or arming becomes a one-way door. Migration 0101 clears failure state on sources that are already disabled — the predicate fixes what the surfaces report, not what the rows carry, and the rows are why the operator had no way out (lesson #4202). It matches what `update` already does on an explicit disable, so rows disabled by any other path come into line. Enabled sources are untouched: a real failure on a live source must keep showing, which the second new test pins. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LVjrnpQjRgHdvq95rASoiR |
||
|
|
11a01a9686 |
revert: remove the placement reconciler — it manufactured the problem it solved
CI / lint (push) Successful in 2s
CI / extension-version (push) Successful in 3s
Build images / sign-extension (push) Successful in 3s
Build images / build-agent (push) Successful in 6s
CI / backend-lint-and-test (push) Successful in 33s
CI / frontend-build (push) Successful in 26s
Build images / build-web (push) Successful in 59s
Build images / smoke-web (push) Skipped
Build images / build-ml (push) Successful in 1m55s
Build images / promote (push) Skipped
CI / integration (push) Successful in 2m22s
Milestone #421 built a sweep that compared each image's `artist_id` to the name of the directory holding its file, and called every mismatch a misplaced image. It reported 33,789 of 63,605 as wrongly filed. That number described the comparison, not the library. What it actually was: 32,475 (97.1%) one artist's own folder, spelled differently — Telepurte/ vs telepurte/. Same artist, same art. 657 ( 2.0%) loose at the images root 328 ( 1.0%) in a folder named after a different artist And the 1% did not mean what the tool assumed either. `ImageProvenance` records the post and source every file was downloaded from — the authoritative answer, which the tool never consulted. Querying it for all 328: 144 provenance agrees with the record (move would be right) 87 provenance agrees with the FOLDER (the record is wrong; move wrong) 53 provenance names SEVERAL artists (no single correct folder) 41 no provenance at all 3 agrees with neither So the sweep would have misfiled or arbitrarily picked for ~41% of the only set it was really needed for. The system already knew where each file came from; the tool inferred it from a column and a directory name instead. Operator, 2026-09-21: *"the current system consistently records where items are and where they came from this is just complicating something works and doesn't need fixing."* Correct on both counts. Removed: the service, the tasks, the model and migration 0099's table, the /api/cleanup/layout and /placement/* endpoints, the Maintenance card and its store actions, and the tests. 0100 drops the table (rule #22 — no legacy). KEPT deliberately, per the operator: - `utils.paths.canonical_subdir` — new filesystem imports derive their directory from the artist's slug, matching what the downloader always did. Not part of this tool; removing it would be churn that fixes nothing. - The 327 files run 1 moved (InsoUwu/ -> insouwu/). Same artist either way, and the gallery renders them correctly. - Everything from #4223 (three-gate dedup, 256-bit pHash) and #4234 (backup credential exclusion). Those fixed problems that were actually reported. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LVjrnpQjRgHdvq95rASoiR |
||
|
|
2dd9b956d5 |
feat: File placement card — survey, plan per artist, apply, put back (4246, slice 3c)
CI / lint (push) Successful in 2s
CI / extension-version (push) Successful in 2s
Build images / sign-extension (push) Successful in 4s
Build images / build-ml (push) Successful in 6s
Build images / build-agent (push) Successful in 6s
CI / frontend-build (push) Successful in 22s
CI / backend-lint-and-test (push) Successful in 32s
Build images / build-web (push) Successful in 1m6s
Build images / smoke-web (push) Skipped
Build images / promote (push) Skipped
CI / integration (push) Successful in 2m17s
The UI half of the reconciler, in Maintenance. Survey shows how many images sit in the wrong artist's folder and which folders they are in; each artist gets its own Plan button; each run can be reviewed, applied, and put back. Deliberately NOT using useMaintenanceTask. That composable stashes a task id in localStorage so a result survives navigate-away, which is the right answer when the only record is a Celery result. Here the runs are database rows — so a reload, another machine, or coming back tomorrow simply shows the same state, because the state IS the row. The card polls the runs endpoint instead. Copy avoids the vocabulary this work has been tripping over: "folder", "put back", "in the wrong folder" rather than artist_id, revert and canonical. The one thing the operator most needs to know — nothing here changes who an image belongs to — is what the blurb says first. Three things I had assumed and checked instead: MaintenanceTile lives in common/ not settings/; there is no generic ConfirmDialog (BackupCard uses a purpose-built modal), so this uses a plain v-dialog; and `loadArtistNames` did not exist — it does now, mapping id to name so a run row reads "Conto" rather than "#47". The name deliberately is not denormalised into the run: it belongs to the artist and would go stale on a rename. Also extracted `stubFetch` to frontend/test/stubFetch.js. The shape ledger flagged it as a byte-identical duplicate across five specs and this would have been the sixth; the others keep their copies until each is next touched. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LVjrnpQjRgHdvq95rASoiR |
||
|
|
a4bdbcaca4 |
test: import the placement task module so its names reach celery.tasks (4246)
CI / lint (push) Successful in 3s
CI / extension-version (push) Successful in 3s
Build images / build-agent (push) Successful in 6s
Build images / build-ml (push) Successful in 6s
CI / frontend-build (push) Successful in 22s
CI / backend-lint-and-test (push) Successful in 32s
Build images / sign-extension (push) Successful in 4s
Build images / build-web (push) Successful in 5s
Build images / smoke-web (push) Skipped
Build images / promote (push) Skipped
CI / integration (push) Successful in 2m26s
Three registration assertions failed: a task name only enters `celery.tasks` when its module is imported, and nothing in the test process imported `library_placement`. The API routes import it lazily inside the handlers, and the registration test runs before any handler test triggers that. `include=[...]` is what gets the module imported in a real WORKER, so production registration was never in question — the test was asserting something only observable after an import it never performed. test_tasks_admin already carries the convention verbatim (`import backend.app.tasks.admin # noqa: F401 — register tasks`); I wrote the assertion from what I meant instead of copying the idiom next to it. Same mistake shape as the commit-vs-flush bounce one commit ago: the pattern was already in the suite both times. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LVjrnpQjRgHdvq95rASoiR |
||
|
|
db63377754 |
test: placement API tests must commit, not flush (4246)
CI / lint (push) Successful in 3s
Build images / sign-extension (push) Successful in 4s
CI / extension-version (push) Successful in 2s
Build images / build-agent (push) Successful in 8s
Build images / build-ml (push) Successful in 8s
Build images / build-web (push) Successful in 6s
CI / frontend-build (push) Successful in 23s
CI / backend-lint-and-test (push) Successful in 37s
Build images / smoke-web (push) Skipped
Build images / promote (push) Skipped
CI / integration (push) Failing after 2m14s
Five of the new tests failed in CI: every one that created a LibraryPlacementRun and then read it back through the client. The ones that touched no rows passed. A flush stays inside the test's own transaction, and the app under test runs on a separate session and connection — so the endpoint queried a database where the row did not exist yet and got its 404 / empty list honestly. `_seed_runs` in test_api_system_backup already commits for this reason; the idiom was there to copy and I did not look first. Recorded in the helper's docstring rather than just fixed, since the next person writing a create-then-fetch API test will reach for flush too. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LVjrnpQjRgHdvq95rASoiR |
||
|
|
abe449b4f2 |
feat: placement reconciler tasks + API (4246, slice 3b)
CI / lint (push) Successful in 3s
CI / extension-version (push) Successful in 3s
Build images / sign-extension (push) Successful in 4s
Build images / build-agent (push) Successful in 7s
CI / frontend-build (push) Successful in 29s
CI / backend-lint-and-test (push) Successful in 1m1s
Build images / build-web (push) Successful in 1m34s
Build images / smoke-web (push) Skipped
Build images / build-ml (push) Successful in 2m26s
Build images / promote (push) Skipped
CI / integration (push) Failing after 3m5s
Three Celery tasks wrapping the 3a service, and the endpoints that drive them. Routed to `maintenance_long` alongside backups: 33k renames on NFS have no business in the quick lane where the self-healing sweeps live (the 2026-06-07 starvation). A durability bug in 3a, found by thinking about what a crash costs rather than by a failing test: `apply_run` wrote its ledger only at the end, so a worker dying at row 30,000 of 33,789 would have taken the undo information for the first 29,999 with it — and that ledger is the ONLY record of where those files came from. It now persists every 200 moves. Two things fell out of writing that: - `_persist` reassigns `run.moves`, so the loop had to snapshot the plan first rather than iterate the attribute it rewrites. - the reassignment is itself load-bearing: SQLAlchemy does not track in-place mutation of a JSONB list, so an `.append()` alone would never reach the database and the ledger would have stayed silently empty. Re-running a partially-applied plan is safe — the moved rows no longer match their `from` and refuse as "row moved since planning" — but `apply_placement` deliberately has NO autoretry: re-entering a half-applied plan should be the operator's call after reading what happened, not the queue's. Endpoints gate on run state as well as the service does, so a stray POST cannot re-apply an applied run. The list response omits `moves` (an applied whole-library run carries tens of thousands of entries); the detail endpoint includes them, because that detail IS the preview read before agreeing. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LVjrnpQjRgHdvq95rASoiR |
||
|
|
9ccc460c69 |
feat: placement reconciler — plan, apply, revert (4246, slice 3a)
CI / lint (push) Successful in 2s
CI / extension-version (push) Successful in 2s
Build images / sign-extension (push) Successful in 4s
Build images / build-agent (push) Successful in 11s
CI / frontend-build (push) Successful in 29s
CI / backend-lint-and-test (push) Successful in 1m1s
Build images / build-web (push) Successful in 1m11s
Build images / smoke-web (push) Skipped
Build images / build-ml (push) Successful in 1m56s
Build images / promote (push) Skipped
CI / integration (push) Successful in 2m43s
Milestone #421 step 3, reframed on the operator's steer: not a one-off migration but the system that keeps the tree true. The 33,789 misplaced rows the survey found are just its first run. The placement half was already done, verified by reading each writer rather than assuming: downloads have always written `<root>/<slug>/<platform>/` (gallery_dl.py:523), attach_in_place leaves files where the downloader put them, and `_copy_to_library` / `_supersede` became canonical in #4244. So nothing is written off-canon today; what remains is the backlog and a standing check for future drift. `LibraryPlacementRun` (migration 0099) holds the plan as JSONB, and that one structure does three jobs: it is the PREVIEW the operator reads, the list the APPLY executes (rather than re-deriving the set, so the two cannot disagree), and — because `from` is retained — the UNDO. The undo is the point. It makes a 33,789-file operation something to do one artist at a time, look at in the gallery, and reverse if it reads wrong. That settles whether artist_id or the folder held the truth (spike #4257) by doing rather than by arguing it from a 50-row sample. An applied run is therefore HISTORY, not state — lesson #4226's trap, since it is the only record of where those files used to be. The model and the migration both say so: any future retention here may prune ready/cancelled/ error runs, never an applied one. Everything fails closed. The apply re-checks each row against what the plan recorded — source still there, destination still free, row still pointing where the plan said — because a download or a supersede can land in between. A refusal is recorded with its reason and the run continues; one stale row is not a reason to abandon the other 33,788. The row is updated only after its rename lands, so a failed move can never leave `path` naming a file that is not there. Writing the collision test caught the code disagreeing with its own comment: it claimed the first of two rows wanting one destination and skipped the second, silently picking a winner by iteration order. Now it counts first and filters after, so genuinely neither is planned. Thumbnails are sha-addressed, not path-keyed, so they do not move — pinned by a test. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LVjrnpQjRgHdvq95rASoiR |
||
|
|
9728407539 |
docs: record why the misplaced-rows LIKE needs no escaping (4245)
CI / lint (push) Successful in 3s
CI / extension-version (push) Successful in 3s
Build images / sign-extension (push) Successful in 4s
Build images / build-agent (push) Successful in 9s
CI / frontend-build (push) Successful in 23s
CI / backend-lint-and-test (push) Successful in 35s
Build images / build-web (push) Successful in 1m5s
Build images / smoke-web (push) Skipped
Build images / build-ml (push) Successful in 1m55s
Build images / promote (push) Skipped
CI / integration (push) Successful in 2m26s
`startswith` compiles to LIKE, where `_` and `%` are wildcards, and the call does not escape them. That is safe only because `slugify` reduces a slug to [a-z0-9-] — an invariant living in a different module, which is exactly the kind of thing that gets widened later without anyone connecting the two. Worth naming because `poch4n_art` is a real directory here: if slugs ever carried underscores, that prefix would start matching `poch4nXart` and the sweep would quietly mis-file one artist's rows. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LVjrnpQjRgHdvq95rASoiR |
||
|
|
fc982f74b9 |
feat: survey which image rows sit outside their artist's canonical directory (4245)
CI / lint (push) Successful in 3s
CI / extension-version (push) Successful in 3s
Build images / sign-extension (push) Successful in 4s
Build images / build-agent (push) Successful in 7s
CI / frontend-build (push) Successful in 25s
CI / backend-lint-and-test (push) Successful in 48s
Build images / build-web (push) Successful in 1m14s
Build images / smoke-web (push) Skipped
Build images / build-ml (push) Successful in 2m6s
Build images / promote (push) Skipped
CI / integration (push) Successful in 2m34s
Step 2 of milestone #421. The disk survey counted FOLDERS; this counts ROWS, which is the number that matters — every move in step 3 is a row update, and `ImageRecord.path` is the only pointer at the bytes. `library_layout.py` holds the decision in two shared pieces, and both halves of the consolidation spread them rather than restating them (rule 93, the _x_conditions shape from snippet #3087): - `_misplaced_conditions(root, artist_id, slug)` — rows of one artist whose file is not under that artist's directory. The prefix carries a trailing separator deliberately: without it `ara` matches everything under `arbuzbudesh/`, and one artist reads as fully placed while another's rows are silently skipped. Both are real artists here, hence the test. - `destination_for(path, root, slug)` — where a row's file belongs, or None when it must not be moved: outside the images root, or under one of the reserved stores (`thumbs`, `attachments`, `cookies`, `secrets`, `_backups`, `_quarantine`). Relocating those would move the thumbnail cache or the credential key into an artist folder. `destination_for` diverges from `canonical_subdir` in exactly one case, and the docstring says why: a file at the images ROOT with a known artist moves under that artist here, where the import-time helper leaves it alone. The two answer different questions — an empty subdir at import means no artist was resolved, while a row that already carries an artist_id is an anomaly with a known correct home. The 660 unattributed files have no artist_id at all, so no predicate reaches them; they are counted and left for task #4247. `GET /api/cleanup/layout` exposes it. `?check_disk=1` additionally stats every destination for collisions and missing sources — the conditions the apply refuses on — but it is off by default so the count-only pass answers "how big is this" in seconds instead of timing the request out on NFS. Nothing here writes; a test asserts that against both the row and the file. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LVjrnpQjRgHdvq95rASoiR |
||
|
|
01c906e6ad |
fix: ruff I001 — canonical_subdir sorts before derive_subdir
Build images / sign-extension (push) Successful in 3s
Build images / build-agent (push) Successful in 5s
CI / lint (push) Successful in 2s
CI / extension-version (push) Successful in 2s
CI / frontend-build (push) Successful in 22s
CI / backend-lint-and-test (push) Successful in 42s
Build images / build-web (push) Successful in 1m19s
Build images / smoke-web (push) Skipped
Build images / build-ml (push) Successful in 2m12s
Build images / promote (push) Skipped
CI / integration (push) Successful in 2m26s
Rule 102's import-order trap, hit by inserting into the block rather than re-sorting it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LVjrnpQjRgHdvq95rASoiR |
||
|
|
30337a6c11 |
fix: library paths follow the artist's slug, not the import folder's name (4244)
CI / lint (push) Failing after 2s
CI / extension-version (push) Successful in 2s
Build images / sign-extension (push) Successful in 4s
Build images / build-agent (push) Successful in 6s
CI / frontend-build (push) Successful in 23s
CI / backend-lint-and-test (push) Successful in 33s
Build images / build-web (push) Successful in 1m11s
Build images / smoke-web (push) Skipped
Build images / build-ml (push) Successful in 2m4s
Build images / promote (push) Skipped
CI / integration (push) Successful in 2m50s
Step 1 of milestone #421. The images tree has 57 directory families for what the database says are single artists — `Conto`/`conto`, `InCaseArt`/`incaseart`, `StickySpoodge`/`Stickyspoodge`/`stickyspoodge`, and so on down to a four-way split for Pocket Ace Games. There was never a duplicate Artist row. `/api/artists/names` returns exactly one per artist. The files simply get written to two places for one row: `_copy_to_library` built its destination from `derive_subdir`, which mirrors the IMPORT tree's folder name verbatim, while the download path leaves files where the ingester wrote them — under the slug. Two writers, two conventions, one artist. This is the half that stops it re-growing, and it has to land before anything moves existing files: consolidate first and the next filesystem import out of a capitalised folder re-creates the directory that was just emptied. `canonical_subdir` replaces the top-level segment with the artist's slug and leaves everything below it alone — the post hierarchy is the downloader's business. Two deliberate pass-throughs: no resolved artist (nothing authoritative to canonicalise against) and an empty subdir (a file at the images root, whose fate is task #4247, not a side effect of this helper). `_supersede` resolves the KEPT row's artist for the same reason — a supersede rewrites `existing.path`, so writing it anywhere else would move a row back out of the tree being consolidated. ImageRecord carries `artist_id` with no relationship attribute, so that is a session lookup rather than an attribute. test_import_one_happy_path pinned the old `Alice/` destination and now pins `alice/` (rule 90). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LVjrnpQjRgHdvq95rASoiR |
||
|
|
6915cbbbe1 |
fix: the images backup carried the key to the accounts it backs up (4234)
CI / lint (push) Successful in 3s
CI / extension-version (push) Successful in 3s
Build images / sign-extension (push) Successful in 4s
Build images / build-agent (push) Successful in 7s
CI / frontend-build (push) Successful in 23s
CI / backend-lint-and-test (push) Successful in 33s
Build images / build-web (push) Successful in 57s
Build images / smoke-web (push) Skipped
Build images / build-ml (push) Successful in 1m49s
Build images / promote (push) Skipped
CI / integration (push) Successful in 2m20s
Listing a 2026-05 tarball while investigating the 4.3T `_backups` pile showed
its second and third entries:
images/secrets/
images/secrets/credential_key.b64
That is the key that decrypts the stored Patreon/SubscribeStar session
credentials, and `cookies/` sat beside it — both unexcluded, so this was true
of every images backup taken today, not just the old ones. An images tarball
is supposed to be a media archive; one that carries the operator's account
keys is a credential leak wearing a backup's name, in a single file that is
easy to copy to another disk or restore somewhere less protected. Encryption
at rest buys nothing when the key travels in the same archive.
`secrets` and `cookies` join `_backups` and `_quarantine` in one named tuple,
each with its reason recorded — the recursion that produced 4.3T of nested
tarballs is the cautionary tale for why the list is worth explaining rather
than just listing.
A restore no longer re-establishes credentials. You sign in again, which is
the correct outcome for a media backup.
Tests cover both new names and that every exclude stays root-relative — a bare
`secrets` would also match an artist folder of that name.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LVjrnpQjRgHdvq95rASoiR
|
||
|
|
3313c3b10a |
feat: a report that shows what the near-dup gates decide about real artwork (4223)
CI / lint (push) Successful in 3s
CI / extension-version (push) Successful in 3s
Build images / sign-extension (push) Successful in 3s
Build images / build-agent (push) Successful in 6s
CI / frontend-build (push) Successful in 20s
CI / backend-lint-and-test (push) Successful in 33s
Build images / build-web (push) Successful in 58s
Build images / smoke-web (push) Skipped
Build images / build-ml (push) Successful in 1m52s
Build images / promote (push) Skipped
CI / integration (push) Successful in 2m22s
The three pixel constants added with the #4223 fix were chosen without ever measuring real files — CI only has synthetic split/solid fixtures, and FC verifies nowhere else. This prints the measurements they should have been chosen from: per pair, the hash distance, the mean drift, the changed-pixel fraction, the verdict, and which gate produced it. It drives the real find_similar with the real confirm rather than restating the decision, so it cannot drift from what the importer does. Read-only: opens files, touches no database. Also splits fingerprint_diff out of fingerprints_match — same computation, now returning the numbers instead of only the boolean, so the report can show how far a pair sat from a limit rather than which side of it it fell on. Runs inside the published :dev image (PIL + imagehash already there, no local env needed) with the art folder mounted read-only — rule 147's channel, so nothing has to reach main to be tried. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LVjrnpQjRgHdvq95rASoiR |
||
|
|
84f873eb1b |
fix: ruff UP037 — unquote the fingerprint_path return annotation
Build images / sign-extension (push) Successful in 3s
Build images / build-agent (push) Successful in 5s
CI / lint (push) Successful in 2s
CI / extension-version (push) Successful in 2s
CI / frontend-build (push) Successful in 25s
CI / backend-lint-and-test (push) Successful in 34s
Build images / build-web (push) Successful in 1m24s
Build images / smoke-web (push) Skipped
Build images / build-ml (push) Successful in 2m35s
Build images / promote (push) Skipped
CI / integration (push) Successful in 2m52s
PIL's Image is imported at module scope, so the annotation never needed to be a string. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LVjrnpQjRgHdvq95rASoiR |
||
|
|
a05cb66635 |
fix: variant artwork was dropped as a near-duplicate even at threshold 0 (4223)
CI / lint (push) Failing after 3s
CI / extension-version (push) Successful in 3s
Build images / sign-extension (push) Successful in 4s
Build images / build-agent (push) Successful in 7s
CI / frontend-build (push) Successful in 30s
CI / backend-lint-and-test (push) Successful in 1m8s
Build images / build-web (push) Successful in 1m28s
Build images / smoke-web (push) Skipped
CI / integration (push) Successful in 2m51s
Build images / build-ml (push) Successful in 2m59s
Build images / promote (push) Skipped
The operator reported a 15-image variant pack landing as 3 records, then reported variants STILL being dropped with phash_threshold at 0 — the floor of the dial. No setting could have fixed it: at hash_size=8 a pHash is 64 bits of coarse light/dark layout, so two variants sharing a composition produce the SAME bits. Distance 0 meant "identical hash", not "identical image", and the dial was simultaneously too coarse to keep variants and too tight to catch a re-encoded rescale. The hash no longer decides a merge on its own. find_similar now runs three gates, cheapest first: the threshold proposes candidates, aspect ratio (ASPECT_TOL, matching the tier-1 video path) rejects crops and re-canvases, and a pixel-level confirm on the two files accepts. Every gate fails closed — unknown dimensions, an unreadable candidate, a hash of the wrong width all mean "not a duplicate", because too strict keeps a redundant copy the operator can see while too loose deletes artwork only a source re-walk returns. - utils/phash.py: HASH_SIZE 8 -> 16 (256-bit, what ImageRepo always used); aspect_matches, fingerprint/fingerprint_path/fingerprints_match (PIL-only, mean drift + changed-pixel fraction), find_similar gains `confirm`. - importer: _pixel_confirmer supplies gate 3 on both dedup sites, lazily and cached, so a non-matching import costs no extra I/O. - 0098: widens image_record.phash to 64 chars and NULLs every value — a stored 64-bit hash cannot be compared to a 256-bit one, and backfill_phash is NULL-only, keyset-paginated and now on the daily beat, so the library re-hashes itself. Dedup degrades to sha256 until it finishes. - phash_threshold counts bits and the denominator went 64 -> 256, so the setting is reset to the new default of 24 (there is no honest carry-over) and the slider is rescaled to 0-64. - gallery_service dup_threshold 8 -> 32: the same fraction of the hash, so the Explore rail keeps the variance the operator tuned in on 2026-07-01. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LVjrnpQjRgHdvq95rASoiR |
||
|
|
3fe9d0a612 |
fix: the Latest feed's filter dropdowns opened empty
CI / lint (push) Successful in 2s
CI / extension-version (push) Successful in 2s
Build images / sign-extension (push) Successful in 3s
Build images / build-agent (push) Successful in 6s
CI / frontend-build (push) Successful in 21s
CI / backend-lint-and-test (push) Successful in 33s
Build images / build-web (push) Successful in 1m2s
Build images / smoke-web (push) Skipped
Build images / build-ml (push) Successful in 1m54s
Build images / promote (push) Skipped
CI / integration (push) Successful in 2m20s
Operator: "the filters in the latest feed, drop down but don't have values". Two separate causes: - Platform: PostsFilterBar built its items from `platformsStore.platforms`. The platforms store has never had that property; it exposes `list` and `byKey`. The read returned undefined, `|| []` turned that into an empty list, and nothing failed. ArtistsView had copied the same read, so the Browse → Artists platform filter was empty too. Both now read `list` and show platform names rather than raw keys. - Artist: the autocomplete searched the server only after something was typed (autocomplete returns [] for an empty query by design, which its tests pin). Opening the dropdown therefore showed an empty menu. PostsFilterBar now loads every artist once from a new lightweight `GET /api/artists/names` (id, name, slug; alphabetical; no joins) and filters client-side, so the list is there on open. A deep-linked artist_id now also shows the artist's real name instead of "Artist #id". Guard: frontend/test/storeUsage.spec.js scans src for `platformsStore.<name>` and fails on any name the store doesn't define, since the frontend CI has no type-checker to catch this. A positive control shows the shipped `platformsStore.platforms` read is flagged, and a vacuity check confirms the scan really walks the tree. tests/test_api_artists_create.py covers /names. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SHQB1YukL3VyvMK8rcbmV9 |
||
|
|
7ff8915147 |
feat: a source stops pulling once its membership ends, and resumes on resubscribe (3995)
CI / lint (push) Successful in 2s
CI / extension-version (push) Successful in 2s
Build images / sign-extension (push) Successful in 3s
Build images / build-agent (push) Successful in 6s
CI / frontend-build (push) Successful in 27s
CI / backend-lint-and-test (push) Successful in 34s
Build images / build-web (push) Successful in 1m24s
Build images / smoke-web (push) Skipped
Build images / build-ml (push) Successful in 2m52s
Build images / promote (push) Skipped
CI / integration (push) Successful in 3m1s
Operator, 2026-09-13: "if I kill a subscription on patreon I would like the pulling to stop on curator as well", with auto-resume chosen. This reverses the 2026-09-11 "report only" decision for lapsed sources. membership_reconcile.apply_membership_lapses runs in sync_memberships right after each platform's successful sync, so it only ever acts on the roster just written. It stops a source (enabled=false, with the same failure-state reset as a manual disable, #1285) only when all of these hold: - the roster is fresh - the source's matched membership says has_paid_access is False (lapsed, or a free follow) - the paid-through date has passed, where the platform gives one (Patreon's member.access_expires_at; SubscribeStar gives none, so it stops at once) - the source is enabled - the operator hasn't chosen to keep it It never acts on absence. A source with no matched membership keeps pulling, because a rename or a never-walked source produces the same absence. An unrecognised status is never a lapse either. It resumes only sources carrying its own `_membership_stopped` marker, once the membership is paid again. The operator outranks the sweep both ways (SourceService.update): - turning a stopped source back on marks it `_membership_kept`, so the next sweep leaves it alone until it's paid again - turning a source off by hand drops the marker, so the sweep never switches it back on Both are `_`-prefixed app-managed config keys, which operator edits already preserve. No migration. The roster/fetch line holds. This is a source-level action by the sweep. No download path reads the roster, and the scheduler still selects on `enabled` alone. test_no_fetch_path_can_read_the_roster is unchanged. UI: SourceRow shows a neutral "Membership ended" chip, with the status and the resume/keep explanation, ahead of the other chips. The sweep's task summary reports stopped/resumed counts. Tests (tests/test_membership_lapses.py): - a lapse stops the source with a clean slate and keeps the id cache - paid-through is honoured - absence, an unknown status and a stale roster never stop anything - a resume touches only what the sweep stopped - a manual on sticks, a manual off drops the marker, and a kept source is released once paid Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SHQB1YukL3VyvMK8rcbmV9 |
||
|
|
4d84ab2816 |
fix: the filter rail goes, and the subscriptions card stops listing what you don't pay for — and can be dismissed
CI / lint (push) Successful in 4s
CI / extension-version (push) Successful in 3s
Build images / sign-extension (push) Successful in 4s
Build images / build-agent (push) Successful in 7s
Build images / build-ml (push) Successful in 7s
CI / frontend-build (push) Successful in 21s
CI / backend-lint-and-test (push) Successful in 33s
Build images / build-web (push) Successful in 1m8s
Build images / smoke-web (push) Skipped
Build images / promote (push) Skipped
CI / integration (push) Successful in 2m29s
Operator live review of #253 on the wide window. The images landed well, but: - "the left rail feels like wasted space". The filters and check status move back into one row above the feed (PostsView). On a wide window the row lines up with the feed column, clear of the day gutter, and the container recentres without the 280px rail. The narrow layout is as before, with the status still first. This retires #407 option E; A and D stay. - The "you can't see" link landed on the Subscriptions reconcile card, which listed 9 sources as `former_patron` under "sources your roster doesn't account for". The operator doesn't want sources they no longer pay for listed there, and wants the card dismissable "unless something changes": - MembershipReconcileCard renders only "creators you subscribe to but don't follow here" and the stale-roster warning. - Stopping pulls on lapsed sources is the membership sweep's job instead (#3995, next). - A close button dismisses the card. The dismissal is keyed to a fingerprint of what the card says (offered membership ids plus stale-roster flags), so a new subscription or a roster going stale brings it back. It's stored per browser, which is enough for a single-operator instance. The backend still computes tracked_not_subscribed. #3995 acts on it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SHQB1YukL3VyvMK8rcbmV9 |
||
|
|
9b82a95b7e |
feat: Settings can re-download the Discord images the None naming broke (3999)
CI / lint (push) Successful in 3s
Build images / sign-extension (push) Successful in 4s
CI / extension-version (push) Successful in 2s
Build images / build-agent (push) Successful in 6s
CI / frontend-build (push) Successful in 19s
CI / backend-lint-and-test (push) Successful in 33s
Build images / build-web (push) Successful in 1m9s
Build images / smoke-web (push) Skipped
Build images / build-ml (push) Successful in 2m3s
Build images / promote (push) Skipped
CI / integration (push) Successful in 2m35s
The operator chose a clean re-download over relinking in place. The ~1,600 broken files can't be relinked reliably: their message ids are gone, and their sidecars collided.
Settings → Maintenance → "Repair Discord downloads" previews, then applies:
- Deletes every image whose path is `…/discord/None/<8 digits>_None_…`. Both the folder and the name are required, because that pair is only what the bug produced. It reuses cleanup_service.delete_images for the record and file deletes.
- Sweeps the leftover collided sidecars from those folders and removes the emptied folders.
- Only then clears gallery-dl's archive rows `discord%`, excluding `discordasset_%`. Upstream keys message attachments as `discord{message_id}_{num}`. Since the broken files lost their message ids, per-source forgetting is impossible. Every pre-fix Discord download is broken, and files fetched after the fix still exist on disk, so gallery-dl's `skip` won't re-fetch them.
- Arms a fresh backfill on every Discord source.
The apply defaults to preview at both the route and the task, runs on maintenance_long, and is never on a beat. The card uses the confirm-dialog pattern of AttachmentReclaimCard.
Supporting refactors, with no behaviour change:
- gallery_dl.archive_path() is the single definition of the archive location.
- source_service.arm_backfill() is the mutation start_backfill already did, now shared with the sync repair.
Tests (tests/test_discord_repair.py):
- The archive clear leaves other platforms and Discord assets alone, and counting mutates nothing.
- Case-twin artist folders are both found.
- The folder sweep works.
- An integration run shows only the broken image goes. A correctly named Discord file and a `None` folder under Patreon survive, and only Discord sources are re-armed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SHQB1YukL3VyvMK8rcbmV9
|
||
|
|
dc840feec7 |
fix: Discord downloads land as None/<date>_None_<name> with no post — name them from the keys gallery-dl really emits
CI / lint (push) Successful in 2s
Build images / sign-extension (push) Successful in 3s
CI / extension-version (push) Successful in 2s
Build images / build-agent (push) Successful in 5s
CI / frontend-build (push) Successful in 23s
CI / backend-lint-and-test (push) Successful in 35s
Build images / build-web (push) Successful in 1m17s
Build images / smoke-web (push) Skipped
Build images / build-ml (push) Successful in 2m15s
Build images / promote (push) Skipped
CI / integration (push) Successful in 2m34s
Operator report: a Discord source showed the wrong post time. Listing the real downloads found every Discord folder on the instance (7 artist folders, ~1,600 media) in a directory named `None`, as `<date>_None_<original name>`, next to sidecars named `<original name>.json`.
The real sidecar settles why:
- gallery-dl's discord extractor emits `channel` as a plain string, the message as `message_id`, and the attachment position as `num`. It has no `id` key.
- The patterns asked for `{channel[name]}` and `{id}`. gallery-dl renders a missing field as "None" and carries on.
- The sidecar was named `{filename}.json`, the attachment's ORIGINAL name. find_sidecar never pairs that with `<date>_None_<name>.png`, so no Discord file ever got a Post or a post date. The card fell back to downloaded_at.
- Every `image.png` in a channel also overwrote the same `image.json`.
Fix (gallery_dl.py):
- The directory is `{channel}`.
- The filename is `{date:%Y%m%d}_{message_id}_{num:>02}_{filename}.{extension}`, unique per attachment.
- A discord-scoped metadata postprocessor names the sidecar exactly like the media minus its extension, so find_sidecar's first candidate matches. A per-source filename override re-derives the sidecar name. save_metadata=False drops it.
Guard (tests/test_gallery_dl_naming.py) renders the patterns through Python's formatter against a sanitized copy of the real sidecar (same keys and types, invented values). A missing key or a subscript into a string raises, which is the loud failure gallery-dl doesn't give. A positive control shows both shipped patterns fail it.
Existing broken downloads are NOT repaired by this. gallery-dl's archive already records them, so a re-run skips them, and their collided sidecars no longer describe them. That repair is a separate, destructive step for the operator to decide on.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SHQB1YukL3VyvMK8rcbmV9
|
||
|
|
98c3b74260 |
feat: the Latest feed uses a wide window — filmstrip cards, a day gutter and a filter rail (407 A, D, E)
CI / lint (push) Successful in 3s
CI / extension-version (push) Successful in 2s
Build images / sign-extension (push) Successful in 4s
Build images / build-agent (push) Successful in 6s
Build images / build-ml (push) Successful in 6s
CI / frontend-build (push) Successful in 25s
CI / backend-lint-and-test (push) Successful in 33s
Build images / build-web (push) Successful in 1m1s
Build images / smoke-web (push) Skipped
Build images / promote (push) Skipped
CI / integration (push) Successful in 2m10s
On the operator's 3432px window the feed was a 900px column, 26% of the width. They picked three options from a to-scale layout study. A — filmstrip card (PostCard.vue): - A card measures itself with a ResizeObserver. At 1100px or wider its hero gets a fixed height, clamp(260px, 34vh, 460px), and the extra images move into a 2-column grid of squares beside it. The grid cells are sized from the hero height, so the grid ends flush with the hero. - The rail cap is 4 cells in this layout (2×2, the last becoming "+N") and 5 in the narrow layout, which is unchanged. - The description clamp drops to 4 lines, because long reads happen in the expanded view. - The hero has a height, not a width, so a wide card can't grow into a full-screen post. That was the operator's constraint. D — day gutter (PostsView.vue): - The normal feed groups consecutive posts by local day (Today, Yesterday, a weekday, or a date), with post and artist counts for what has loaded. - Runs rather than date buckets, because the sort key includes resurfaced_at, which the payload doesn't carry. A resurfaced grouping gets its own heading where it actually appears, instead of being pulled out of order. E — filter rail (PostsView.vue): - At 1600px and wider, the filters and status ribbon stack in a sticky 280px left rail, and each day's heading sits in a sticky 150px gutter beside its posts. - Below 1600px the layout is exactly the old one, including the 900px column. The in-context (post_id) view gets the wide column but no rail or day grouping, so anchor scrolling is untouched. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SHQB1YukL3VyvMK8rcbmV9 |
||
|
|
4b4e532c56 |
fix: the membership status vocabulary moves beside Membership, so the Patreon client never imports the roster
CI / extension-version (push) Successful in 3s
Build images / sign-extension (push) Successful in 4s
CI / lint (push) Successful in 4s
Build images / build-agent (push) Successful in 7s
CI / frontend-build (push) Successful in 26s
CI / backend-lint-and-test (push) Successful in 35s
Build images / build-web (push) Successful in 1m20s
Build images / smoke-web (push) Skipped
Build images / build-ml (push) Successful in 2m6s
Build images / promote (push) Skipped
CI / integration (push) Successful in 2m33s
|
||
|
|
240f11c5aa |
fix: one lapsed membership to a deleted creator no longer fails the Patreon roster
CI / lint (push) Successful in 3s
CI / extension-version (push) Successful in 3s
Build images / sign-extension (push) Successful in 5s
Build images / build-agent (push) Successful in 7s
CI / backend-lint-and-test (push) Failing after 35s
CI / frontend-build (push) Successful in 32s
Build images / build-web (push) Successful in 1m20s
Build images / smoke-web (push) Skipped
Build images / build-ml (push) Successful in 2m17s
Build images / promote (push) Skipped
CI / integration (push) Successful in 2m26s
The first live sweep failed with "Patreon member resource has no campaign relationship". The operator ran FC's exact /api/members request in a browser and shared the response. - It has 104 rows. The browser's settings page shows 43, because FC sends no membership-type filter and so also gets lapses back to 2016. - Exactly one row has no `campaign` key at all: a former_patron whose membership ended in 2017. Its included reward has no campaign link either, so the creator's page is gone. - Every other row, including the 4 is_active=false campaigns, has a campaign. _membership returns None for a campaign-less row only when has_paid_access says it is definitely not paying (a known lapsed status, or a free member), and iter_memberships skips it. That changes no conclusion. A lapsed membership and an absent one both mean "not paying", and no Source can match a campaign with no id. An active or unrecognised membership without a campaign still raises, because dropping a membership that might be paid would read downstream as a cancellation. Paging still counts the rows the server sent, not the rows kept. A test pins that, so a skip can't re-read an offset or stop a page short. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SHQB1YukL3VyvMK8rcbmV9 |
||
|
|
57c880a623 |
feat: the full brand mark as a faint backdrop behind every page
CI / lint (push) Successful in 2s
CI / extension-version (push) Successful in 2s
Build images / sign-extension (push) Successful in 4s
Build images / build-agent (push) Successful in 5s
Build images / build-ml (push) Successful in 6s
CI / frontend-build (push) Successful in 22s
CI / backend-lint-and-test (push) Successful in 31s
Build images / build-web (push) Successful in 1m9s
Build images / smoke-web (push) Skipped
Build images / promote (push) Skipped
CI / integration (push) Successful in 2m36s
The operator asked for one large version of the logo as the site background. It is pinned to the viewport behind .fc-content at about 6% strength, so it shows in the gutters and on bare page ground while cards and the nav cover it. The series reader is immersive, skips the shell, and never draws over it. It is a layered background (the page colour at 94% over logo.svg), not an overlay element with opacity. An overlay needs the content z-indexed above it, which turns every page into one stacking context under the nav's z-index 1000 and can trap an in-page overlay beneath the nav. A background changes nothing about stacking. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SHQB1YukL3VyvMK8rcbmV9 |
||
|
|
dd766eb976 |
fix: the favicon and nav brand mark rendered blank — "--" inside an XML comment
CI / lint (push) Successful in 2s
CI / extension-version (push) Successful in 2s
Build images / sign-extension (push) Successful in 4s
CI / frontend-build (push) Successful in 21s
CI / backend-lint-and-test (push) Successful in 32s
Build images / smoke-web (push) Skipped
Build images / promote (push) Skipped
Build images / build-agent (push) Successful in 5s
Build images / build-ml (push) Successful in 7s
Build images / build-web (push) Successful in 1m8s
CI / integration (push) Successful in 2m36s
7ca6ee0's favicon.svg had a comment naming the `--fc-chrome-rgb` custom property. `--` is illegal inside an XML comment, so the file is not well-formed XML, and a browser renders an SVG-as-image only if it parses. It fails without any error, just a blank image. Because TopNav's brand glyph is the same file, the tab icon and the nav mark both went missing on the #251 deploy. logo.svg parses and was unaffected. The comment now names the property without the hyphens, and says why it has to. tests/test_public_svgs.py parses every SVG under frontend/public. It includes a vacuity guard (the directory really contains the two known files) and a positive control (the exact defect raises ParseError), so the check can actually fail. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SHQB1YukL3VyvMK8rcbmV9 |
||
|
|
e3c516d6be |
fix: a retired platform leaves the chip maps, and a fixture stops creating pixiv sources (406)
CI / lint (push) Successful in 3s
CI / extension-version (push) Successful in 3s
Build images / sign-extension (push) Successful in 4s
Build images / build-agent (push) Successful in 6s
Build images / build-ml (push) Successful in 6s
CI / frontend-build (push) Successful in 22s
CI / backend-lint-and-test (push) Successful in 34s
Build images / build-web (push) Successful in 1m5s
Build images / smoke-web (push) Skipped
Build images / promote (push) Skipped
CI / integration (push) Successful in 2m25s
extension / lint (pull_request) Successful in 49s
|
||
|
|
c2f9e9cc08 |
docs: stop claiming pixiv, and stop claiming everything gallery-dl supports (406 step 4)
CI / lint (push) Successful in 3s
CI / extension-version (push) Successful in 2s
Build images / build-agent (push) Successful in 7s
CI / frontend-build (push) Successful in 24s
CI / backend-lint-and-test (push) Failing after 53s
extension / lint (push) Successful in 22s
Build images / build-ml (push) Successful in 2m10s
CI / integration (push) Failing after 2m33s
Build images / sign-extension (push) Successful in 5m33s
Build images / build-web (push) Successful in 1m7s
Build images / smoke-web (push) Skipped
Build images / promote (push) Skipped
Ships with the switch-off rather than with the code removal: a doc that promises a platform the product refuses is the Install and Public Surface area's characteristic defect. pixiv comes out of README (twice), SECURITY.md (twice), .env.example and the compose header. The stored-credential warnings now name Patreon and SubscribeStar - the accounts that usually carry a payment method. One correction beyond pixiv. README said FabledCurator follows creators on Patreon, SubscribeStar, Pixiv "and anything gallery-dl supports". That was already false: a platform not in the registry is rejected, however capable gallery-dl is. It now names the real set, which rule 171 records: Patreon, SubscribeStar, Discord and HentaiFoundry. The 3422 docs guards still hold - the key path and bootstrap variable are untouched. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SHQB1YukL3VyvMK8rcbmV9 |
||
|
|
e3fd8c67d4 |
feat: switch pixiv off — unregistered, unreachable, and refused at dispatch (406 phase 1)
Milestone 406 retires pixiv (rule 171) in two phases at the operator's explicit ask: switch it off, then later delete its code. This is the switch-off. Steps 2 and 3 ship together because each is a half-state of the other: unregistered but still in the extension, pixiv creator pages would offer a button the backend then refuses. Reachability removed, never gated (rule 22 - no flag, no `if platform == "pixiv"`): - platforms registry: pixiv unregistered, so /api/platforms, the source validator and quick-add all refuse it through their existing unknown-platform paths. - NATIVE_INGESTER_PLATFORMS: pixiv removed. - extension_service: pixiv's quick-add URL pattern removed (the Python half of the JS mirror). - extension: pixiv's host permissions, content-script match, platform entry and artist pattern removed; popup's pixiv branches removed; and the whole pixiv PKCE OAuth flow cut out of background.js. That last one could not wait for phase 2 - a webRequest listener on a host the manifest no longer grants is at best dead and at worst a startup failure for the entire background script. On startup the extension now also removes any pixiv refresh token a browser still holds in storage, for the same reason as the server-side credential cleanup (3980). - frontend: the extension card stops listing pixiv; SourceActions' copy of the native list drops it. platformColor keeps rendering a pixiv key so existing pixiv posts do not look broken. The guard, and why a registry change alone was not enough. A source outlives its platform: the live instance still had one ENABLED pixiv source (step 1). Tracing it: the scheduler only selects enabled rows and every platform lookup uses .get(), so a disabled row is inert - but re-enabling it and pressing Check would have routed pixiv, no longer native, straight into the gallery-dl branch, which still has a pixiv extractor. And a worker can pick up a still-enabled row before a deploy's migration runs. So run_download and verify_source_credential - the two functions every download and credential probe pass through - now refuse any platform not in the registry: an unsupported_url failure for downloads, and an inconclusive (None, not False) verify, since nothing was probed so nothing was rejected. Generic by registration, so it covers deviantart's leftovers too. Positive-controlled: a supported gallery-dl platform must still reach gallery-dl, or a guard that refused everything would pass (rule 167). Migration 0097 disables sources on retired platforms (pixiv, deviantart) and clears their failure state exactly as disabling through the app does (1285), so the stale row stops being scheduled and stops showing as failing. Nothing is deleted: removing a source can collide with uq_post_artist_external_id_null_source on real data, which is phase 2's step 6 to check. No post or image is touched. Tests: the known-platform lists drop pixiv and gain retirement assertions beside deviantart's; pixiv's positive extension cases become negative guards; the pixiv sidecar post-URL test is deleted with the behaviour it tested; quick-add rejects a pixiv URL. The pixiv client/downloader/ingester suites stay - that code stays until phase 2. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SHQB1YukL3VyvMK8rcbmV9 |
||
|
|
0835da8a91 |
fix: give the roster's column zip an explicit strict=False (387 D1, B905)
CI / lint (push) Successful in 3s
CI / extension-version (push) Successful in 3s
Build images / sign-extension (push) Successful in 4s
Build images / build-agent (push) Successful in 6s
CI / frontend-build (push) Successful in 22s
CI / backend-lint-and-test (push) Successful in 32s
Build images / build-web (push) Successful in 58s
Build images / smoke-web (push) Skipped
CI / integration (push) Successful in 2m5s
Build images / build-ml (push) Successful in 2m46s
Build images / promote (push) Skipped
|
||
|
|
ef91fcfd26 |
feat: SubscribeStar joins the membership roster (387 D1)
CI / lint (push) Failing after 2s
CI / extension-version (push) Successful in 3s
Build images / sign-extension (push) Successful in 3s
Build images / build-agent (push) Successful in 6s
CI / frontend-build (push) Successful in 21s
CI / backend-lint-and-test (push) Successful in 32s
Build images / build-web (push) Successful in 55s
Build images / smoke-web (push) Skipped
Build images / build-ml (push) Successful in 1m41s
Build images / promote (push) Skipped
CI / integration (push) Successful in 2m12s
The second platform through the seam note 3970 contracted, characterized first from a live capture of the account's /subscriptions page (note 3989). The capture lives in the gitignored captures dir; the committed fixture is hand-built with invented values and was verified tag-for-tag against it - card wrappers, both table heads, and every distinct row shape - before any code depended on it. What the page is, and the three decisions it forced: The table IS the status. SubscribeStar has no per-row status word: a creator is either in the active_subscriptions card or the cancelled_subscriptions one. The card's data-identifier is stored verbatim as Membership.status and mapped in MEMBERSHIP_STATUS, keyed on the identifier rather than the table class because the cancelled table's class names the same list differently (for-unsubscribed_users). The creator's numeric data-user-id is the key, not the slug. A slug re-keys when a creator renames; the old row stops appearing; and a disappearance is exactly what reconciliation reads as a lapse. Keyed on the slug, a rename would have told a paying subscriber they had cancelled. The slug rides as vanity, where the identity join already looks for a handle. Price is kept as text, never parsed into amount_cents. A bare $ names no currency and a page price is not proven to be the charge - 3970 finding 4. Tier names live behind a per-row modal and are not fetched. Refusals, because SubscribeStar offers nothing like Patreon's meta.pagination.total and every conclusion downstream is drawn from absence. The parser raises when: the active card is missing (auth error on a login/age wall, drift otherwise); a row lacks a numeric creator id or a creator link; anything renders after a card's table; or the page carries a page= link. Both cards are paginatable (app#embed_pagination) and the captured account was too small to show what pagination looks like, so possible pagination is a roster FC cannot prove complete. A loud error on a larger account beats a quiet half-list. A missing cancelled card is not drift, and a creator in both tables is reported once, as active. Fetched from subscribestar.adult, not the .art the capture came from: FC's requests never clear the .art age wall with the 18+ cookie (1259, 1284). Whether /subscriptions on .adult authenticates exactly as .art did in the browser is untested - if not, the sweep records a visible error and C6 shows its unavailable rung. The seam leak D1 found. Note 3970 promised a second platform would be one builders line plus the client method. The sweep instead called current_user_id() on every client, which only Patreon's has, so SubscribeStar would have raised AttributeError on the first sweep. roster_user_id probes it with getattr, the same way the sweep already probes iter_memberships. Two existing tests were passing for the wrong reason and now can fail: - "a platform that has never been characterised says nothing" named SubscribeStar, and stayed green only because active_patron is not a SubscribeStar word. Now uses hentaifoundry, with a positive SubscribeStar test beside it. - the freshness test gave SubscribeStar a Patreon word, so the vocabulary excluded it and deleting the freshness gate outright would have left it green. It now uses cancelled_subscriptions, making the gate the only thing that excludes it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SHQB1YukL3VyvMK8rcbmV9 |
||
|
|
529d4bff57 |
test: tie the install docs to the code they quote (3422 follow-up)
CI / lint (push) Successful in 4s
CI / extension-version (push) Successful in 4s
Build images / sign-extension (push) Successful in 4s
Build images / build-ml (push) Successful in 7s
Build images / build-agent (push) Successful in 7s
Build images / build-web (push) Successful in 5s
Build images / smoke-web (push) Skipped
Build images / promote (push) Skipped
CI / frontend-build (push) Successful in 24s
CI / backend-lint-and-test (push) Successful in 38s
CI / integration (push) Successful in 2m11s
3422 was already fixed. Commit
|
||
|
|
eb6e0df858 |
feat: the empty front door offers to find what you already subscribe to (387 C6)
CI / lint (push) Successful in 2s
Build images / sign-extension (push) Successful in 5s
CI / extension-version (push) Successful in 2s
Build images / build-agent (push) Successful in 8s
Build images / build-ml (push) Successful in 8s
CI / frontend-build (push) Successful in 28s
CI / backend-lint-and-test (push) Successful in 39s
Build images / build-web (push) Successful in 1m16s
Build images / smoke-web (push) Skipped
Build images / promote (push) Skipped
CI / integration (push) Successful in 2m45s
B4's on-ramp read "add a credential, add a source" - the manual path, and the one that makes a new installer retype creators they have already told Patreon about. With C4 in place the app can just look them up. This is the step where the milestone's original framing actually lands on screen. Four rungs, chosen by ONE predicate rather than four independent v-ifs. The rungs are mutually exclusive by construction and the rung shown matches what is actually POSSIBLE: no credential -> Add a credential. Discovery is not offered, because a button that cannot work is worse than its absence. credential, never synced -> Find what you already subscribe to. Queues the C3 sweep. synced, unmatched > 0 -> the count, linking into C4's bucket 1. synced, nothing unmatched-> the manual add, because there is genuinely nothing to discover. The fifth state is rule 164's. A sweep that has been ATTEMPTED and never succeeded reads as "couldn't reach patreon", with the error type and the manual path still open - not a spinner, not a crash, not a retry button that will fail identically. An install with no outbound network lands here. That is deliberately narrower than "there is an error": a roster that synced once and failed since is NOT unavailable. It has a roster, just an ageing one, and C4's freshness gate already handles that. Collapsing the two would hide a usable roster behind an error banner. Still offer, never auto-add. An empty front door is exactly where "just add all thirty" is most tempting and most wrong - thirty backfills on first boot - so the rung carries the count and sends them to the picker. No new machinery: three existing stores (credentials, membershipSync, membershipReconcile). Reconcile is fetched only once something has synced, since bucket 1's count is meaningless before that. Every load swallows its failure, because this screen renders on an install that can reach nothing. Ten tests on top of B4's, including the mutual-exclusion property asserted directly. Its phrases are each pinned to a single template line and both sides whitespace-normalised - a phrase spanning a line break would never match, and a mutual-exclusion check whose phrases never match passes vacuously (rule 167). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SHQB1YukL3VyvMK8rcbmV9 |
||
|
|
4533e036ac |
refactor: the membership seam's contract type is the seam's, not Patreon's (387 C7)
CI / lint (push) Successful in 4s
CI / extension-version (push) Successful in 3s
Build images / sign-extension (push) Successful in 5s
Build images / build-agent (push) Successful in 7s
CI / frontend-build (push) Successful in 29s
CI / backend-lint-and-test (push) Successful in 33s
Build images / build-web (push) Successful in 1m16s
Build images / smoke-web (push) Skipped
Build images / build-ml (push) Successful in 2m36s
Build images / promote (push) Skipped
CI / integration (push) Successful in 3m11s
Membership moves from patreon_client to native_ingest_common, beside PostRecordOutcome, for exactly the reason that one lives there: it is the seam's contract rather than the first platform's. Left where it was, D1 would have had to import the shape it implements from the module of the platform it is being mirrored FROM - which inverts the dependency and is how a seam advertised as portable quietly stays Patreon-shaped. Found by C7's own pass, which is the point of running C7 before D1 rather than writing it up afterwards: this is invisible while there is only one implementer and load-bearing the moment there are two. No behaviour change. Three files, no shim (rule 122): patreon_client imports it, the dataclass keeps its docstring, and the test imports from the seam's home. The docstring gains what the contract owes a second platform - that a missing field supplies the empty answer and never a guess: no tiers -> [], no pledge -> None (absent stays distinguishable from zero, since "free" and "we don't know" are different answers), no vanity -> None with identity falling back to the URL tail. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SHQB1YukL3VyvMK8rcbmV9 |
||
|
|
6f5ea5d1d3 |
fix: the provenance panel hotlinked the CDN for images already on disk (3965)
CI / lint (push) Successful in 8s
CI / extension-version (push) Successful in 9s
Build images / sign-extension (push) Successful in 9s
Build images / build-agent (push) Successful in 17s
CI / frontend-build (push) Successful in 35s
CI / backend-lint-and-test (push) Successful in 1m18s
Build images / build-web (push) Successful in 1m22s
Build images / smoke-web (push) Skipped
Build images / build-ml (push) Successful in 2m19s
Build images / promote (push) Skipped
CI / integration (push) Successful in 2m38s
Two surfaces render a post's HTML body. get_post did _localize_inline_images(sanitize_post_html(...)); provenance_service._post_dict called sanitize_post_html alone. So opening the Provenance panel fetched images from Patreon's CDN for files FC had already downloaded - the archive reaching out to the platform to display what it had archived, which is the thing 830 Phase 2 set out to stop. The same bodies also break when a CDN URL expires or a post is removed, while the identical local copy sits unused. The fix is the shape, not the call. Those were two separately-callable halves and only the first looked mandatory, so a second caller was always going to do half of it. render_post_body in the new services/post_body.py is the whole pipeline in one call, and sanitizing-without-localizing is no longer a reachable operation. _localize_inline_images moves there verbatim; post_feed_service loses five imports that went with it. _post_dict becomes async and takes the session. Both call sites are already inside async methods, so for_image's list comprehension awaits per entry - fine, because localization issues ZERO queries for a body with no inline <img>, which is most of them. Recorded that early exit in the module so the loop isn't "optimized" into a batch without a measurement. Deliberately NOT fixed: provenance still names the same columns url/title/date where the feed says post_url/post_title/post_date, and its description_translated is full text where the feed truncates to DESCRIPTION_LIMIT. That is 3965's wider half - a breaking payload change for ProvenancePanel with no second reason to spend it today. Noted in _post_dict's docstring so the next reader knows it was seen and left. Four regression tests on the provenance path, covering both entry points plus the two refusals the feed already pins: an uncaptured image stays hotlinked (a broken local path is worse than an intact remote one), and a filehash owned by another artist never leaks in. Reverting render_post_body to a bare sanitize fails the first two. Recorded as snippet 3968. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SHQB1YukL3VyvMK8rcbmV9 |
||
|
|
2862fadcb1 |
fix: the roster guard's import walk resolved package __init__ imports wrongly (387 C5)
CI / lint (push) Successful in 2s
Build images / sign-extension (push) Successful in 3s
CI / extension-version (push) Successful in 1s
Build images / build-agent (push) Successful in 6s
Build images / build-ml (push) Successful in 6s
CI / frontend-build (push) Successful in 30s
CI / backend-lint-and-test (push) Successful in 32s
Build images / build-web (push) Successful in 7s
Build images / smoke-web (push) Skipped
Build images / promote (push) Skipped
CI / integration (push) Successful in 2m37s
Two of C5's three structural tests errored in CI with ValueError: PosixPath('.') has an empty name. The walk special-cased a package's __init__.py, dropping the __init__ component before computing what `from .` refers to — which made `api/__init__.py`'s `from . import health` resolve to the app root instead of to `api`, and `celery_app.py`'s `from . import celery_signals` resolve to the empty string, which is what actually crashed.
The special case was never needed: `parts[:-1]` already gives the CONTAINING package for both forms, because `services/foo.py` drops `foo` to leave `services` and `api/__init__.py` drops `__init__` to leave `api` — exactly what `from .` means inside each. The level slice is now clamped at 0 as well; an import climbing past backend/app left the tree, and the unclamped negative index wrapped and resolved to the wrong module rather than to nothing.
A module directly under backend/app doing `from . import x` still yields no package prefix, and there the alias alone IS the dotted name - handled explicitly rather than by falling through into a Path built from an empty string.
The two positive controls earned their place immediately: they are what failed. Without them the walk would have resolved almost nothing and test_no_fetch_path_can_read_the_roster would have passed on a broken walker, reading as coverage.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SHQB1YukL3VyvMK8rcbmV9
|
||
|
|
aa765f0a72 |
feat: say why the posts are invisible, without ever deciding they are (387 C5)
Build images / promote (push) Skipped
CI / lint (push) Successful in 3s
CI / extension-version (push) Successful in 3s
Build images / sign-extension (push) Successful in 4s
Build images / build-agent (push) Successful in 7s
CI / frontend-build (push) Successful in 21s
CI / backend-lint-and-test (push) Failing after 32s
Build images / build-web (push) Successful in 58s
Build images / smoke-web (push) Skipped
Build images / build-ml (push) Successful in 1m47s
CI / integration (push) Successful in 2m16s
A3 made a tier-gated source say "47 posts you can't see". The roster turns that into a reason: the membership ended, or the tier doesn't reach these posts, or it's a free follow. Rendered under A3's count in the health tooltip, quieter than the count it explains. FREE is a fourth case the step didn't enumerate, and it earns its own sentence. has_paid_access collapses "former patron" and "current free follower" to the same False, so deriving the reason from that boolean would tell a free follower "you're not a patron any more" - a false statement about a state they were never in. gated_reason reads the status axis first, calling has_paid_access with is_free_member forced off, then splits on the free flag. Silence is the default, and there are four ways into it: campaign absent from the roster, roster stale, platform never swept, status word not yet characterised. All four send null and the count stands alone. The frontend has no fallback sentence either - a default would turn "we don't know why" into a reason, which is the one thing this step must not do. The line that must not be crossed is pinned structurally rather than by inspection: test_no_fetch_path_can_read_the_roster walks the transitive first-party imports from the fetch roots and asserts the roster is unreachable. FC runs no local verification (rule 85), so a guard cannot be falsified by hand before it lands - it carries two positive controls instead, proving the walker finds roster imports that ARE there, one direct and one through a hop, so the real assertion can never pass merely because the walk resolved nothing. C4's identity loop moved to membership_roster.pair_sources_with_memberships when C5 became its second caller; two copies would let the Subscriptions row and the reconciliation card disagree about which creator a source IS. Three test files were each building PlatformMembership rows with their own drifting helper - consolidated into tests/roster_builders.py, same family as issue 3109. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SHQB1YukL3VyvMK8rcbmV9 |
||
|
|
de11c14448 |
refactor: one relative-time formatter, not three
CI / lint (push) Successful in 2s
CI / extension-version (push) Successful in 2s
Build images / sign-extension (push) Successful in 4s
Build images / build-agent (push) Successful in 6s
Build images / build-ml (push) Successful in 6s
CI / frontend-build (push) Successful in 23s
CI / backend-lint-and-test (push) Successful in 31s
Build images / build-web (push) Successful in 1m26s
Build images / smoke-web (push) Skipped
Build images / promote (push) Skipped
CI / integration (push) Successful in 2m11s
MembershipRosterCard had grown its own ago(iso) helper, a near-copy of utils/date.js::formatRelative. C4's card was about to become a third copy before a hook caught it. Both now use the shared helper. The hand-rolled copy was also slightly wrong in ways the shared one is not: it floored everything under a minute to '1m ago', and would have rendered NaNm ago for a null timestamp had a caller ever reached it without a v-if guard. Only sub-minute output changes, which no spec exercises - membershipRosterCard.spec.js seeds its rows at exactly 1h and 9d, where both helpers agree, and asserts on literal phrases rather than time strings. Recorded formatRelative as snippet 3959 so the next component is offered it instead of deriving a fourth copy. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LNXXULQDjVZmbuNa2G9mD9 |
||
|
|
005680f234 |
fix: one source edit no longer wipes the campaign id and the backfill position
Build images / sign-extension (push) Successful in 2s
Build images / build-agent (push) Successful in 6s
CI / lint (push) Successful in 1s
CI / extension-version (push) Successful in 2s
CI / backend-lint-and-test (push) Successful in 39s
CI / frontend-build (push) Successful in 28s
Build images / build-web (push) Successful in 1m10s
Build images / smoke-web (push) Skipped
Build images / build-ml (push) Successful in 3m4s
Build images / promote (push) Skipped
CI / integration (push) Successful in 2m22s
Source.config_overrides carries two unrelated things under one column: the operator's per-source download settings, and state FC writes for itself. update() treated the whole column as operator-owned and assigned it wholesale, so a dialog save discarded patreon_campaign_id and the entire #693 backfill state machine. Not a hand-edited-JSON edge case: SourceFormDialog's structured tab rebuilds the object from two fields, so saving without touching anything was enough. _merged_config now merges: the operator's keys replace wholesale (removing a key must still remove it), FC's keys survive and are applied LAST so a stale echoed cursor cannot roll a walk backwards. App-managed is _-prefixed or *_campaign_id, both matching data already on disk, so no migration. Preserving the id exposed a bug the wipe was MASKING: nothing cleared it when a source's URL changed, and patreon_resolver reads that cache before attempting any lookup. A repointed source would have resolved the old creator forever and 387 C4 would have reported a confident wrong match. So update() drops *_campaign_id when the URL actually changes - but keeps the backfill cursor, which the walk's own stall guard validates and which is expensive to rebuild. test_update_changes_fields was DOCUMENTING the bug: it asserted config_overrides == {videos: False} on a source whose create() had armed _backfill_state, so it could only pass because the state had been destroyed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LNXXULQDjVZmbuNa2G9mD9 |
||
|
|
fc136006b7 |
feat: what you pay for, against what FC actually follows (387 C4)
CI / lint (push) Successful in 2s
Build images / sign-extension (push) Successful in 3s
CI / extension-version (push) Successful in 2s
Build images / build-agent (push) Successful in 5s
CI / frontend-build (push) Successful in 21s
CI / backend-lint-and-test (push) Successful in 32s
Build images / build-web (push) Successful in 1m18s
Build images / smoke-web (push) Skipped
Build images / build-ml (push) Successful in 2m11s
Build images / promote (push) Skipped
CI / integration (push) Successful in 3m4s
Reconciliation in Subscriptions, asymmetric on purpose. Subscriptions FC does not follow get a per-row add; sources the roster cannot account for are REPORT ONLY (operator decision) and link to the list on the same page. No one-click disable, so no disabled-reason column and no migration. The membership<->source join lands as a SHARED resolver in membership_roster, not inline here: E4 now uses it as its negative check, so the two features cannot give different answers to 'is this membership already tracked?'. Keys on the exact cached campaign id FIRST and the URL handle only as fallback, because the id is written only after a source has been walked once. Read via any <platform>_campaign_id override rather than naming Patreon's, per rule 169. The report-only bucket is gated on roster freshness and carries a per-row basis, so 'your membership says former patron', 'we know this id and it is absent', and 'we only have a handle' stay three different sentences. has_paid_access None never reads as lapsed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LNXXULQDjVZmbuNa2G9mD9 |
||
|
|
f8614d437d |
fix: a self-contradicting test fixture, and import order (E4 follow-up)
CI / lint (push) Successful in 3s
Build images / sign-extension (push) Successful in 4s
CI / extension-version (push) Successful in 2s
Build images / build-agent (push) Successful in 7s
CI / frontend-build (push) Successful in 21s
CI / backend-lint-and-test (push) Successful in 34s
Build images / build-web (push) Successful in 1m13s
Build images / smoke-web (push) Skipped
Build images / build-ml (push) Successful in 2m15s
Build images / promote (push) Skipped
CI / integration (push) Successful in 2m46s
Two failures on
|
||
|
|
51e78a329b |
feat: offer the creator you already track as the one you subscribe to (388 E4)
CI / lint (push) Failing after 3s
CI / extension-version (push) Successful in 3s
Build images / sign-extension (push) Successful in 4s
Build images / build-agent (push) Successful in 8s
CI / frontend-build (push) Successful in 23s
CI / backend-lint-and-test (push) Successful in 31s
Build images / build-ml (push) Successful in 2m23s
Build images / build-web (push) Successful in 1m25s
Build images / smoke-web (push) Skipped
Build images / promote (push) Skipped
CI / integration (push) Failing after 2m31s
**The verification the step asked for came back "not the schema".**
`Source.artist_id` is a plain FK so many sources per artist already works;
`POST /api/sources` already takes an `artist_id`; the add-source dialog already
has an artist autocomplete that attaches to an EXISTING artist; and
`SourceService.reassign` already moves a source between artists WITH post and
image re-attribution. A sweep for one-source-per-artist assumptions found only
`func.count()` calls — the opposite of assuming one.
So no parallel association table was built for a relationship the schema
already expresses (rule 28). What was missing is FC OFFERING the link, and that
is all this adds.
**Accepting adds a SOURCE. It never merges two artists.** That asymmetry sets
the whole posture: adding a source is trivially undone, while a wrong merge
silently mixes two creators' work and corrupts tagging, series and provenance
downstream with nothing left to tell them apart by. A test asserts the artist
count is unchanged by accepting.
The weights encode the judgement rather than a code path doing it — name 0.65,
declared 0.35, cut at 0.60 — so that:
* an EXACT name match alone proposes (same slug on both sides is strong, and
demanding corroboration would propose almost nothing);
* a CONTAINMENT match alone does not ("art" sits inside "artgirl"), and short
slugs are excluded from containment entirely because a 3-character slug is
inside a great many longer ones;
* the declaration ALONE never proposes, because a creator may link another
creator's Patreon and a link is not a claim of identity.
A guard test pins all three against WEIGHTS directly and says not to fix a
failure by moving the numbers.
Two corrections carried forward from earlier steps rather than rediscovered:
* The declaration is NOT read from `ExternalLink`. `SUPPORTED_HOSTS` is file
hosts only and `host_for()` returns None for patreon.com, so no row is ever
written for one — the same trap that caught E5 for Discord invites. It reads
the raw body, because these links live in an `href` and `html_to_plain`
discards attributes.
* `vanity` is not a column: C1 modelled the roster before any platform was
characterised, which is exactly what `details` exists for. `vanity_or_none()`
reads it from there and falls back to the URL's last segment, so a row
written before the field was understood still resolves.
Two fixes during the writing. `accept()` first created a bare `Source()`,
skipping the platform/URL validation, duplicate check and #693 backfill-arming
that a hand-added source gets — a second, quieter way to create a source is how
two paths drift until one is subtly broken; it now goes through
`SourceService.create`. And the candidate query used a bare `exists().where()`,
which has no FROM to correlate against; now `select(...).exists()`.
Chained onto the roster sweep rather than given its own beat entry: a
suggestion can only be as good as the roster behind it, so any other cadence
would just propose from staler data.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LNXXULQDjVZmbuNa2G9mD9
|
||
|
|
61abd0007c |
fix: stdlib imports split by a stray blank line (isort I001)
CI / extension-version (push) Successful in 3s
CI / lint (push) Successful in 3s
Build images / sign-extension (push) Successful in 5s
Build images / build-agent (push) Successful in 7s
CI / frontend-build (push) Successful in 30s
CI / backend-lint-and-test (push) Successful in 35s
Build images / build-web (push) Successful in 1m34s
Build images / smoke-web (push) Skipped
CI / integration (push) Successful in 2m42s
Build images / build-ml (push) Successful in 3m1s
Build images / promote (push) Skipped
My scripted patch inserted the new stdlib from-imports after `import logging`
with a blank line between them, which isort reads as a group boundary.
Ruff-only failure — every test passed on
|
||
|
|
751e7ddb9f |
feat: the membership sweep, and the state that makes its failures readable (387 C3)
CI / lint (push) Failing after 3s
Build images / sign-extension (push) Successful in 4s
CI / extension-version (push) Successful in 2s
Build images / build-agent (push) Successful in 8s
CI / frontend-build (push) Successful in 28s
CI / backend-lint-and-test (push) Successful in 38s
Build images / build-web (push) Successful in 1m28s
Build images / smoke-web (push) Skipped
Build images / build-ml (push) Successful in 2m35s
Build images / promote (push) Skipped
CI / integration (push) Successful in 2m55s
A daily sweep that walks each platform's roster into `platform_membership`. Daily because memberships change on a BILLING cycle, not a download cadence. **`membership_sync` is the part that earns its keep.** Without it three very different situations are one indistinguishable state — the account subscribes to nothing, the sweep never ran, the sweep failed — and all three leave zero rows in `platform_membership`. "You are tracking 12 sources you no longer subscribe to" is correct in the first case and an invitation to cancel things the operator is actively paying for in the other two. So C4 gates its CONCLUSIONS on `last_success_at`, not merely its display, and `roster_is_fresh` is computed server-side so no caller can forget to. Two timestamps rather than one: `last_attempt_at` moves every run, `last_success_at` only on a clean walk. The gap between them is the signal — a sweep hammering a broken credential every day must not look healthy because it ran recently, and there is a test for exactly that. Rejected shortcuts, both tempting: `MAX(platform_membership.last_seen_at)` cannot tell "synced fine, found nothing" from "never synced"; `task_run` is worse, since its retention prunes ok rows after 24h and a sweep that last succeeded three days ago would leave no trace at all. **The fetch completes before anything is written.** That ordering is the safety property: a walk that dies mid-pagination writes nothing, so a failure can never leave a roster half this week's and half last week's. `touch_membership` never deletes, so a failure cannot empty the roster either — but "intact" should mean intact, not merely non-empty. Rule 89's four, each where it actually lives: recovery is "run it again" (upsert, no deletes); retention is C1's age-out-never-delete, because disappearing IS the signal; the wall-clock deadline is per-platform and distinct from the per-REQUEST timeout the client already has (rule 156 — a paginated roster answering every page slowly-but-within-timeout would never trip that one and would sit on a worker indefinitely); duration comes from the existing TaskRun signal plumbing. **A bug caught in review, not production:** the broad `except Exception` would have swallowed Celery's SoftTimeLimitExceeded — which is an ORDINARY Exception subclass, not a BaseException — letting the sweep run past the soft limit into the hard one, where it is SIGKILLed mid-transaction. A sweep that cannot be stopped is worse than one that fails. Now re-raised explicitly, with a test that also asserts SoftTimeLimitExceeded is still an Exception, so the re-raise cannot quietly become dead code. Rule 164 is why this ships with UI rather than backend-only: a roster that never synced must be VISIBLE as such. The card says "never synced" in words and states no count at all — rendering it as 0 is the precise conflation the whole step exists to prevent — while a real zero behind a real sync is reported as zero, because that one IS an answer. Pinned in both directions. Three independent gates decide whether a platform is swept — registered here, client exposes `iter_memberships`, credential exists — each silent, so adding SubscribeStar (D1) is one line and nothing else. A missing credential is not an error: recording a failure would light up the UI for a feature never enabled. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LNXXULQDjVZmbuNa2G9mD9 |
||
|
|
afcde8e457 |
feat: PatreonClient.iter_memberships — the roster seam (milestone 387 step C2)
CI / lint (push) Successful in 3s
CI / extension-version (push) Successful in 3s
Build images / sign-extension (push) Successful in 5s
Build images / build-agent (push) Successful in 7s
CI / frontend-build (push) Successful in 24s
CI / backend-lint-and-test (push) Successful in 58s
Build images / build-web (push) Successful in 1m16s
Build images / smoke-web (push) Skipped
CI / integration (push) Successful in 2m25s
Build images / build-ml (push) Successful in 2m45s
Build images / promote (push) Skipped
Built on C0's real capture (Scribe note #3886), not on API docs — gallery-dl has no membership extractor and Patreon's public v2 API is the CREATOR surface behind OAuth, so the rule-130 reference had to be a characterized response. **The request is deliberately minimal, and that is a privacy decision.** The browser's own include set pulls `latest_pledge.card`, and those card resources come back carrying the ACCOUNT HOLDER'S EMAIL in `merchant_name`; `address` is in there too. Copying the query string wholesale is the obvious move and would have FC fetching payment PII it has no use for and can only mishandle. We ask for `include=campaign,reward` and nothing else, and a test asserts on the params actually sent so nobody widens it back. **We do not send `filter[membership_type]`.** The browser sends the six buckets its settings page displays, which excludes lapsed memberships — and a DISAPPEARANCE is precisely the signal the roster exists to read. Filtering here would manufacture the event C4 acts on. Two corrections the capture forced, both now in code: * **The filter vocabulary is not the status vocabulary.** I had read the six filter words off a screenshot and was about to write them into MEMBERSHIP_STATUS as the enum. The body shows `patron_status` carrying `former_patron` — absent from that filter — on a row the filter selected as `free_member`. So the map is taught exactly the two OBSERVED values, and `declined_patron` stays out despite looking obviously right: believing the filter is the mistake that was just caught. * **Free membership is a boolean, not a status.** `has_paid_access` gains an `is_free_member` axis, because `active_patron` alone would report a free follower as a paying patron and C4 would never offer to clean it up. Honest limit, stated in the docstring: the capture has no ACTIVE free member, so it shows the separation is possible, not that it occurs. C1's tripwire test did its job — it was written to fail the moment anyone populated the status map, and updating it here IS the confirmation step, done with the capture rather than ahead of it. `_fetch`'s retry/backoff/auth-vs-drift/Retry-After logic is extracted to a shared `_request` so the roster rides the same path rather than growing a second copy — two copies would drift, and the half that drifted would be the one that only runs daily. Every error message and log line renders byte-identically for the posts path, so the existing tests pin the refactor. Pagination is driven by `page[offset]` against `meta.pagination.total`, never by `links`: the response's own `links.first` is built WITHOUT the `/api/` prefix the request uses, so following it would hit the web page. An empty page is terminal regardless of what the total claims, so a server reporting more rows than it hands over cannot spin the walk forever. Drift is stricter here than on the posts path, on purpose: a missing `meta.pagination.total` raises rather than returning a short list, because a truncated roster reads downstream as "you cancelled those" — the worst wrong answer this feature can give. `current_user_id()` is marked INFERRED, not characterized: C0 captured /api/members, not /api/current_user, so it relies only on the JSON:API envelope this API demonstrably uses elsewhere, and raises drift rather than returning something plausible if that is wrong. The fixture is derived from the real capture with every piece of account data replaced (the raw capture stays gitignored). Six members, each earning its place: a former patron with a null pledge, an active patron with no tier, an annual cadence, a previous_pledge whose included resource has no `relationships` key at all, and a reward priced in CAD beside a USD charge — the trap that makes reading `reward.amount_cents` report a number the operator was never charged. A leak check caught a free-membership-subscription id and six real campaign launch timestamps before any of it was staged. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LNXXULQDjVZmbuNa2G9mD9 |
||
|
|
533a1ce674 |
chore: a gitignored home for raw platform captures (milestone 387 C0)
CI / extension-version (push) Successful in 3s
CI / lint (push) Successful in 3s
Build images / sign-extension (push) Successful in 4s
Build images / build-agent (push) Successful in 7s
Build images / build-ml (push) Successful in 8s
Build images / build-web (push) Successful in 6s
Build images / smoke-web (push) Skipped
Build images / promote (push) Skipped
CI / frontend-build (push) Successful in 24s
CI / backend-lint-and-test (push) Successful in 32s
CI / integration (push) Successful in 2m12s
C0 characterized Patreon's `/api/members` from a live capture of the operator's own session. That capture is worth keeping — re-capturing means re-authenticating by hand, and it is the ground truth the characterization (Scribe note #3886) gets re-checked against when a platform's shape is suspected to have drifted. It cannot be committed. It carries the operator's creator list, pledge amounts, and — inside the `card` resources the web app's include set pulls — the account's own email address. So: a directory that is ignored wholesale rather than by filename, so the next capture is covered by this rule instead of needing a line somebody has to remember to add. Two details that are the point rather than incidental: * The ignore is written as `captures/*` plus a negation for README.md, NOT as `captures/`. Git does not descend into an excluded DIRECTORY, so a negation for a file inside one never takes effect — the README would have been silently ignored along with everything else, and the convention would not have survived a fresh clone. * The README states plainly that SANITIZED fixtures belong in git, elsewhere under tests/fixtures/. The raw capture exists to derive those from and to re-check against; it is not the thing tests should load. The ignore rule landed before the capture file did, deliberately: a payload with an email address in it should never be sitting in the working tree un-ignored, however briefly. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LNXXULQDjVZmbuNa2G9mD9 |
||
|
|
059e2128ee |
fix(ci): stop telling readers the runner can't run upload-artifact@v4+
CI / lint (push) Successful in 3s
CI / extension-version (push) Successful in 3s
Build images / sign-extension (push) Successful in 4s
Build images / build-agent (push) Successful in 7s
Build images / build-ml (push) Successful in 7s
Build images / build-web (push) Successful in 7s
Build images / smoke-web (push) Skipped
Build images / promote (push) Skipped
CI / frontend-build (push) Successful in 24s
extension / lint (push) Successful in 25s
CI / backend-lint-and-test (push) Successful in 32s
CI / integration (push) Successful in 2m17s
build.yml and baseline.yml both explained a missing artifact step by saying act_runner cannot run actions/upload-artifact@v4+ (and baseline.yml said ci-requirements.md records that, which it does not). The runner is now gitea/runner 3.x, which edits the action's GHES refusal out of its bundle, and stock v4+ is proven working on this forge (Scribe spike #3843). Comments only. Neither lane gains an upload step: build-web still reads the signed XPI from the release asset, and the baseline candidate is still printed to the log, because those remain the better channels. The comments now say why for the right reason. Scribe snippet #2271, milestone 395. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DwoKYuw3qJmUUYsJeNherB |
||
|
|
d0b0458d27 |
feat: the announcement link, on the card and in a review queue (388 E5)
CI / lint (push) Successful in 4s
CI / extension-version (push) Successful in 3s
Build images / sign-extension (push) Successful in 5s
Build images / build-agent (push) Successful in 8s
Build images / build-ml (push) Successful in 8s
CI / frontend-build (push) Successful in 22s
CI / backend-lint-and-test (push) Successful in 31s
Build images / build-web (push) Successful in 1m1s
Build images / smoke-web (push) Skipped
Build images / promote (push) Skipped
CI / integration (push) Successful in 2m11s
Rule 27 — E5's other half. The matcher can propose; this is where the operator
decides, and where an accepted link actually shows up.
**The review queue** (Settings → Ingestion & filters). Each proposal shows the
per-signal breakdown, not just the total: "why did it suggest this" is the
question the operator actually has, and a lone percentage cannot answer it. So
a row reads "72% · timing 95% · says so 60%", and the copy states outright that
a pair always needs two reasons — which is the property that stops a busy
posting day from producing false pairs.
The empty state says so explicitly. Nothing proposed is the EXPECTED state most
of the time, and an empty queue that looks like a failure invites turning the
threshold down until it produces noise.
**On the card**, both directions, and accepted links only: the teaser gets "The
full set is in Discord", the drop gets "Announced on Patreon". A pending
proposal is a question for the review queue, never a claim to render beside the
artwork — that distinction is the whole confirm-only design, so it is asserted
in the backend (only `linked` rows reach the payload) and again here.
One detail worth the comment it carries: the link's target is `{ query: {
post_id } }` with no name or path. In vue-router that means "the current route
with these query params", so it works identically from Latest and from Browse —
and, more usefully, the card never reaches for `useRoute()`, which it has no
other reason to know about and which is not available when it is mounted in a
test without a router.
Backend CI on
|
||
|
|
235393c08b |
feat: link the Patreon teaser to the Discord drop it announced (388 step E5)
CI / lint (push) Successful in 3s
CI / extension-version (push) Successful in 3s
Build images / sign-extension (push) Successful in 4s
Build images / build-agent (push) Successful in 7s
CI / frontend-build (push) Successful in 25s
CI / backend-lint-and-test (push) Successful in 32s
Build images / build-web (push) Successful in 1m3s
Build images / smoke-web (push) Skipped
Build images / build-ml (push) Successful in 1m53s
Build images / promote (push) Skipped
CI / integration (push) Successful in 2m12s
The point of the milestone rather than its tail. Two of the operator's artists post a deliberately cropped fragment on Patreon to signal that the real thing has landed in their Discord; this proposes those pairs. Confirm-only, following the FC-6.3 series matcher. A wrongly-asserted association tells the operator two different pieces are one, which is strictly worse than no link: no link leaves them where they already were, a wrong one actively misinforms and then propagates into whatever reads it. So the matcher's job is a SHORT list worth reading, not a long list worth trusting. **The threshold sits above every single signal weight, and that is the design.** Proximity is 0.55, declaration 0.45, the cut 0.60 — so neither signal can carry a pair alone. That makes "time proximity alone is never sufficient" an arithmetic property rather than an aspiration: on a busy day an artist posts several times, and a matcher that could pair on proximity alone would turn every one of those days into false pairs until the review queue got abandoned. A guard test asserts the relationship against WEIGHTS directly, so it survives any refactor of the scorer, and says in its own failure message not to fix it by lowering the assertion. **Crop-to-source matching is HELD, on the plan's instruction** — real work with real false-positive risk, worth building only once signals 1 and 2 are shown insufficient against the operator's actual artists. Worth stating: a naive whole-image SigLIP similarity is NOT that signal. A cropped teaser and its full version are precisely the pair a whole-image comparison handles worst, so adding one as a "bonus" would mostly add noise while looking like progress. Two premises in the plan corrected in the building: * **E4 is not actually a prerequisite.** A Patreon Source and a Discord Source the operator has added under one Artist already share `Post.artist_id`, and the synthetic grouping inherits it. E4 EXTENDS this to creators FC has to learn the association for; it is not needed to represent one FC was told. Same-artist is then a hard filter, not a scored signal — two different creators posting minutes apart is a coincidence, not evidence. * **`link_extract` cannot supply the declaration signal.** It exists, but `SUPPORTED_HOSTS` is file hosts only and `host_for()` returns None for a Discord URL, so no ExternalLink row is ever written for one. The signal reads the post body directly instead. And a bug my own test would have caught: `declared_signal` stripped the HTML before looking for an invite, but `html_to_plain` discards attributes and these creators put the invite in an anchor's `href` — so the strongest form of the signal was being thrown away, leaving only whatever the link text said. The invite now matches the raw body; the bare mention still matches stripped text, so `\bdiscord\b` is tested against prose rather than against markup. Dismissed rows are kept, not deleted: the row is what remembers the rejection, and re-proposing a rejected pair on every scan is the one behaviour that makes a review queue get ignored. Both FKs CASCADE, so E3's one-DELETE reversal cannot leave a proposal pointing at a post that no longer exists. Only ACCEPTED links reach the post payload. A pending proposal is a question for the review queue, not a claim to render beside the artwork. UI (rule 27) follows in the next commit. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LNXXULQDjVZmbuNa2G9mD9 |
||
|
|
ba96ecfb2d |
fix: the disabled sweep's shape assertion pinned the pre-E3 payload
CI / lint (push) Successful in 3s
CI / extension-version (push) Successful in 3s
Build images / sign-extension (push) Successful in 5s
Build images / build-ml (push) Successful in 9s
Build images / build-agent (push) Successful in 9s
Build images / build-web (push) Successful in 6s
Build images / smoke-web (push) Skipped
Build images / promote (push) Skipped
CI / frontend-build (push) Successful in 20s
CI / backend-lint-and-test (push) Successful in 32s
CI / integration (push) Successful in 2m3s
My own E2 test asserted `sweep`'s disabled return by exact equality, and E3 added `images_joined` to it — a rule 90 miss on a consumer I wrote an hour earlier. Every E3 test passed; this was the only failure (1 failed, 1202 passed). Fixed by extending the assertion, NOT by loosening it to a subset check. The exactness is the point: a disabled sweep reports a complete zeroed shape rather than a shorter one, so a caller can read any counter unconditionally, and this assertion is what notices when a new counter skips that path. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LNXXULQDjVZmbuNa2G9mD9 |
||
|
|
1e45e2c56c |
feat: an open grouping — a later drop joins its post (milestone 388 step E3)
CI / extension-version (push) Successful in 3s
CI / lint (push) Successful in 3s
Build images / sign-extension (push) Successful in 4s
Build images / build-agent (push) Successful in 7s
CI / frontend-build (push) Successful in 22s
CI / backend-lint-and-test (push) Successful in 31s
Build images / build-web (push) Successful in 1m6s
Build images / smoke-web (push) Skipped
Build images / build-ml (push) Successful in 1m59s
Build images / promote (push) Skipped
CI / integration (push) Failing after 2m7s
A synthetic post is no longer sealed at creation. A creator who adds two more variants the next day extends the existing post, its body grows with the new messages, and no rival post appears. That is what makes chat capture read as content trickling in rather than as a stream of separate arrivals. The sweep now runs two passes per source and the ORDER is load-bearing: offer new messages to still-open groups BEFORE founding new ones, because whichever runs first claims a message. E3's three named problems, each answered rather than discovered later: **Bridging.** A candidate near two groups joins NEITHER. Nearest-wins would silently make an arbitrary choice between two posts the operator may already have seen; merging them is worse still, because a merge rewrites history and anything pointing at the absorbed post dangles. Leaving it to found its own group is the recoverable failure. AMBIGUITY_MARGIN is a module constant and deliberately not a setting — it is not a quality dial anyone would tune toward a better feed, and exposing it would invite turning it to zero, which is exactly the silent arbitrary choice it prevents. **Re-surfacing without thrashing.** A grouping has two dates, and which one orders the feed is a real decision, so the feed orders by neither directly. Ordering by when the drop STARTED buries a group that grows a week later under a week of other posts — defeating the point of keeping it open. Ordering by every growth lets a group gaining one image a day live permanently at the top, so chat out-competes authored posts for the front page — the opposite of "post pacing stays front and centre". Instead `resurfaced_at` moves only when growth clears BOTH a minimum-images bar and a cooldown, so a drip-feed updates in place and a genuine second wave resurfaces exactly once. It is NULL on every ordinary post, so the sort key COALESCEs through it without moving anything that is not a grouping. **Reopening forever.** Groups close after a quiet period — artists reuse characters for years, and a group left open indefinitely will eventually absorb something it shouldn't. Openness is DERIVED, not stored: a group is open if it grew (or started) within the window. Lowering the setting closes old groups and raising it reopens them, with nothing to repair either way; a stored closed_at would have needed a sweep to set it and a repair path to ever change the policy. Rule 89 is satisfied structurally rather than by a parallel mechanism: celery_signals writes a TaskRun for every task, which already supplies duration, the 5-minute stalled-run recovery, and retention pruning. What this step owed on top of that was a wall-clock limit (present) and idempotence — re-running the joiner adds nothing, asserted directly rather than left to the unique (image, post) constraint to catch. Two bugs fixed in the writing, one of which my own test would have hit: * `assign_to_group` sorted bare (distance, Post) tuples, which falls through to comparing Posts when two distances tie — and a perfectly symmetric bridge, the exact case the function exists for, would have raised TypeError instead of declining to choose. Now keyed on the distance alone. * The cursor was still built from `post_date or downloaded_at` while the ORDER BY had gained `resurfaced_at`. Two expressions that disagree at a page boundary don't error, they silently skip or repeat rows; both sites now go through one `_post_sort_value`, and a test pages through one row at a time to prove the walk matches the whole list. Image linking is now one shared helper rather than written twice, because creation and joining would otherwise be free to drift on exactly the detail (which post owns the image) that makes a grouping reversible. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LNXXULQDjVZmbuNa2G9mD9 |
||
|
|
7071c87cd6 |
feat: a grouped post says so, and the operator can tune the grouping (388 E2)
CI / lint (push) Successful in 2s
CI / extension-version (push) Successful in 2s
Build images / sign-extension (push) Successful in 4s
Build images / build-agent (push) Successful in 6s
CI / frontend-build (push) Successful in 22s
CI / backend-lint-and-test (push) Successful in 31s
Build images / build-web (push) Successful in 1m5s
Build images / smoke-web (push) Skipped
Build images / build-ml (push) Successful in 1m52s
Build images / promote (push) Skipped
CI / integration (push) Successful in 2m7s
Rule 27 — E2's other half. The backend can author posts; this is what makes
that visible and adjustable.
**The honesty marker.** A chip on every synthetic post's card: "grouped by
FabledCurator", titled with what it was built from ("Grouped from 4 Discord
messages"). This chip is the only thing standing between "FC assembled this"
and the card reading as something the artist authored, so it keys off nothing
but the flag, and it states the member count rather than just disclosing that
grouping happened — a claim you can check beats a claim you're asked to trust.
A synthetic post has no title on purpose (inventing one is the one place this
feature could put words in a creator's mouth), and the untitled fallback would
otherwise have printed the internal key: "Post fc-drop:99887766". It now names
the post for what it is. There's a test for that specifically.
**The tuning card.** Ingestion & filters gets a Discord-drop-grouping tile:
the switch, the distance cut and the drop window, each with the sentence that
tells the operator which one to reach for. The window's copy says outright
that it is the setting doing most of the work — without it, everything an
artist ever drew of one character collapses into a single post.
Both directions are pinned in postCard.spec.js, including the one that
actually matters: an ordinary post is never marked. Also covered — a post dict
composed before these fields existed degrades to unmarked rather than throwing
on `synthesis.message_count`.
Also fixes the ruff UP017 that failed the lint job on
|
||
|
|
73eeb7a377 |
feat: FC authors the post that Discord never wrote (milestone 388 step E2)
CI / lint (push) Failing after 3s
CI / extension-version (push) Successful in 3s
Build images / sign-extension (push) Successful in 5s
Build images / build-agent (push) Successful in 9s
CI / frontend-build (push) Successful in 25s
CI / backend-lint-and-test (push) Successful in 31s
Build images / build-web (push) Successful in 1m5s
Build images / smoke-web (push) Skipped
Build images / build-ml (push) Successful in 1m53s
Build images / promote (push) Skipped
CI / integration (push) Successful in 2m5s
Discord is a delivery channel, not a publisher. One message is not one post,
and today every message lands as its own `post` row, so chat lines compete
with authored work for the same surface. Rather than demote them into a
second-class feed, FC now writes the post itself: one row per DROP, its
images the drop's images, its body the messages' text in arrival order.
Synthesising a `Post` (rather than inventing a parallel entity) is the whole
point — the result is post-shaped by construction, so feed, provenance,
translation, attachments and series keep working on it unchanged.
The predicate is three axes ANDed, and the time one does the real work:
same source AND cosine distance <= threshold AND no gap > window
Similarity alone over-groups, and that is the failure that would make this
useless: any two pieces of the same character by the same artist sit close in
SigLIP space, so a cosine-only rule collapses a month of one character into a
single "post". Two details inside the predicate are load-bearing —
* distance is measured to the group's SEED, never to the previous member,
because chaining lets a group DRIFT: twenty small steps walk from one piece
to a completely different one, each hop individually within threshold;
* the window is measured between CONSECUTIVE messages, not from the first, so
an artist trickling variants out over an evening stays one drop.
Why a post-import sweep and not part of ingest. The obvious alternative was to
migrate Discord to the native post-first ingester (#1266) and group at capture
time. That cannot work: the grouping signal is `siglip_embedding`, which is
produced asynchronously AFTER import (tasks/ml.py, the GPU backfill), so at
capture time there is nothing to group on. Grouping is necessarily something
that happens once the vectors catch up — hence a re-runnable sweep that skips
what it cannot yet place, and an hourly (not daily) cadence.
The honesty rule, enforced in the schema. `post.synthesized_by` names the
grouper; `synthesis_details` records the members, the count, and the
thresholds AS THEY WERE (they are operator-tunable, so without that "why did
it group these" is unanswerable a month later). Member posts are absorbed, not
destroyed — they remain the images' true origin and the audit trail — and
`absorbed_by_post_id` is ON DELETE SET NULL, so deleting a synthetic post
releases its members back into the feed in one DELETE with no repair step.
`post_title` stays NULL deliberately: a synthesised title is the one place
this could put words in a creator's mouth.
Two guards the first draft would have failed:
* the per-run cap took the lowest post IDs, not the oldest posts — DISTINCT ON
forces its own ORDER BY, so the sort now happens outside the subquery;
* a cap landing mid-drop would have published a truncated group claiming to be
a whole drop, so the last group is left for the next run.
And one vacuous test caught before it shipped: the support vector perturbed a
single component of an all-ones vector, moving it ~1e-6, so every distance
assertion passed regardless of what the predicate did. `_vec` now builds a
unit vector at a stated angle, where distance is exactly 1 - cos(delta) —
rule 167, a guard has to be able to fail.
UI (rule 27) follows in the next commit.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LNXXULQDjVZmbuNa2G9mD9
|
||
|
|
4fe792b61c |
feat: platform_membership — the learned roster of what the account pays for (milestone 387 step C1)
CI / extension-version (push) Successful in 3s
CI / lint (push) Successful in 3s
Build images / sign-extension (push) Successful in 4s
Build images / build-agent (push) Successful in 8s
CI / frontend-build (push) Successful in 23s
CI / backend-lint-and-test (push) Successful in 39s
Build images / build-web (push) Successful in 1m14s
Build images / smoke-web (push) Skipped
Build images / build-ml (push) Successful in 2m18s
Build images / promote (push) Skipped
CI / integration (push) Successful in 2m21s
FC knows which creators it was TOLD to follow and nothing about which ones the operator is subscribed to. Those two sets drift both ways and neither drift is currently visible: a subscription FC doesn't track is content the operator believes they're archiving and aren't, and a source walked after the subscription lapsed is requests spent on a wall reported as a creator gone quiet. Sibling of service_seen (milestone 365) and the same insight — an absence is only observable against a record of presence. touch_membership reuses the recorded touch_service shape (snippet 3447): upsert rather than read-modify-write, first_seen_at deliberately outside the update set because it's the one field that makes a later DISAPPEARANCE readable as a lapse rather than as a creator we never knew. Nothing populates it yet, and that's the intended intermediate state. The sweep (C3) needs a client seam (C2) that needs Patreon's real response characterised from a captured sample (C0), which needs the operator's browser session. The table's SHAPE doesn't wait on that, because it's deliberately free-form exactly where C0's findings would otherwise dictate a column. status is an unconstrained String holding the PLATFORM's own word, not a normalised FC value. Rule 36 considered and declined, same reasoning service_seen.kind records: the vocabulary isn't ours to invent, and picking a lowest-common-denominator enum before any platform has been characterised would bake a guess into the schema. The service owns the whitelist and the mapping; the column owns the evidence. MEMBERSHIP_STATUS ships EMPTY, guarded by a test that fails if anyone adds an entry — every one must come from a characterised response, not from API docs. That's rule 130 at the one place it's easiest to break, and the failure message says so. has_paid_access returns None, never False, for a word it hasn't been taught. The difference is load-bearing: False means the operator lost access, which C4 turns into an offer to disable the source, so asserting it from an unrecognised word would tell them to cancel a subscription they're still paying for. Retention decided here rather than deferred (rule 89): a membership that stops appearing is aged out on time, never deleted on absence — deleting would destroy the signal at the moment it became interesting. Rule 90 check, done on the right thing this time: the per-test TRUNCATE teardown derives its table list from Base.metadata.sorted_tables, so the new table is picked up automatically; test_models asserts a subset, so it doesn't break. 0091 follows 0090 on the collapsed baseline. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LNXXULQDjVZmbuNa2G9mD9 |
||
|
|
6b19012bb6 |
feat: the empty front door is the install's first screen (milestone 387 step B4)
CI / lint (push) Successful in 5s
CI / extension-version (push) Successful in 6s
CI / frontend-build (push) Successful in 26s
CI / backend-lint-and-test (push) Successful in 36s
CI / integration (push) Successful in 2m3s
Build images / sign-extension (push) Successful in 5s
Build images / build-agent (push) Successful in 9s
Build images / build-ml (push) Successful in 9s
Build images / build-web (push) Successful in 6s
Build images / smoke-web (push) Skipped
Build images / promote (push) Skipped
The front door is now a feed, and a blank feed implies things should be here in a way a blank masonry does not. On a fresh install this is the first screen anyone sees — including someone who is not the operator, which is what milestone 328 is making possible. Tells the two empties apart, which is the point. "No sources yet" gets the on-ramp; "sources configured, nothing landed yet" gets told that the first check takes a while and pointed at Downloads. Telling someone to add a source when they already have three and are mid-backfill reads as the app not knowing its own state. Needs total_sources on schedule-status to distinguish them — deliberately not auto_sources, which counts only what is on a schedule, so a source with auto_check off would have read as "nothing configured". Both exact-shape assertions updated in THIS change rather than after CI caught them, which is the lesson from B3's red push. An absent status falls back to the on-ramp on purpose: it is merely redundant to an established operator, whereas "see what's running" shown to someone with nothing configured is a dead end. A filtered miss is deliberately NOT the onboarding case — the operator has posts, they just narrowed past them. Showing a fresh-install on-ramp there would tell someone with a full library to go set it up. This is where logo.svg lands, as the operator asked. It earns its place on a first-run screen and not on a populated feed, and gives the on-ramp something to compose around instead of prose plus two buttons. Large: the mark stops reading below ~48px, which is why the 22px nav slot has a different one. Pinned by test so a later tidy-up cannot quietly shrink it to a glyph. Also extracts mountWithStore into the shared test support module. Writing the second spec created exactly the copy-paste that open issue 3109 tracks for the backend row factories, so it is consolidated now rather than at copy three, and recorded as snippet 3829 with the two traps it does NOT solve — named slots rendering nothing, and components that fetch on mount. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LNXXULQDjVZmbuNa2G9mD9 |