Compare commits

..
238 Commits
Author SHA1 Message Date
bvandeusen a8fdbd86bc Merge pull request 'FabledCurator can now tell you one of its own parts has stopped' (#250) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 3s
CI / extension-version (push) Successful in 4s
Build images / build-agent (push) Successful in 8s
Build images / build-ml (push) Successful in 13s
Build images / build-web (push) Successful in 13s
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 33s
CI / integration (push) Successful in 1m53s
2026-09-02 18:01:38 -04:00
bvandeusen 0421fd3109 Merge pull request 'The weekly refresh now publishes only what a gate has proven — and a new install can start' (#249) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 4s
CI / extension-version (push) Successful in 4s
Build images / build-ml (push) Successful in 8s
Build images / build-agent (push) Successful in 9s
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 22s
extension / lint (push) Successful in 19s
CI / backend-lint-and-test (push) Successful in 31s
CI / integration (push) Successful in 1m49s
2026-09-02 16:31:55 -04:00
bvandeusen d01f33dea6 Merge pull request 'A gate for the weekly refresh, and the lever that makes it testable' (#248) from dev into main
CI / lint (push) Successful in 3s
Build images / sign-extension (push) Successful in 3s
CI / extension-version (push) Successful in 2s
Build images / build-ml (push) Successful in 6s
Build images / build-agent (push) Successful in 7s
Build images / build-web (push) Successful in 6s
Build images / smoke-web (push) Skipped
CI / frontend-build (push) Successful in 19s
extension / lint (push) Successful in 17s
CI / backend-lint-and-test (push) Successful in 31s
CI / integration (push) Successful in 1m42s
2026-09-02 15:51:50 -04:00
bvandeusen adab33694d Merge pull request 'Make a digest mean something again, and give the refresh somewhere to stand' (#247) from dev into main
CI / lint (push) Successful in 3s
Build images / sign-extension (push) Successful in 4s
CI / extension-version (push) Successful in 3s
Build images / build-agent (push) Successful in 7s
Build images / build-ml (push) Successful in 8s
Build images / build-web (push) Successful in 14s
CI / frontend-build (push) Successful in 21s
extension / lint (push) Successful in 18s
CI / backend-lint-and-test (push) Successful in 32s
CI / integration (push) Successful in 2m2s
2026-09-02 14:56:12 -04:00
bvandeusen 3e4d39b111 Merge pull request 'The install path a stranger reads, and a CI probe that never probed' (#246) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 3s
CI / extension-version (push) Successful in 3s
Build images / build-agent (push) Successful in 8s
Build images / build-ml (push) Successful in 14s
extension / lint (push) Successful in 17s
Build images / build-web (push) Successful in 15s
CI / frontend-build (push) Successful in 20s
CI / backend-lint-and-test (push) Successful in 37s
CI / integration (push) Successful in 1m55s
2026-09-02 11:25:28 -04:00
bvandeusen b93a5fc5b4 Merge pull request 'Collapse alembic 0001..0089 into one baseline' (#245) from dev into main
CI / lint (push) Successful in 5s
CI / extension-version (push) Successful in 5s
CI / frontend-build (push) Successful in 17s
CI / backend-lint-and-test (push) Successful in 32s
CI / integration (push) Successful in 3m40s
Build images / sign-extension (push) Successful in 4s
Build images / build-agent (push) Successful in 9s
Build images / build-web (push) Successful in 7s
Build images / build-ml (push) Successful in 14s
2026-09-01 01:30:52 -04:00
bvandeusen 725bf15f88 Merge pull request 'AGPL-3.0, SECURITY and CONTRIBUTING; the install path pulls :latest, not :dev' (#244) from dev into main
Build images / sign-extension (push) Successful in 4s
CI / lint (push) Successful in 5s
CI / extension-version (push) Successful in 5s
Build images / build-ml (push) Successful in 7s
Build images / build-agent (push) Successful in 9s
CI / frontend-build (push) Successful in 16s
Build images / build-web (push) Successful in 6s
CI / backend-lint-and-test (push) Successful in 31s
CI / integration (push) Successful in 3m43s
2026-08-31 17:01:20 -04:00
bvandeusen b14818303c Merge pull request 'Schema reconciliation + index hygiene, and the weekly base-image refresh' (#243) from dev into main
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 14s
CI / frontend-build (push) Successful in 19s
Build images / build-web (push) Successful in 14s
extension / lint (push) Successful in 18s
CI / backend-lint-and-test (push) Successful in 30s
CI / integration (push) Successful in 3m54s
2026-08-31 08:34:55 -04:00
bvandeusen 499720d87e Merge pull request #242 from dev
Build images / sign-extension (push) Successful in 4s
CI / extension-version (push) Successful in 5s
CI / lint (push) Successful in 4s
Build images / build-agent (push) Successful in 10s
Build images / build-ml (push) Successful in 13s
Build images / build-web (push) Successful in 8s
extension / lint (push) Successful in 22s
CI / frontend-build (push) Successful in 25s
CI / backend-lint-and-test (push) Successful in 57s
CI / integration (push) Successful in 4m3s
ci: buildx driver, registry layer cache, and a force_build hatch (milestone 326 steps 1-3)
2026-08-29 22:48:47 -04:00
bvandeusen bd24f4e876 Merge pull request #241 from bvandeusen/dev
CI / lint (push) Successful in 3s
Build images / sign-extension (push) Successful in 3s
Build images / build-ml (push) Successful in 4s
Build images / build-agent (push) Successful in 5s
CI / extension-version (push) Successful in 3s
CI / backend-lint-and-test (push) Successful in 29s
CI / frontend-build (push) Successful in 19s
extension / lint (push) Successful in 18s
Build images / build-web (push) Successful in 1m59s
CI / integration (push) Successful in 3m45s
Milestone 318 step 8 — extension CalVer, and the #3190 mitigation's first main build
2026-08-29 13:53:59 -04:00
bvandeusen 0e15c44c51 Merge pull request '#240 dev→main: milestone 318 steps 5–7 and 9, plus #3202' from dev into main
CI / lint (push) Successful in 3s
CI / frontend-build (push) Successful in 21s
extension / lint (push) Successful in 19s
CI / backend-lint-and-test (push) Successful in 49s
CI / extension-version (push) Successful in 3s
CI / integration (push) Successful in 3m57s
Build images / build-ml (push) Successful in 4s
Build images / sign-extension (push) Successful in 4s
Build images / build-agent (push) Successful in 6s
Build images / build-web (push) Successful in 7s
2026-08-29 00:14:33 -04:00
bvandeusen 6d98dfc0ec Merge pull request 'Stop minting identifiers nothing reads: moving channel tags, :c-<sha> on main, and a label-keyed build cache' (#239) from dev into main
Build images / build-agent (push) Successful in 9m23s
Build images / sign-extension (push) Successful in 4s
CI / lint (push) Successful in 4s
CI / extension-version (push) Successful in 3s
CI / backend-lint-and-test (push) Successful in 34s
CI / frontend-build (push) Successful in 23s
Build images / build-ml (push) Successful in 3m1s
extension / lint (push) Successful in 21s
CI / integration (push) Successful in 4m11s
Build images / build-web (push) Successful in 3m21s
2026-08-28 15:14:35 -04:00
bvandeusen 6c76f08b69 Merge pull request 'Per-artifact commit-derived versions: pinnable date tags, and no rebuild when nothing changed' (#238) from dev into main
Build images / sign-extension (push) Successful in 4s
CI / lint (push) Successful in 4s
CI / extension-version (push) Successful in 3s
Build images / build-ml (push) Successful in 5s
Build images / build-agent (push) Successful in 6s
Build images / build-web (push) Successful in 6s
extension / lint (push) Successful in 17s
CI / frontend-build (push) Successful in 19s
CI / backend-lint-and-test (push) Successful in 28s
CI / integration (push) Successful in 3m42s
2026-08-28 10:14:15 -04:00
bvandeusen 7b1019ba82 Merge pull request 'Extension channels: dev and main each carry their own signed extension' (#237) from dev into main
CI / lint (push) Successful in 3s
Build images / sign-extension (push) Successful in 4s
CI / frontend-build (push) Successful in 24s
CI / backend-lint-and-test (push) Successful in 28s
extension / lint (push) Successful in 23s
CI / extension-version (push) Successful in 3s
Build images / build-web (push) Successful in 2m21s
Build images / build-ml (push) Successful in 3m12s
CI / integration (push) Successful in 5m29s
Build images / build-agent (push) Successful in 9m54s
2026-08-27 12:49:39 -04:00
bvandeusen 0d204e6837 Merge pull request 'Extension packaging: one source of truth, shadow-mode versioning, real XPI verification' (#235) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 3s
CI / extension-version (push) Successful in 3s
CI / frontend-build (push) Successful in 34s
extension / lint (push) Successful in 40s
CI / backend-lint-and-test (push) Successful in 2m1s
CI / integration (push) Successful in 4m3s
Build images / build-web (push) Successful in 4m21s
Build images / build-ml (push) Successful in 5m39s
Build images / build-agent (push) Successful in 12m49s
2026-08-03 20:23:27 -04:00
bvandeusen 8300029741 Merge pull request 'Extension: fix credential-push 405, guard the publish path, add a unit suite' (#234) from dev into main
CI / lint (push) Successful in 3s
CI / extension-version (push) Successful in 4s
CI / frontend-build (push) Successful in 32s
extension / lint (push) Successful in 19s
CI / backend-lint-and-test (push) Successful in 1m14s
Build images / sign-extension (push) Successful in 2m23s
Build images / build-ml (push) Successful in 3m53s
CI / integration (push) Successful in 3m56s
Build images / build-web (push) Successful in 2m34s
Build images / build-agent (push) Successful in 13m33s
2026-08-03 08:29:09 -04:00
bvandeusen b5b437ca80 Merge pull request 'feat(agent): idle-unload GPU models to free VRAM when the queue is idle' (#233) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / backend-lint-and-test (push) Successful in 34s
CI / lint (push) Successful in 4s
CI / frontend-build (push) Successful in 25s
Build images / build-web (push) Successful in 3m20s
Build images / build-ml (push) Successful in 4m29s
CI / integration (push) Successful in 4m26s
Build images / build-agent (push) Successful in 11m55s
2026-07-17 13:07:15 -04:00
bvandeusen ce0dac3524 Merge pull request 'DRY + stability pass — extension, ML/settings backend, frontend cards (#161)' (#232) from dev into main
Build images / sign-extension (push) Successful in 2s
CI / lint (push) Successful in 3s
Build images / build-agent (push) Successful in 7s
Build images / build-ml (push) Successful in 12s
extension / lint (push) Successful in 11s
Build images / build-web (push) Successful in 10s
CI / frontend-build (push) Successful in 22s
CI / backend-lint-and-test (push) Successful in 50s
CI / integration (push) Successful in 3m47s
2026-07-13 23:08:18 -04:00
bvandeusen 9b5ec86222 Merge pull request 'feat(extension): in-app update prompt + v1.0.9 (#1489)' (#231) from dev into main
CI / lint (push) Successful in 3s
Build images / build-ml (push) Successful in 6s
Build images / build-agent (push) Successful in 7s
extension / lint (push) Successful in 8s
CI / frontend-build (push) Successful in 19s
CI / backend-lint-and-test (push) Successful in 30s
Build images / sign-extension (push) Successful in 3m34s
Build images / build-web (push) Successful in 8s
CI / integration (push) Successful in 3m50s
2026-07-13 18:32:31 -04:00
bvandeusen 89c83ee5de Merge pull request 'fix(extension): Add-to-FC button on subscribed Patreon creators + v1.0.8 (#1485)' (#230) from dev into main
CI / lint (push) Successful in 3s
Build images / build-agent (push) Successful in 8s
Build images / build-ml (push) Successful in 8s
extension / lint (push) Successful in 8s
CI / frontend-build (push) Successful in 20s
CI / backend-lint-and-test (push) Successful in 30s
CI / integration (push) Successful in 3m46s
Build images / sign-extension (push) Successful in 5m8s
Build images / build-web (push) Successful in 10s
2026-07-13 17:56:32 -04:00
bvandeusen d3192f1843 Merge pull request 'chore(deps): web-ext 8→10 (Renovate batch 5) (#1450)' (#229) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 3s
Build images / build-ml (push) Successful in 7s
Build images / build-agent (push) Successful in 8s
Build images / build-web (push) Successful in 6s
extension / lint (push) Successful in 9s
CI / frontend-build (push) Successful in 19s
CI / backend-lint-and-test (push) Successful in 35s
CI / integration (push) Successful in 3m48s
2026-07-13 16:36:26 -04:00
bvandeusen 5a5694f200 Merge pull request 'fix(ui): softer chrome-gradient falloff + segmented media toggle (#1478)' (#228) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (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 9s
CI / frontend-build (push) Successful in 19s
CI / backend-lint-and-test (push) Successful in 35s
CI / integration (push) Successful in 3m42s
2026-07-13 15:55:02 -04:00
bvandeusen bb1a938cc0 Merge pull request 'fix(ui): measure real nav height (--fc-nav-h) — Explore fits on 1080p, no breadcrumb tuck (#1481)' (#227) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 3s
Build images / build-agent (push) Successful in 6s
Build images / build-ml (push) Successful in 8s
Build images / build-web (push) Successful in 9s
CI / frontend-build (push) Successful in 21s
CI / backend-lint-and-test (push) Successful in 32s
CI / integration (push) Successful in 3m50s
2026-07-13 15:32:25 -04:00
bvandeusen fc0293029d Merge pull request 'fix(ui): Vuetify-4 review polish — active-tab indicator + one continuous chrome gradient' (#226) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 3s
Build images / build-agent (push) Successful in 7s
Build images / build-ml (push) Successful in 8s
Build images / build-web (push) Successful in 8s
CI / frontend-build (push) Successful in 24s
CI / backend-lint-and-test (push) Successful in 36s
CI / integration (push) Successful in 3m44s
2026-07-13 14:47:59 -04:00
bvandeusen 7d1c701b67 Merge pull request 'fix(ui): remove redundant app-shell top gap (#1480)' (#225) from dev into main
CI / lint (push) Successful in 4s
CI / frontend-build (push) Successful in 22s
CI / backend-lint-and-test (push) Successful in 36s
CI / integration (push) Successful in 3m48s
Build images / sign-extension (push) Successful in 3s
Build images / build-agent (push) Successful in 7s
Build images / build-ml (push) Successful in 8s
Build images / build-web (push) Successful in 7s
2026-07-13 14:12:41 -04:00
bvandeusen b638382cd5 Merge pull request 'Ship: UI framework batch 4 — Vuetify 4 + vue-router 5 + pinia 3 (#1449)' (#224) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 3s
Build images / build-ml (push) Successful in 8s
Build images / build-agent (push) Successful in 8s
Build images / build-web (push) Successful in 28s
CI / frontend-build (push) Successful in 34s
CI / backend-lint-and-test (push) Successful in 53s
CI / integration (push) Successful in 3m46s
2026-07-13 13:40:25 -04:00
bvandeusen 17903068b4 Merge pull request 'Ship: system-tag refactor + soft WIP tier + Explore reach (#1464, #1474, #1476)' (#223) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 4s
Build images / build-agent (push) Successful in 9s
Build images / build-ml (push) Successful in 9s
Build images / build-web (push) Successful in 9s
CI / frontend-build (push) Successful in 22s
CI / backend-lint-and-test (push) Successful in 34s
CI / integration (push) Successful in 3m45s
2026-07-13 12:13:19 -04:00
bvandeusen 31d400ab0a Merge pull request 'Ship: title-based WIP auto-tagging (#1458)' (#222) from dev into main
Build images / sign-extension (push) Successful in 2s
CI / lint (push) Successful in 3s
Build images / build-ml (push) Successful in 8s
Build images / build-agent (push) Successful in 8s
Build images / build-web (push) Successful in 9s
CI / frontend-build (push) Successful in 22s
CI / backend-lint-and-test (push) Successful in 34s
CI / integration (push) Successful in 3m50s
2026-07-12 19:45:03 -04:00
bvandeusen d45ce5e426 Merge pull request 'Ship: Renovate onboarding + dependency modernization batches 1–3' (#221) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 4s
Build images / build-agent (push) Successful in 8s
extension / lint (push) Successful in 16s
CI / frontend-build (push) Successful in 29s
CI / backend-lint-and-test (push) Successful in 1m4s
Build images / build-web (push) Successful in 1m24s
Build images / build-ml (push) Successful in 2m36s
CI / integration (push) Successful in 3m46s
2026-07-11 23:16:33 -04:00
bvandeusen 77bfc32b02 Merge pull request 'feat(translation): tunable acceptance floor (0.90) + per-post sticky override (#155)' (#210) from dev into main
CI / lint (push) Successful in 3s
CI / frontend-build (push) Successful in 23s
CI / backend-lint-and-test (push) Successful in 45s
CI / integration (push) Successful in 3m45s
Build images / sign-extension (push) Successful in 3s
Build images / build-ml (push) Successful in 7s
Build images / build-agent (push) Successful in 7s
Build images / build-web (push) Successful in 8s
2026-07-10 22:47:45 -04:00
bvandeusen d13efe1b69 Merge pull request 'refactor(tags): unify suggestion source — one canonical DB-tag dropdown (#154)' (#209) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 3s
Build images / build-ml (push) Successful in 8s
Build images / build-agent (push) Successful in 13s
Build images / build-web (push) Successful in 11s
CI / frontend-build (push) Successful in 23s
CI / backend-lint-and-test (push) Successful in 41s
CI / integration (push) Successful in 3m44s
2026-07-10 15:52:44 -04:00
bvandeusen ca6d26d236 Merge pull request 'feat(translation): acceptance gate on Interpreter detection confidence (#1376)' (#208) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 3s
Build images / build-agent (push) Successful in 8s
Build images / build-ml (push) Successful in 9s
Build images / build-web (push) Successful in 8s
CI / frontend-build (push) Successful in 19s
CI / backend-lint-and-test (push) Successful in 29s
CI / integration (push) Successful in 3m43s
2026-07-09 17:58:22 -04:00
bvandeusen 5366047d55 Merge pull request 'feat(translation): 8h sweep + drain button + detection probe (#1376)' (#207) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 2s
Build images / build-agent (push) Successful in 8s
Build images / build-ml (push) Successful in 9s
Build images / build-web (push) Successful in 10s
CI / frontend-build (push) Successful in 18s
CI / backend-lint-and-test (push) Successful in 35s
CI / integration (push) Successful in 3m50s
2026-07-08 20:19:33 -04:00
bvandeusen 2923257529 Merge pull request 'fix(ui): tag-chip left-edge clipping + kind-coloured border' (#206) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 3s
Build images / build-ml (push) Successful in 8s
Build images / build-agent (push) Successful in 7s
Build images / build-web (push) Successful in 10s
CI / frontend-build (push) Successful in 18s
CI / backend-lint-and-test (push) Successful in 34s
CI / integration (push) Successful in 3m45s
2026-07-08 10:37:09 -04:00
bvandeusen 934731e9ef Merge pull request 'feat(translation): per-field language detection for mixed-language posts' (#205) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 3s
Build images / build-agent (push) Successful in 8s
Build images / build-ml (push) Successful in 8s
Build images / build-web (push) Successful in 8s
CI / frontend-build (push) Successful in 18s
CI / backend-lint-and-test (push) Successful in 36s
CI / integration (push) Successful in 3m48s
2026-07-08 09:25:23 -04:00
bvandeusen 4c67c116b2 Merge pull request 'Bolder auto-tag accept/reject + larger tag chips' (#204) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 3s
Build images / build-ml (push) Successful in 7s
Build images / build-agent (push) Successful in 8s
Build images / build-web (push) Successful in 10s
CI / frontend-build (push) Successful in 20s
CI / backend-lint-and-test (push) Successful in 35s
CI / integration (push) Successful in 3m50s
2026-07-07 23:21:26 -04:00
bvandeusen f8b667604f Merge pull request 'Graceful shutdown + resilience & Explore polish (m147)' (#203) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 3s
Build images / build-agent (push) Successful in 8s
Build images / build-ml (push) Successful in 9s
Build images / build-web (push) Successful in 11s
CI / frontend-build (push) Successful in 24s
CI / backend-lint-and-test (push) Successful in 33s
CI / integration (push) Successful in 3m48s
2026-07-07 22:53:34 -04:00
bvandeusen 11572f469a Merge pull request 'Re-translate on Interpreter model change — artist-scoped + global (m146)' (#202) from dev into main
Build images / sign-extension (push) Successful in 3s
Build images / build-agent (push) Successful in 7s
Build images / build-ml (push) Successful in 8s
Build images / build-web (push) Successful in 11s
CI / lint (push) Successful in 3s
CI / frontend-build (push) Successful in 20s
CI / backend-lint-and-test (push) Successful in 35s
CI / integration (push) Successful in 3m41s
2026-07-07 20:22:40 -04:00
bvandeusen ba3fd2a118 Merge pull request 'Post translation via Interpreter (milestone 143)' (#201) from dev into main
CI / lint (push) Successful in 3s
Build images / sign-extension (push) Successful in 3s
Build images / build-agent (push) Successful in 7s
Build images / build-ml (push) Successful in 8s
Build images / build-web (push) Successful in 11s
CI / frontend-build (push) Successful in 19s
CI / backend-lint-and-test (push) Successful in 32s
CI / integration (push) Successful in 3m44s
2026-07-07 13:05:18 -04:00
bvandeusen 9bf095179a Merge pull request 'Auto-tag accept/reject as an in-pill yes/no pair; confirm refocuses the tag input' (#200) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 3s
Build images / build-ml (push) Successful in 7s
Build images / build-agent (push) Successful in 8s
Build images / build-web (push) Successful in 10s
CI / frontend-build (push) Successful in 22s
CI / backend-lint-and-test (push) Successful in 27s
CI / integration (push) Successful in 3m44s
2026-07-07 01:13:02 -04:00
bvandeusen cefc064e0f Merge pull request 'Surface auto-hide misfires proactively (review strip no longer gated on Show-hidden)' (#199) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 3s
Build images / build-ml (push) Successful in 8s
Build images / build-agent (push) Successful in 8s
Build images / build-web (push) Successful in 10s
CI / frontend-build (push) Successful in 20s
CI / backend-lint-and-test (push) Successful in 31s
CI / integration (push) Successful in 3m42s
2026-07-07 00:43:45 -04:00
bvandeusen 06757daf80 Merge pull request 'Presentation-chrome auto-hide (milestone 141) + tagging-UX fixes' (#198) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 3s
Build images / build-agent (push) Successful in 8s
Build images / build-ml (push) Successful in 8s
Build images / build-web (push) Successful in 11s
CI / frontend-build (push) Successful in 22s
CI / backend-lint-and-test (push) Successful in 35s
CI / integration (push) Successful in 3m46s
2026-07-07 00:24:51 -04:00
bvandeusen a723ef436b Merge pull request 'Soft auto-apply (retract + confirm, no self-training) + tagging UX (reject-rest, tag-input race, modal playlist)' (#197) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 4s
Build images / build-agent (push) Successful in 9s
Build images / build-ml (push) Successful in 9s
Build images / build-web (push) Successful in 11s
CI / frontend-build (push) Successful in 24s
CI / backend-lint-and-test (push) Successful in 37s
CI / integration (push) Successful in 3m46s
2026-07-06 21:03:30 -04:00
bvandeusen d68f39ca50 Merge pull request 'Suggestions perf + UX polish: incremental CCIP + head training, hover label, scroll/toast fixes, dead-preview removal' (#196) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 3s
Build images / build-agent (push) Successful in 8s
Build images / build-ml (push) Successful in 8s
Build images / build-web (push) Successful in 11s
CI / frontend-build (push) Successful in 20s
CI / backend-lint-and-test (push) Successful in 31s
CI / integration (push) Successful in 3m43s
2026-07-06 16:41:08 -04:00
bvandeusen a66a695977 Merge pull request 'Tag-hover → crop-region grounding overlay (#1206, milestone #133)' (#195) from dev into main
CI / lint (push) Successful in 3s
Build images / sign-extension (push) Successful in 3s
Build images / build-agent (push) Successful in 7s
Build images / build-ml (push) Successful in 9s
Build images / build-web (push) Successful in 17s
CI / frontend-build (push) Successful in 24s
CI / backend-lint-and-test (push) Successful in 40s
CI / integration (push) Successful in 3m51s
2026-07-06 13:29:31 -04:00
bvandeusen 5289fa3879 Merge pull request 'Crop-proposer detector config → Settings → Tagging (default-on, lease-announced) (#134)' (#194) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 3s
Build images / build-agent (push) Successful in 8s
Build images / build-ml (push) Successful in 8s
Build images / build-web (push) Successful in 10s
CI / frontend-build (push) Successful in 21s
CI / backend-lint-and-test (push) Successful in 30s
CI / integration (push) Successful in 3m40s
2026-07-05 20:10:02 -04:00
bvandeusen ebac34e17b Merge pull request 'Artist identity/storage/display decoupling — rename, move-source, add-time name resolution (#130)' (#193) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 3s
Build images / build-agent (push) Successful in 7s
Build images / build-ml (push) Successful in 9s
Build images / build-web (push) Successful in 10s
CI / frontend-build (push) Successful in 19s
CI / backend-lint-and-test (push) Successful in 30s
CI / integration (push) Successful in 3m40s
2026-07-04 22:43:12 -04:00
bvandeusen 4c3ba20198 Merge pull request 'Recapture image→post linking (#1288) + ugoira timings + system suggestion category' (#192) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 3s
Build images / build-agent (push) Successful in 7s
Build images / build-ml (push) Successful in 9s
Build images / build-web (push) Successful in 10s
CI / frontend-build (push) Successful in 20s
CI / backend-lint-and-test (push) Successful in 41s
CI / integration (push) Successful in 3m36s
2026-07-04 20:36:58 -04:00
bvandeusen d2acec61ae Merge pull request 'Pixiv native ingester (#129) + SubscribeStar .art fix, disable-clears-failure, system-tag suggestion floor' (#191) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 3s
Build images / build-agent (push) Successful in 7s
Build images / build-ml (push) Successful in 8s
Build images / build-web (push) Successful in 11s
CI / frontend-build (push) Successful in 22s
CI / backend-lint-and-test (push) Successful in 39s
CI / integration (push) Successful in 3m35s
2026-07-03 16:36:56 -04:00
bvandeusen 15ae5ef4fa Merge pull request 'SubscribeStar .art age fix, modal tag-flow sync, system tags / training hygiene (#128)' (#190) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 3s
Build images / build-agent (push) Successful in 9s
Build images / build-ml (push) Successful in 9s
Build images / build-web (push) Successful in 10s
CI / frontend-build (push) Successful in 20s
CI / backend-lint-and-test (push) Successful in 28s
CI / integration (push) Successful in 3m34s
2026-07-03 09:16:16 -04:00
bvandeusen e3ceefc820 Merge pull request 'Surgical re-fetch for deep posts (ExternalLink reset) — recovery no longer needs deep scans' (#189) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 4s
Build images / build-ml (push) Successful in 9s
Build images / build-agent (push) Successful in 14s
Build images / build-web (push) Successful in 8s
CI / frontend-build (push) Successful in 38s
CI / backend-lint-and-test (push) Successful in 2m10s
CI / integration (push) Successful in 3m40s
2026-07-02 21:14:45 -04:00
bvandeusen e52090a4cf Merge pull request 'Agent sleep mode: empty queue sheds to one downloader, lease poll backs off to 15 min' (#188) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 4s
Build images / build-ml (push) Successful in 7s
Build images / build-agent (push) Successful in 8s
CI / frontend-build (push) Successful in 21s
Build images / build-web (push) Successful in 17s
CI / backend-lint-and-test (push) Successful in 42s
CI / integration (push) Successful in 3m50s
2026-07-02 20:37:48 -04:00
bvandeusen bfba8045e4 Merge pull request 'Admin IA overhaul, cap-aware autoscaler, optional ml-worker, hardened tag reset' (#187) from dev into main
Build images / sign-extension (push) Successful in 2s
CI / lint (push) Successful in 3s
Build images / build-agent (push) Successful in 8s
Build images / build-ml (push) Successful in 8s
Build images / build-web (push) Successful in 10s
CI / frontend-build (push) Successful in 20s
CI / backend-lint-and-test (push) Successful in 40s
CI / integration (push) Successful in 3m29s
2026-07-02 18:01:56 -04:00
bvandeusen a708357436 Merge pull request 'Settings defaults, GPU error tombstones + failure triage/recovery, agent bandwidth cap, approved retirements' (#186) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 4s
Build images / build-agent (push) Successful in 9s
Build images / build-ml (push) Successful in 10s
Build images / build-web (push) Successful in 11s
CI / frontend-build (push) Successful in 20s
CI / backend-lint-and-test (push) Successful in 38s
CI / integration (push) Successful in 3m33s
2026-07-02 15:48:48 -04:00
bvandeusen d9354ac1e1 Merge pull request 'Agent: short-video sampling fix + "Retry errored jobs" recovery action' (#185) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 3s
Build images / build-agent (push) Successful in 8s
Build images / build-ml (push) Successful in 8s
Build images / build-web (push) Successful in 10s
CI / frontend-build (push) Successful in 22s
CI / backend-lint-and-test (push) Successful in 27s
CI / integration (push) Successful in 3m26s
2026-07-01 21:13:48 -04:00
bvandeusen 1d48770793 Merge pull request 'Agent: fix Status freeze — conchint.textContent destroyed #capn (root cause)' (#184) from dev into main
Build images / sign-extension (push) Successful in 2s
CI / lint (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
CI / frontend-build (push) Successful in 21s
CI / backend-lint-and-test (push) Successful in 27s
CI / integration (push) Successful in 3m25s
2026-07-01 20:46:28 -04:00
bvandeusen 489e6aaaee Merge pull request 'Agent: server-side throughput rates + killable-on-stop ffmpeg' (#183) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 4s
Build images / build-ml (push) Successful in 8s
Build images / build-agent (push) Successful in 9s
Build images / build-web (push) Successful in 7s
CI / frontend-build (push) Successful in 19s
CI / backend-lint-and-test (push) Successful in 28s
CI / integration (push) Successful in 3m26s
2026-07-01 20:01:00 -04:00
bvandeusen ed20df905b Merge pull request 'Agent: DRY pass + Status rate-metrics + real start/stop state machine' (#182) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 3s
Build images / build-ml (push) Successful in 6s
Build images / build-agent (push) Successful in 7s
Build images / build-web (push) Successful in 7s
CI / frontend-build (push) Successful in 19s
CI / backend-lint-and-test (push) Successful in 27s
CI / integration (push) Successful in 3m25s
2026-07-01 16:30:32 -04:00
bvandeusen 5ba9871ef0 Merge pull request 'fix(agent): stream videos via ffmpeg-from-URL (no full download) — env-agnostic' (#181) from dev into main
Build images / sign-extension (push) Successful in 2s
CI / lint (push) Successful in 3s
Build images / build-ml (push) Successful in 7s
Build images / build-agent (push) Successful in 7s
Build images / build-web (push) Successful in 6s
CI / frontend-build (push) Successful in 19s
CI / backend-lint-and-test (push) Successful in 26s
CI / integration (push) Successful in 3m27s
2026-07-01 14:20:53 -04:00
bvandeusen 2a820d0848 Merge pull request 'fix(agent): log the real fetch/submit failure reason (diagnose #1225)' (#180) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 3s
Build images / build-ml (push) Successful in 6s
Build images / build-agent (push) Successful in 7s
Build images / build-web (push) Successful in 6s
CI / frontend-build (push) Successful in 20s
CI / backend-lint-and-test (push) Successful in 27s
CI / integration (push) Successful in 3m28s
2026-07-01 12:37:45 -04:00
bvandeusen 2f9aa3d86c Merge pull request 'perf(web): 4 MiB file streaming + 4 hypercorn workers (fix 40s downloads)' (#179) from dev into main
CI / lint (push) Successful in 4s
CI / frontend-build (push) Successful in 23s
CI / backend-lint-and-test (push) Successful in 29s
Build images / sign-extension (push) Successful in 3s
Build images / build-agent (push) Successful in 6s
Build images / build-ml (push) Successful in 8s
Build images / build-web (push) Successful in 6s
CI / integration (push) Successful in 3m26s
2026-07-01 11:55:35 -04:00
bvandeusen 560a5000a2 Merge pull request 'gallery: sort by earliest post date across all posts (new default)' (#178) from dev into main
Build images / sign-extension (push) Successful in 4s
CI / lint (push) Successful in 4s
Build images / build-agent (push) Successful in 8s
Build images / build-ml (push) Successful in 9s
Build images / build-web (push) Successful in 10s
CI / frontend-build (push) Successful in 22s
CI / backend-lint-and-test (push) Successful in 28s
CI / integration (push) Successful in 3m26s
2026-07-01 10:54:41 -04:00
bvandeusen 7ddad231f8 Merge pull request 'agent: stop downloader pool stampeding a slow curator (congestion collapse)' (#177) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 4s
Build images / build-ml (push) Successful in 8s
Build images / build-agent (push) Successful in 8s
Build images / build-web (push) Successful in 7s
CI / integration (push) Successful in 3m22s
CI / frontend-build (push) Successful in 20s
CI / backend-lint-and-test (push) Successful in 35s
2026-07-01 10:45:48 -04:00
bvandeusen a78f7eaace Merge pull request 'explore: more variance in the related rail (stronger MMR diversification)' (#176) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 2s
Build images / build-agent (push) Successful in 8s
Build images / build-ml (push) Successful in 9s
Build images / build-web (push) Successful in 7s
CI / frontend-build (push) Successful in 20s
CI / backend-lint-and-test (push) Successful in 26s
CI / integration (push) Successful in 3m25s
2026-07-01 00:50:59 -04:00
bvandeusen 71337b0ba4 Merge pull request 'agent: temporal video dedup — drop near-duplicate frames before the GPU' (#175) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 3s
Build images / build-ml (push) Successful in 6s
Build images / build-agent (push) Successful in 7s
Build images / build-web (push) Successful in 6s
CI / frontend-build (push) Successful in 18s
CI / backend-lint-and-test (push) Successful in 27s
CI / integration (push) Successful in 3m25s
2026-07-01 00:38:53 -04:00
bvandeusen 3996205f3b Merge pull request 'agent: dedupe near-duplicate crops before the SigLIP embed' (#174) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 5s
Build images / build-agent (push) Successful in 7s
Build images / build-ml (push) Successful in 8s
Build images / build-web (push) Successful in 6s
CI / frontend-build (push) Successful in 21s
CI / backend-lint-and-test (push) Successful in 26s
CI / integration (push) Successful in 3m26s
2026-07-01 00:24:39 -04:00
bvandeusen 9f9db01456 Merge pull request 'agent: download/GPU producer-consumer pipeline + detector fuse fix' (#173) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 2s
Build images / build-ml (push) Successful in 7s
Build images / build-agent (push) Successful in 8s
Build images / build-web (push) Successful in 6s
CI / frontend-build (push) Successful in 19s
CI / backend-lint-and-test (push) Successful in 26s
CI / integration (push) Successful in 3m25s
2026-06-30 23:39:25 -04:00
bvandeusen 216b7fc743 Merge pull request 'Agent: bound video GPU work (early-exit frame loop at max_regions)' (#172) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 4s
Build images / build-ml (push) Successful in 6s
Build images / build-agent (push) Successful in 8s
Build images / build-web (push) Successful in 6s
CI / frontend-build (push) Successful in 20s
CI / backend-lint-and-test (push) Successful in 27s
CI / integration (push) Successful in 3m26s
2026-06-30 22:57:33 -04:00
bvandeusen fbb76e6f36 Merge pull request 'Agent: huge-image load + figure/region caps + stale-active fix' (#171) from dev into main
Build images / sign-extension (push) Successful in 2s
CI / lint (push) Successful in 3s
Build images / build-ml (push) Successful in 7s
Build images / build-agent (push) Successful in 8s
Build images / build-web (push) Successful in 6s
CI / frontend-build (push) Successful in 19s
CI / backend-lint-and-test (push) Successful in 27s
CI / integration (push) Successful in 3m25s
2026-06-30 22:47:48 -04:00
bvandeusen 5ef1478ade Merge pull request 'Modernise agent base → Ubuntu 24.04 / Python 3.12 / CUDA 12.9' (#170) from dev into main
Build images / build-ml (push) Failing after 3s
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 4s
Build images / build-web (push) Successful in 7s
CI / frontend-build (push) Successful in 21s
CI / backend-lint-and-test (push) Successful in 41s
CI / integration (push) Successful in 3m22s
Build images / build-agent (push) Successful in 8m44s
2026-06-30 22:30:18 -04:00
bvandeusen 88ff4147e1 Merge pull request 'Fix: agent py3.10 startup crash + submit-path retry + pin agent ruff to py310' (#169) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 3s
Build images / build-ml (push) Successful in 6s
Build images / build-agent (push) Successful in 7s
Build images / build-web (push) Successful in 6s
CI / frontend-build (push) Successful in 19s
CI / backend-lint-and-test (push) Successful in 26s
CI / integration (push) Successful in 3m25s
2026-06-30 22:03:57 -04:00
bvandeusen 1ea02ad44c Merge pull request 'Release: prompt agent stop + lazy curator polling + build marker + agent in CI' (#168) from dev into main
Build images / sign-extension (push) Successful in 2s
CI / lint (push) Successful in 3s
Build images / build-ml (push) Successful in 6s
Build images / build-web (push) Successful in 6s
Build images / build-agent (push) Successful in 7s
CI / frontend-build (push) Successful in 19s
CI / backend-lint-and-test (push) Successful in 27s
CI / integration (push) Successful in 3m23s
2026-06-30 21:42:55 -04:00
bvandeusen 937cfb65b4 Merge pull request 'Release: agent per-stage timing breakdown' (#167) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 2s
Build images / build-ml (push) Successful in 6s
Build images / build-agent (push) Successful in 7s
CI / backend-lint-and-test (push) Successful in 25s
Build images / build-web (push) Successful in 5s
CI / frontend-build (push) Successful in 18s
CI / integration (push) Successful in 3m23s
2026-06-30 21:28:57 -04:00
bvandeusen baac851220 Merge pull request 'perf: GPU-job leasing stays O(batch) — partial indexes + two-phase lease' (#166) from dev into main
Build images / sign-extension (push) Successful in 3s
Build images / build-agent (push) Successful in 9s
Build images / build-ml (push) Successful in 9s
CI / integration (push) Successful in 3m27s
CI / lint (push) Successful in 4s
Build images / build-web (push) Successful in 7s
CI / frontend-build (push) Successful in 19s
CI / backend-lint-and-test (push) Successful in 27s
2026-06-30 21:18:23 -04:00
bvandeusen a28c33281a Merge pull request 'Release: unfreeze agent status view + smoothed throughput autoscaler + log pane' (#165) from dev into main
CI / lint (push) Successful in 2s
Build images / sign-extension (push) Successful in 3s
Build images / build-ml (push) Successful in 7s
Build images / build-agent (push) Successful in 9s
Build images / build-web (push) Successful in 6s
CI / frontend-build (push) Successful in 16s
CI / backend-lint-and-test (push) Successful in 25s
CI / integration (push) Successful in 3m23s
2026-06-30 20:20:34 -04:00
bvandeusen 40be0a9323 Merge pull request 'Release: agent page no-store cache header' (#164) from dev into main
Build images / sign-extension (push) Successful in 3s
Build images / build-agent (push) Failing after 3s
CI / lint (push) Successful in 2s
Build images / build-ml (push) Successful in 8s
Build images / build-web (push) Successful in 6s
CI / frontend-build (push) Successful in 16s
CI / backend-lint-and-test (push) Successful in 25s
CI / integration (push) Successful in 3m23s
2026-06-30 20:00:36 -04:00
bvandeusen 2c6bf26bfc Merge pull request 'Release: graceful Start/Stop (starting/stopping states) + cached GPU reads' (#163) from dev into main
Build images / build-web (push) Successful in 6s
CI / frontend-build (push) Successful in 16s
CI / backend-lint-and-test (push) Successful in 26s
Build images / sign-extension (push) Successful in 2s
CI / lint (push) Successful in 2s
Build images / build-ml (push) Successful in 6s
Build images / build-agent (push) Successful in 7s
CI / integration (push) Successful in 3m24s
2026-06-30 19:38:50 -04:00
bvandeusen 3c1e76bd44 Merge pull request 'Release: stable util-band autoscaler + live GPU meters' (#162) from dev into main
Build images / sign-extension (push) Successful in 3s
Build images / build-ml (push) Successful in 6s
Build images / build-agent (push) Successful in 5s
CI / lint (push) Successful in 3s
CI / frontend-build (push) Successful in 16s
Build images / build-web (push) Successful in 6s
CI / backend-lint-and-test (push) Successful in 26s
CI / integration (push) Successful in 3m44s
2026-06-30 19:16:29 -04:00
bvandeusen 831c5b1c10 Merge pull request 'Release: agent page — centered column + logs fill to bottom' (#161) from dev into main
Build images / sign-extension (push) Successful in 2s
CI / lint (push) Successful in 3s
Build images / build-agent (push) Successful in 7s
Build images / build-ml (push) Successful in 7s
Build images / build-web (push) Successful in 6s
CI / frontend-build (push) Successful in 18s
CI / backend-lint-and-test (push) Successful in 27s
CI / integration (push) Successful in 3m25s
2026-06-30 19:10:50 -04:00
bvandeusen 76b6af4903 Merge pull request 'Release: agent perf (batched crop embeds) + UI (full-width, copy logs, quiet noise) + truncated-image fix' (#160) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 3s
Build images / build-ml (push) Successful in 6s
Build images / build-agent (push) Successful in 7s
CI / frontend-build (push) Successful in 17s
Build images / build-web (push) Successful in 6s
CI / backend-lint-and-test (push) Successful in 25s
CI / integration (push) Successful in 3m32s
2026-06-30 18:47:55 -04:00
bvandeusen 92494ec4ed Merge pull request 'Release: agent control-page logs console + styling pass' (#159) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 2s
Build images / build-ml (push) Successful in 7s
Build images / build-agent (push) Successful in 7s
CI / integration (push) Successful in 3m26s
Build images / build-web (push) Successful in 6s
CI / frontend-build (push) Successful in 19s
CI / backend-lint-and-test (push) Successful in 27s
2026-06-30 18:37:35 -04:00
bvandeusen bdfc17477c Merge pull request 'Release: GPU agent autoscaler (throughput hill-climb, Auto default-on)' (#158) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 3s
Build images / build-ml (push) Successful in 7s
Build images / build-agent (push) Successful in 8s
Build images / build-web (push) Successful in 6s
CI / frontend-build (push) Successful in 19s
CI / backend-lint-and-test (push) Successful in 25s
CI / integration (push) Successful in 3m24s
2026-06-30 18:23:15 -04:00
bvandeusen 6cd3153bf4 Merge pull request 'Release: SigLIP 2 default (new installs) + embedder model dropdown' (#157) from dev into main
CI / lint (push) Successful in 3s
Build images / sign-extension (push) Successful in 3s
Build images / build-agent (push) Successful in 7s
Build images / build-ml (push) Successful in 8s
Build images / build-web (push) Successful in 11s
CI / frontend-build (push) Successful in 20s
CI / backend-lint-and-test (push) Successful in 32s
CI / integration (push) Successful in 3m26s
2026-06-30 18:01:01 -04:00
bvandeusen 5f2853168a Merge pull request 'Release: GPU re-process trigger (apply new crop detectors to the existing library)' (#156) from dev into main
Build images / build-ml (push) Successful in 9s
Build images / build-web (push) Successful in 10s
CI / frontend-build (push) Successful in 19s
CI / backend-lint-and-test (push) Successful in 28s
CI / integration (push) Successful in 3m22s
Build images / build-agent (push) Successful in 3m35s
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 4s
2026-06-30 16:13:36 -04:00
bvandeusen 9a979ee808 Merge pull request 'Release: Explore diversification, model-swap, Camie/centroid retirement, tag-API, crop proposers' (#155) from dev into main
Build images / build-agent (push) Successful in 10m56s
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 4s
CI / frontend-build (push) Successful in 21s
CI / backend-lint-and-test (push) Successful in 29s
Build images / build-web (push) Successful in 2m21s
Build images / build-ml (push) Successful in 2m42s
CI / integration (push) Successful in 3m26s
2026-06-30 16:06:13 -04:00
bvandeusen 3138f912fd Merge pull request 'Release: SigLIP concept crops + max-over-bag + agent redeploy-survival' (#154) from dev into main
CI / lint (push) Successful in 3s
CI / frontend-build (push) Successful in 20s
Build images / sign-extension (push) Successful in 2s
CI / backend-lint-and-test (push) Successful in 26s
Build images / build-web (push) Successful in 2m18s
Build images / build-ml (push) Successful in 2m44s
CI / integration (push) Successful in 3m35s
Build images / build-agent (push) Successful in 7m50s
2026-06-30 08:46:20 -04:00
bvandeusen 9df874e396 Merge pull request 'CCIP automation + reference quality (auto-enqueue, auto-apply, contamination fix, cache)' (#152) from dev into main
Build images / sign-extension (push) Successful in 2s
CI / lint (push) Successful in 3s
CI / backend-lint-and-test (push) Successful in 28s
CI / frontend-build (push) Successful in 20s
Build images / build-web (push) Successful in 2m20s
Build images / build-ml (push) Successful in 2m40s
Build images / build-agent (push) Successful in 3m17s
CI / integration (push) Successful in 3m27s
2026-06-29 22:29:30 -04:00
bvandeusen 6915a7590a Merge pull request 'fix(tags): refocus tag input after reject/un-reject too' (#151) from dev into main
CI / integration (push) Successful in 3m26s
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 3s
Build images / build-ml (push) Successful in 7s
Build images / build-agent (push) Successful in 7s
Build images / build-web (push) Successful in 9s
CI / frontend-build (push) Successful in 16s
CI / backend-lint-and-test (push) Successful in 27s
2026-06-29 21:07:21 -04:00
bvandeusen 5e8c28236a Merge pull request 'CCIP match threshold (tunable, 0.85) + Explore → variance' (#150) from dev into main
Build images / sign-extension (push) Successful in 2s
CI / lint (push) Successful in 3s
Build images / build-agent (push) Successful in 7s
Build images / build-ml (push) Successful in 8s
Build images / build-web (push) Successful in 10s
CI / frontend-build (push) Successful in 20s
CI / backend-lint-and-test (push) Successful in 27s
CI / integration (push) Successful in 3m29s
2026-06-29 20:48:14 -04:00
bvandeusen 7e3c0f0b74 Merge pull request 'agent: raise worker cap to 32 + size HTTP pool' (#149) from dev into main
Build images / sign-extension (push) Successful in 2s
CI / lint (push) Successful in 1s
CI / backend-lint-and-test (push) Successful in 26s
CI / frontend-build (push) Successful in 20s
Build images / build-web (push) Successful in 2m17s
Build images / build-ml (push) Successful in 2m47s
Build images / build-agent (push) Successful in 3m11s
CI / integration (push) Successful in 3m31s
2026-06-29 19:42:11 -04:00
bvandeusen 5d0c7ba706 Merge pull request 'fix(agent): flatten transparency onto white before RGB' (#148) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 2s
Build images / build-ml (push) Successful in 6s
Build images / build-agent (push) Successful in 7s
Build images / build-web (push) Successful in 6s
CI / frontend-build (push) Successful in 18s
CI / backend-lint-and-test (push) Successful in 26s
CI / integration (push) Successful in 3m27s
2026-06-29 19:18:48 -04:00
bvandeusen 18300e1f8a Merge pull request 'Agent: GPU load + live worker tuning + fast orphan recovery' (#147) from dev into main
Build images / sign-extension (push) Successful in 2s
CI / lint (push) Successful in 3s
Build images / build-ml (push) Successful in 8s
Build images / build-agent (push) Successful in 8s
Build images / build-web (push) Successful in 7s
CI / frontend-build (push) Successful in 22s
CI / backend-lint-and-test (push) Successful in 29s
CI / integration (push) Successful in 3m27s
2026-06-29 19:11:22 -04:00
bvandeusen d52ac0a0e2 Merge pull request 'fix(agent): cuDNN base image so onnxruntime-gpu loads' (#146) from dev into main
Build images / sign-extension (push) Successful in 2s
CI / lint (push) Successful in 3s
CI / frontend-build (push) Successful in 16s
CI / backend-lint-and-test (push) Successful in 26s
Build images / build-web (push) Successful in 2m19s
Build images / build-ml (push) Successful in 2m50s
Build images / build-agent (push) Successful in 3m29s
CI / integration (push) Successful in 3m28s
2026-06-29 18:48:24 -04:00
bvandeusen 401fe8213e Merge pull request 'ci: publish the GPU agent image' (#145) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 4s
Build images / build-ml (push) Successful in 7s
Build images / build-web (push) Successful in 6s
CI / frontend-build (push) Successful in 17s
CI / backend-lint-and-test (push) Successful in 27s
Build images / build-agent (push) Successful in 3m15s
CI / integration (push) Successful in 3m27s
2026-06-29 14:26:51 -04:00
bvandeusen e8774d7953 Merge pull request 'CCIP characters + crop/region pipeline + desktop GPU agent (#114)' (#144) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / frontend-build (push) Successful in 18s
CI / backend-lint-and-test (push) Successful in 26s
CI / lint (push) Successful in 3s
Build images / build-web (push) Successful in 2m8s
Build images / build-ml (push) Successful in 2m37s
CI / integration (push) Successful in 3m26s
2026-06-29 14:18:57 -04:00
bvandeusen bc0f00c51b Merge pull request 'Earned auto-apply (fire + observability + UI), retrain cadences, Explore arrow-nav' (#143) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 3s
CI / frontend-build (push) Successful in 20s
CI / backend-lint-and-test (push) Successful in 27s
Build images / build-web (push) Successful in 2m2s
Build images / build-ml (push) Successful in 2m30s
CI / integration (push) Successful in 3m24s
2026-06-29 07:30:41 -04:00
bvandeusen 1bef68aa29 Merge pull request 'Tagging-v2: heads are the suggestion source (learn-from-tags) + rail accept/reject' (#142) from dev into main
Build images / sign-extension (push) Successful in 2s
CI / frontend-build (push) Successful in 18s
CI / lint (push) Successful in 2s
CI / backend-lint-and-test (push) Successful in 25s
Build images / build-web (push) Successful in 2m4s
Build images / build-ml (push) Successful in 2m44s
CI / integration (push) Successful in 3m25s
2026-06-28 11:55:49 -04:00
bvandeusen 1a4bc2f981 Merge pull request 'tag-eval: "keep" records a confirmation so doubts stop resurfacing' (#141) from dev into main
CI / lint (push) Successful in 2s
CI / frontend-build (push) Successful in 17s
CI / backend-lint-and-test (push) Successful in 27s
Build images / build-web (push) Successful in 2m5s
CI / integration (push) Successful in 3m20s
Build images / sign-extension (push) Successful in 3s
Build images / build-ml (push) Successful in 2m30s
2026-06-28 01:36:26 -04:00
bvandeusen 862ace69d6 Merge pull request 'fix(tag-eval): stop re-suggesting already-rejected items' (#140) from dev into main
Build images / sign-extension (push) Successful in 2s
CI / lint (push) Successful in 3s
Build images / build-ml (push) Successful in 7s
Build images / build-web (push) Successful in 7s
CI / frontend-build (push) Successful in 18s
CI / backend-lint-and-test (push) Successful in 27s
CI / integration (push) Successful in 3m19s
2026-06-28 01:10:26 -04:00
bvandeusen abf88b1a15 Merge pull request 'tag-eval: auto-apply operating point + server-side top-N concept discovery' (#139) from dev into main
CI / backend-lint-and-test (push) Failing after 2s
Build images / sign-extension (push) Successful in 2s
CI / lint (push) Successful in 2s
CI / frontend-build (push) Successful in 20s
Build images / build-web (push) Successful in 2m3s
Build images / build-ml (push) Successful in 2m37s
CI / integration (push) Successful in 3m19s
2026-06-28 00:54:14 -04:00
bvandeusen c05dcafbea Merge pull request 'fix(tag-eval): example thumbnail opens the view modal instead of Explore' (#138) from dev into main
Build images / build-ml (push) Successful in 7s
CI / frontend-build (push) Successful in 18s
CI / integration (push) Successful in 3m20s
Build images / sign-extension (push) Successful in 2s
CI / lint (push) Successful in 3s
Build images / build-web (push) Successful in 9s
CI / backend-lint-and-test (push) Successful in 27s
2026-06-28 00:11:20 -04:00
bvandeusen 55e8632dab Merge pull request 'Tag-eval review actions: bigger clickable thumbnails + inline confirm/reject' (#137) from dev into main
Build images / sign-extension (push) Successful in 2s
CI / lint (push) Successful in 3s
CI / frontend-build (push) Successful in 19s
CI / backend-lint-and-test (push) Successful in 27s
Build images / build-web (push) Successful in 2m2s
Build images / build-ml (push) Successful in 2m36s
CI / integration (push) Successful in 3m18s
2026-06-27 23:49:12 -04:00
bvandeusen 825e6b90bf Merge pull request 'Tag-eval (heads vs centroid) + focus/provenance/layout/sticky-header fixes + tag-gaps cleanup' (#136) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / frontend-build (push) Successful in 20s
CI / lint (push) Successful in 3s
CI / backend-lint-and-test (push) Successful in 27s
Build images / build-web (push) Successful in 2m5s
Build images / build-ml (push) Successful in 2m31s
CI / integration (push) Successful in 3m21s
2026-06-27 23:03:04 -04:00
bvandeusen fb012c557c Merge pull request 'fix(explore): bound 3-pane grid row so a tall rail can't scroll the page' (#135) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 3s
CI / frontend-build (push) Successful in 23s
CI / backend-lint-and-test (push) Successful in 28s
Build images / build-web (push) Successful in 2m5s
Build images / build-ml (push) Successful in 2m37s
CI / integration (push) Successful in 3m20s
2026-06-26 23:37:43 -04:00
bvandeusen 66593ab895 Merge pull request 'Explore: focus-everywhere + provenance in rail; tag-gaps cleanup' (#134) from dev into main
Build images / sign-extension (push) Successful in 2s
CI / lint (push) Successful in 3s
Build images / build-ml (push) Successful in 7s
Build images / build-web (push) Successful in 10s
CI / frontend-build (push) Successful in 18s
CI / backend-lint-and-test (push) Successful in 27s
CI / integration (push) Successful in 3m17s
2026-06-26 21:40:56 -04:00
bvandeusen b266a54ad3 Merge pull request 'feat(explore): auto-focus tag input on every image change' (#133) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 3s
Build images / build-ml (push) Successful in 7s
Build images / build-web (push) Successful in 11s
CI / frontend-build (push) Successful in 20s
CI / backend-lint-and-test (push) Successful in 28s
CI / integration (push) Successful in 3m25s
2026-06-26 11:28:32 -04:00
bvandeusen ad803b646f Merge pull request 'fix(modal): place meta + save block under Provenance, above Tags' (#132) from dev into main
CI / lint (push) Successful in 3s
CI / frontend-build (push) Successful in 19s
CI / backend-lint-and-test (push) Successful in 27s
CI / integration (push) Successful in 3m20s
Build images / sign-extension (push) Successful in 3s
Build images / build-ml (push) Successful in 7s
Build images / build-web (push) Successful in 11s
2026-06-26 10:14:15 -04:00
bvandeusen 1f5da3d283 Merge pull request 'Fandom count fix + Explore 3-pane workspace + modal rail layout' (#131) from dev into main
Build images / sign-extension (push) Successful in 4s
CI / lint (push) Successful in 3s
Build images / build-ml (push) Successful in 7s
Build images / build-web (push) Successful in 10s
CI / frontend-build (push) Successful in 19s
CI / backend-lint-and-test (push) Successful in 27s
CI / integration (push) Successful in 3m21s
2026-06-26 08:24:26 -04:00
bvandeusen 93034f580d Merge pull request 'fix(tags): fandom views aggregate images via their characters' (#130) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 3s
CI / frontend-build (push) Successful in 20s
CI / backend-lint-and-test (push) Successful in 28s
Build images / build-web (push) Successful in 2m6s
Build images / build-ml (push) Successful in 2m34s
CI / integration (push) Successful in 3m19s
2026-06-26 00:28:21 -04:00
bvandeusen 9b9b12f410 Merge pull request 'Modal suggestions scroll-cap + Celery broker resilience' (#129) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 3s
CI / frontend-build (push) Successful in 18s
CI / backend-lint-and-test (push) Successful in 28s
Build images / build-web (push) Successful in 2m3s
Build images / build-ml (push) Failing after 2m34s
CI / integration (push) Successful in 3m30s
2026-06-24 08:34:17 -04:00
bvandeusen 376d310693 Merge pull request 'Tagging & viewing roadmap: tag query surface + hygiene projections + Explore view (Clusters A/B/C)' (#128) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 3s
CI / frontend-build (push) Successful in 18s
CI / backend-lint-and-test (push) Successful in 28s
Build images / build-web (push) Successful in 2m6s
Build images / build-ml (push) Successful in 2m39s
CI / integration (push) Successful in 3m24s
2026-06-23 08:57:34 -04:00
bvandeusen bc69495a16 Merge pull request 'Provenance archive linkage, post reconciliation close-out, and the cleanup/admin DRY pass' (#127) from dev into main
Build images / build-web (push) Successful in 2m2s
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 3s
CI / frontend-build (push) Successful in 20s
CI / backend-lint-and-test (push) Successful in 28s
Build images / build-ml (push) Successful in 2m33s
CI / integration (push) Successful in 3m22s
2026-06-22 20:22:56 -04:00
bvandeusen 478f898e72 Merge pull request 'feat(settings): Maintenance tab compact tiles + centered Settings (pass 2)' (#126) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 3s
CI / frontend-build (push) Successful in 18s
CI / backend-lint-and-test (push) Successful in 37s
Build images / build-web (push) Successful in 2m18s
Build images / build-ml (push) Successful in 3m3s
CI / integration (push) Successful in 3m26s
2026-06-18 00:11:54 -04:00
bvandeusen 38a5e7f332 Merge pull request 'feat(settings): tidy Cleanup tab into sectioned compact tiles (pass 1)' (#125) from dev into main
CI / backend-lint-and-test (push) Failing after 2s
Build images / sign-extension (push) Successful in 2s
CI / lint (push) Successful in 2s
Build images / build-ml (push) Successful in 7s
Build images / build-web (push) Successful in 10s
CI / frontend-build (push) Successful in 19s
CI / integration (push) Successful in 3m19s
2026-06-17 23:49:21 -04:00
bvandeusen 57fe15c267 Merge pull request 'feat(maintenance): reconcile duplicate posts (gallery-dl→native unify)' (#124) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 3s
Build images / build-ml (push) Successful in 9s
Build images / build-web (push) Successful in 10s
CI / frontend-build (push) Successful in 24s
CI / backend-lint-and-test (push) Successful in 26s
CI / integration (push) Successful in 3m22s
2026-06-17 22:01:12 -04:00
bvandeusen eb3231ef10 Merge pull request 'fix(subscribestar): port gallery-dl date extraction (wrapped dates) + parse canary' (#123) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 2s
Build images / build-ml (push) Successful in 7s
Build images / build-web (push) Successful in 7s
CI / frontend-build (push) Successful in 21s
CI / backend-lint-and-test (push) Successful in 26s
CI / integration (push) Successful in 3m19s
2026-06-17 21:15:43 -04:00
bvandeusen e9af459c0d Merge pull request 'feat(subscribestar): port gallery-dl doc + audio attachment extraction' (#122) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 4s
Build images / build-ml (push) Successful in 9s
CI / frontend-build (push) Successful in 17s
Build images / build-web (push) Successful in 7s
CI / backend-lint-and-test (push) Successful in 32s
CI / integration (push) Successful in 3m15s
2026-06-17 16:20:12 -04:00
bvandeusen 6f02806aec Merge pull request 'fix(subscribestar): port gallery-dl content + preview-skip extraction (body bug)' (#121) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 2s
Build images / build-ml (push) Successful in 8s
Build images / build-web (push) Successful in 12s
CI / frontend-build (push) Successful in 19s
CI / backend-lint-and-test (push) Successful in 32s
CI / integration (push) Successful in 3m25s
2026-06-17 16:10:56 -04:00
bvandeusen a1d19bd96a Merge pull request 'fix(subscribestar): mirror gallery-dl's full request profile (verify_subscriber gate)' (#120) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 3s
Build images / build-ml (push) Successful in 7s
Build images / build-web (push) Successful in 7s
CI / frontend-build (push) Successful in 19s
CI / backend-lint-and-test (push) Successful in 27s
CI / integration (push) Successful in 3m24s
2026-06-17 15:32:28 -04:00
bvandeusen 26827ff38f Merge pull request 'fix(subscribestar): match gallery-dl's generic post delimiter (live-feed drift)' (#119) from dev into main
Build images / sign-extension (push) Successful in 2s
CI / lint (push) Successful in 2s
Build images / build-ml (push) Successful in 7s
Build images / build-web (push) Successful in 8s
CI / frontend-build (push) Successful in 19s
CI / backend-lint-and-test (push) Successful in 38s
CI / integration (push) Successful in 3m15s
2026-06-17 15:07:59 -04:00
bvandeusen 26dcfaf6c2 Merge pull request 'fix(subscribestar): initial feed GET is a navigation, not XHR (first-run drift)' (#118) from dev into main
CI / lint (push) Successful in 3s
Build images / sign-extension (push) Successful in 3s
Build images / build-ml (push) Successful in 7s
Build images / build-web (push) Successful in 7s
CI / frontend-build (push) Successful in 18s
CI / backend-lint-and-test (push) Successful in 37s
CI / integration (push) Successful in 3m14s
2026-06-17 14:30:02 -04:00
bvandeusen 9b1b0369cc Merge pull request 'SubscribeStar → native core ingester + native-ingest DRY pass (milestone #71)' (#117) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 3s
Build images / build-ml (push) Successful in 8s
Build images / build-web (push) Successful in 10s
CI / frontend-build (push) Successful in 18s
CI / backend-lint-and-test (push) Successful in 29s
CI / integration (push) Successful in 3m20s
2026-06-17 12:48:29 -04:00
bvandeusen 18123fb9cb Merge pull request 'fix(external): recovery-sweep threshold + queue recording + split fetch timeouts (#883)' (#116) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 2s
Build images / build-ml (push) Successful in 7s
Build images / build-web (push) Successful in 7s
CI / frontend-build (push) Successful in 22s
CI / backend-lint-and-test (push) Successful in 34s
CI / integration (push) Successful in 3m14s
2026-06-16 21:24:31 -04:00
bvandeusen 2e806f202f Merge pull request 'test(artist-dir): fix flaky uq_image_record_sha256 collision' (#115) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 3s
Build images / build-ml (push) Successful in 7s
Build images / build-web (push) Successful in 6s
CI / frontend-build (push) Successful in 22s
CI / backend-lint-and-test (push) Successful in 31s
CI / integration (push) Successful in 3m17s
2026-06-16 20:34:21 -04:00
bvandeusen 18d5c05639 Merge pull request 'fix(ml): per-task async engine for recompute_centroid (#881)' (#114) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 3s
Build images / build-ml (push) Successful in 8s
Build images / build-web (push) Successful in 7s
CI / frontend-build (push) Successful in 21s
CI / backend-lint-and-test (push) Successful in 30s
CI / integration (push) Failing after 3m19s
2026-06-16 20:24:37 -04:00
bvandeusen 11ddfc3876 Merge pull request 'fix(maint): resurface dedup/gated-purge results after navigate-away (#877)' (#113) from dev into main
Build images / sign-extension (push) Successful in 2s
Build images / build-ml (push) Successful in 6s
CI / lint (push) Successful in 2s
CI / frontend-build (push) Successful in 18s
Build images / build-web (push) Successful in 15s
CI / backend-lint-and-test (push) Successful in 29s
CI / integration (push) Successful in 3m20s
2026-06-16 16:48:52 -04:00
bvandeusen 2b8ce86622 Merge pull request 'Gated Patreon posts: skip on ingest + cleanup tool (#874)' (#112) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 4s
Build images / build-ml (push) Successful in 8s
Build images / build-web (push) Successful in 11s
CI / frontend-build (push) Successful in 20s
CI / backend-lint-and-test (push) Successful in 34s
CI / integration (push) Successful in 3m27s
2026-06-16 15:25:34 -04:00
bvandeusen 49bee77cdc Merge pull request 'Video tag quality: cadence sampling + min-frame aggregation + ML thread cap (#747)' (#111) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 2s
Build images / build-ml (push) Successful in 8s
Build images / build-web (push) Successful in 10s
CI / frontend-build (push) Successful in 18s
CI / backend-lint-and-test (push) Successful in 30s
CI / integration (push) Successful in 3m15s
2026-06-16 14:08:40 -04:00
bvandeusen c209e3b37e Merge pull request 'Tier-1 video dedup: import-time + retroactive cleanup (#871)' (#110) from dev into main
CI / integration (push) Successful in 3m16s
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 3s
Build images / build-ml (push) Successful in 7s
CI / frontend-build (push) Successful in 17s
CI / backend-lint-and-test (push) Successful in 27s
Build images / build-web (push) Successful in 26s
2026-06-16 08:55:38 -04:00
bvandeusen cffdd93418 Merge pull request 'Nested-archive extraction (#718) + post-first ingest (#67) + post-body canary (#862)' (#109) from dev into main
Build images / build-ml (push) Successful in 8s
Build images / build-web (push) Successful in 8s
CI / frontend-build (push) Successful in 21s
CI / backend-lint-and-test (push) Successful in 28s
CI / integration (push) Successful in 3m19s
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 3s
2026-06-15 21:37:39 -04:00
bvandeusen fd84be40dd Merge pull request 'External-attach orphan fix (#859) + image-less post display' (#108) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 3s
Build images / build-ml (push) Successful in 8s
CI / frontend-build (push) Successful in 21s
CI / backend-lint-and-test (push) Successful in 40s
Build images / build-web (push) Successful in 10s
CI / integration (push) Successful in 3m19s
2026-06-15 01:55:36 -04:00
bvandeusen 79f510d7f8 Merge pull request 'Merge dev → main: read post body from content_json_string (the empty-body fix) (#842)' (#107) from dev into main
CI / lint (push) Successful in 2s
Build images / build-ml (push) Successful in 10s
Build images / build-web (push) Successful in 8s
CI / frontend-build (push) Successful in 19s
CI / backend-lint-and-test (push) Successful in 37s
CI / integration (push) Successful in 3m24s
Build images / sign-extension (push) Successful in 3s
2026-06-15 00:19:45 -04:00
bvandeusen 59181069da Merge pull request 'Merge dev → main: per-post stdout diagnostics + post-field write DRY (#842/#753)' (#106) from dev into main
CI / lint (push) Successful in 3s
CI / frontend-build (push) Successful in 21s
CI / backend-lint-and-test (push) Successful in 41s
Build images / sign-extension (push) Successful in 3s
Build images / build-web (push) Successful in 2m15s
Build images / build-ml (push) Successful in 2m55s
CI / integration (push) Successful in 3m11s
2026-06-14 23:45:39 -04:00
bvandeusen 428ecd8642 Merge pull request 'Merge dev → main: per-post body-capture diagnostics in the event UI (#842)' (#105) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 3s
Build images / build-ml (push) Successful in 8s
Build images / build-web (push) Successful in 10s
CI / integration (push) Successful in 3m11s
CI / frontend-build (push) Successful in 22s
CI / backend-lint-and-test (push) Successful in 37s
2026-06-14 23:14:14 -04:00
bvandeusen ed1e04b831 Merge pull request 'Merge dev → main: recapture body-fetch fix + diagnostics (#842)' (#104) from dev into main
Build images / sign-extension (push) Successful in 2s
CI / lint (push) Successful in 3s
Build images / build-ml (push) Successful in 7s
Build images / build-web (push) Successful in 8s
CI / frontend-build (push) Successful in 19s
CI / backend-lint-and-test (push) Successful in 35s
CI / integration (push) Successful in 3m15s
2026-06-14 22:31:41 -04:00
bvandeusen f5156bd847 Merge pull request 'Merge dev → main: Recapture mode (#842) — re-grab post bodies/links + localize on-disk inline images' (#103) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 3s
CI / frontend-build (push) Successful in 22s
CI / backend-lint-and-test (push) Successful in 32s
Build images / build-web (push) Successful in 2m30s
CI / integration (push) Successful in 3m29s
Build images / build-ml (push) Successful in 3m27s
2026-06-14 21:21:49 -04:00
bvandeusen dfc3922d24 Merge pull request 'Merge dev → main: #830 rich post capture + external-host downloads (+ #768/#789/#739)' (#102) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 3s
CI / frontend-build (push) Successful in 23s
CI / backend-lint-and-test (push) Successful in 48s
Build images / build-web (push) Successful in 2m21s
Build images / build-ml (push) Successful in 2m47s
CI / integration (push) Successful in 3m20s
2026-06-14 19:16:09 -04:00
bvandeusen 3eb08e926b Merge pull request 'fix(aliases): modal raw-key bug + alias visibility/management' (#101) from dev into main
CI / lint (push) Successful in 2s
Build images / sign-extension (push) Successful in 3s
Build images / build-ml (push) Successful in 7s
Build images / build-web (push) Successful in 10s
CI / frontend-build (push) Successful in 19s
CI / backend-lint-and-test (push) Successful in 26s
CI / integration (push) Successful in 3m13s
2026-06-12 14:02:00 -04:00
bvandeusen 9e81ced359 Merge pull request 'fix(images): percent-encode original-image URLs ('#' in paths 404'd)' (#100) from dev into main
CI / lint (push) Successful in 3s
Build images / sign-extension (push) Successful in 3s
Build images / build-ml (push) Successful in 7s
Build images / build-web (push) Successful in 8s
CI / frontend-build (push) Successful in 20s
CI / backend-lint-and-test (push) Successful in 36s
CI / integration (push) Successful in 3m15s
2026-06-12 00:44:56 -04:00
bvandeusen 11e9f5af60 Merge pull request 'fix(browse): tabs and search on one row' (#99) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 3s
Build images / build-ml (push) Successful in 7s
CI / frontend-build (push) Successful in 24s
CI / backend-lint-and-test (push) Successful in 29s
Build images / build-web (push) Successful in 11s
CI / integration (push) Successful in 3m13s
2026-06-12 00:28:38 -04:00
bvandeusen 909fa37b15 Merge pull request 'Browse search + series numbering rework + kebab fix' (#98) from dev into main
CI / lint (push) Successful in 3s
Build images / sign-extension (push) Successful in 3s
CI / frontend-build (push) Successful in 22s
CI / backend-lint-and-test (push) Successful in 28s
Build images / build-web (push) Successful in 2m17s
Build images / build-ml (push) Successful in 2m50s
CI / integration (push) Successful in 3m16s
2026-06-12 00:14:31 -04:00
bvandeusen dfab8f65ff Merge pull request 'feat(series): flat sequence + cosmetic dividers + pending staging (#789)' (#97) from dev into main
CI / lint (push) Successful in 3s
Build images / sign-extension (push) Successful in 3s
CI / frontend-build (push) Successful in 29s
CI / backend-lint-and-test (push) Successful in 38s
Build images / build-web (push) Successful in 2m13s
Build images / build-ml (push) Successful in 2m49s
CI / integration (push) Successful in 3m9s
2026-06-11 22:07:57 -04:00
bvandeusen 618f7cdc36 Merge pull request 'feat(ml): drop image_record.tagger_predictions — image_prediction is sole store (#768 step 3)' (#96) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 3s
CI / frontend-build (push) Successful in 22s
CI / backend-lint-and-test (push) Successful in 25s
Build images / build-web (push) Successful in 2m29s
Build images / build-ml (push) Successful in 3m0s
CI / integration (push) Successful in 3m13s
2026-06-11 19:32:05 -04:00
bvandeusen 028ea33a7c Merge pull request 'fix(migration): make 0045 DDL-only; backfill image_prediction via batched task (#768)' (#95) from dev into main
CI / backend-lint-and-test (push) Successful in 38s
Build images / build-ml (push) Successful in 2m58s
CI / integration (push) Successful in 3m15s
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 3s
CI / frontend-build (push) Successful in 19s
Build images / build-web (push) Successful in 2m50s
2026-06-11 09:22:22 -04:00
bvandeusen 444c1fb075 Merge pull request 'perf(migration): 0045 streams json_each (no materialize / no temp blowup)' (#94) from dev into main
Build images / sign-extension (push) Successful in 3s
Build images / build-web (push) Successful in 2m14s
Build images / build-ml (push) Successful in 2m36s
CI / lint (push) Successful in 2s
CI / frontend-build (push) Successful in 21s
CI / backend-lint-and-test (push) Successful in 32s
CI / integration (push) Successful in 3m8s
2026-06-10 22:07:16 -04:00
bvandeusen 26c68b0a75 Merge pull request 'fix(migration): 0045 guards json_each against scalar tagger_predictions' (#93) from dev into main
CI / lint (push) Successful in 2s
CI / frontend-build (push) Successful in 21s
CI / backend-lint-and-test (push) Successful in 27s
CI / integration (push) Successful in 3m13s
Build images / sign-extension (push) Successful in 2s
Build images / build-web (push) Successful in 6s
Build images / build-ml (push) Successful in 2m40s
2026-06-10 20:33:27 -04:00
bvandeusen e75427b19a Merge pull request '#768 steps 1+2: normalized image_prediction table (read cutover)' (#92) from dev into main
CI / lint (push) Successful in 3s
Build images / sign-extension (push) Successful in 3s
CI / frontend-build (push) Successful in 22s
CI / backend-lint-and-test (push) Successful in 27s
Build images / build-web (push) Successful in 2m4s
Build images / build-ml (push) Successful in 2m42s
CI / integration (push) Successful in 3m8s
2026-06-10 20:15:26 -04:00
bvandeusen 5447fab987 Merge pull request 'Activity search + RecoverySweep fix + tagger_predictions shrink (#762, #764) + backup polish' (#91) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / backend-lint-and-test (push) Successful in 38s
Build images / build-web (push) Successful in 2m23s
Build images / build-ml (push) Successful in 2m51s
CI / integration (push) Successful in 3m10s
CI / lint (push) Successful in 3s
CI / frontend-build (push) Successful in 19s
2026-06-10 14:33:35 -04:00
bvandeusen bad37e07b2 Merge pull request 'Browse hub, series rename, full-prediction dropdown + a DRY pass (7 sweeps)' (#90) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 2s
CI / backend-lint-and-test (push) Successful in 27s
CI / frontend-build (push) Successful in 44s
Build images / build-ml (push) Successful in 3m2s
CI / integration (push) Successful in 3m15s
Build images / build-web (push) Successful in 2m17s
2026-06-10 00:24:01 -04:00
bvandeusen 2bfc9936a1 Merge pull request 'UI batch: tagging flow, series browse, fandom chips, nav' (#89) from dev into main
CI / lint (push) Successful in 3s
CI / backend-lint-and-test (push) Successful in 39s
Build images / sign-extension (push) Successful in 3s
CI / frontend-build (push) Successful in 26s
Build images / build-web (push) Successful in 2m21s
Build images / build-ml (push) Successful in 2m52s
CI / integration (push) Successful in 3m24s
2026-06-09 20:48:19 -04:00
bvandeusen 4c6406ee18 Merge pull request 'fix(posts): link duplicate items to every post + prune bare shells' (#88) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 3s
CI / frontend-build (push) Successful in 24s
CI / backend-lint-and-test (push) Successful in 28s
Build images / build-web (push) Successful in 2m19s
Build images / build-ml (push) Successful in 2m41s
CI / integration (push) Successful in 3m5s
2026-06-08 19:42:31 -04:00
bvandeusen bb47e80b3e Merge pull request 'fix(cleanup): unused-tags delete must use the same predicate as the preview' (#87) from dev into main
Build images / sign-extension (push) Successful in 2s
CI / lint (push) Successful in 2s
Build images / build-ml (push) Successful in 7s
Build images / build-web (push) Successful in 7s
CI / frontend-build (push) Successful in 23s
CI / backend-lint-and-test (push) Successful in 26s
CI / integration (push) Successful in 3m13s
2026-06-08 18:17:49 -04:00
bvandeusen dc1083b5e0 Merge pull request 'Unused-tag fandom fix + ML-worker logging/tuning + unified dropdown Enter' (#86) from dev into main
CI / lint (push) Successful in 3s
CI / frontend-build (push) Successful in 18s
CI / backend-lint-and-test (push) Successful in 27s
CI / integration (push) Successful in 3m16s
Build images / sign-extension (push) Successful in 3s
Build images / build-ml (push) Successful in 8s
Build images / build-web (push) Successful in 2m21s
2026-06-08 17:27:55 -04:00
bvandeusen e46893fefd Merge pull request 'Migration lock safety + remove the merge's full-table scan (the real 0040-hang fix)' (#85) from dev into main
Build images / sign-extension (push) Successful in 2s
CI / lint (push) Successful in 2s
CI / frontend-build (push) Successful in 25s
CI / backend-lint-and-test (push) Successful in 25s
Build images / build-web (push) Successful in 2m3s
Build images / build-ml (push) Successful in 2m33s
CI / integration (push) Successful in 3m7s
2026-06-08 01:03:06 -04:00
bvandeusen 0666e15211 Merge pull request 'Series manage redesign (FC-6.4) + migration/normalize hardening + UX fixes' (#84) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 3s
CI / frontend-build (push) Successful in 23s
CI / backend-lint-and-test (push) Successful in 25s
Build images / build-web (push) Successful in 1m58s
Build images / build-ml (push) Successful in 2m28s
CI / integration (push) Successful in 3m6s
2026-06-07 21:17:57 -04:00
bvandeusen 747390631d Merge pull request 'FC-6 series authoring + backup/NFS hardening + UX fixes' (#83) from dev into main
Build images / sign-extension (push) Successful in 2s
CI / lint (push) Successful in 2s
CI / frontend-build (push) Successful in 18s
CI / backend-lint-and-test (push) Successful in 27s
Build images / build-web (push) Successful in 2m5s
Build images / build-ml (push) Successful in 2m43s
CI / integration (push) Successful in 3m6s
2026-06-07 19:31:04 -04:00
bvandeusen e0d2a20588 Merge pull request 'Modal focus/keyboard polish, Camie-in-autocomplete, re-extract self-resume' (#82) from dev into main
Build images / sign-extension (push) Successful in 2s
CI / lint (push) Successful in 3s
CI / frontend-build (push) Successful in 25s
CI / backend-lint-and-test (push) Successful in 28s
Build images / build-web (push) Successful in 2m2s
Build images / build-ml (push) Successful in 2m32s
CI / integration (push) Successful in 3m9s
2026-06-07 12:17:43 -04:00
bvandeusen 1d84f67418 Merge pull request 'Modal: large centered spinner + kebab z-index fix' (#81) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 3s
CI / frontend-build (push) Successful in 27s
CI / backend-lint-and-test (push) Successful in 29s
Build images / build-web (push) Successful in 2m3s
Build images / build-ml (push) Successful in 2m35s
CI / integration (push) Successful in 3m7s
2026-06-07 10:57:00 -04:00
bvandeusen 91265df3d6 Merge pull request 'Maintenance-queue health + modal/tagging keyboard pass' (#80) from dev into main
Build images / sign-extension (push) Successful in 2s
CI / lint (push) Successful in 3s
CI / frontend-build (push) Successful in 20s
CI / backend-lint-and-test (push) Successful in 25s
Build images / build-web (push) Successful in 2m5s
Build images / build-ml (push) Successful in 2m26s
CI / integration (push) Successful in 3m1s
2026-06-07 10:31:01 -04:00
bvandeusen 11acdb0322 Merge pull request 'Patreon: a missing media file_name is a fallback, not API drift' (#79) from dev into main
CI / lint (push) Successful in 2s
Build images / build-web (push) Successful in 7s
CI / frontend-build (push) Successful in 20s
Build images / sign-extension (push) Successful in 2s
Build images / build-ml (push) Successful in 7s
CI / backend-lint-and-test (push) Successful in 27s
CI / integration (push) Successful in 3m2s
2026-06-06 23:14:25 -04:00
bvandeusen 2eb9fd5dd0 Merge pull request 'Patreon: enforce the backfill time-box mid-post (stop soft-limit overruns)' (#78) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 3s
Build images / build-ml (push) Successful in 8s
Build images / build-web (push) Successful in 8s
CI / frontend-build (push) Successful in 19s
CI / backend-lint-and-test (push) Successful in 27s
CI / integration (push) Successful in 3m3s
2026-06-06 23:00:29 -04:00
bvandeusen 01e5ce1410 Merge pull request 'Patreon: resolve creator campaign from a single-post URL' (#77) from dev into main
Build images / sign-extension (push) Successful in 2s
CI / lint (push) Successful in 2s
Build images / build-ml (push) Successful in 7s
Build images / build-web (push) Successful in 7s
CI / frontend-build (push) Successful in 24s
CI / backend-lint-and-test (push) Successful in 26s
CI / integration (push) Successful in 3m4s
2026-06-06 21:38:33 -04:00
bvandeusen 3bb94674cf Merge pull request 'Patreon download concurrency cap + immediate backfill kickoff on create' (#76) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 2s
Build images / build-ml (push) Successful in 7s
Build images / build-web (push) Successful in 7s
CI / frontend-build (push) Successful in 23s
CI / backend-lint-and-test (push) Successful in 26s
CI / integration (push) Successful in 3m1s
2026-06-06 21:27:22 -04:00
bvandeusen a75c602175 Merge pull request 'Subscriptions UX overhaul + Patreon /cw/ vanity fix' (#75) from dev into main
Build images / sign-extension (push) Successful in 2s
CI / lint (push) Successful in 2s
Build images / build-ml (push) Successful in 7s
Build images / build-web (push) Successful in 9s
CI / frontend-build (push) Successful in 24s
CI / backend-lint-and-test (push) Successful in 28s
CI / integration (push) Successful in 3m2s
2026-06-06 21:12:02 -04:00
bvandeusen ef8f4f7193 Merge pull request 'Tag-casing acronym fix, Patreon resolver hardening, archive diagnostics, post-card strip' (#74) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 2s
Build images / build-ml (push) Successful in 8s
Build images / build-web (push) Successful in 10s
CI / frontend-build (push) Successful in 22s
CI / backend-lint-and-test (push) Successful in 26s
CI / integration (push) Successful in 3m1s
2026-06-06 19:59:32 -04:00
bvandeusen ec3d27b219 Merge pull request 'Tag-maintenance sweep + bug-fix batch: #699 #700 #701 #709 #711 #712 #713 #714' (#73) from dev into main
Build images / sign-extension (push) Successful in 2s
CI / lint (push) Successful in 3s
Build images / build-ml (push) Successful in 8s
Build images / build-web (push) Successful in 9s
CI / frontend-build (push) Successful in 18s
CI / backend-lint-and-test (push) Successful in 27s
CI / integration (push) Successful in 3m2s
2026-06-06 16:49:00 -04:00
bvandeusen 03bd3b2eda Merge pull request 'Native Patreon ingester + download-engine ownership (plans #697, #703–#708)' (#72) from dev into main
Build images / build-ml (push) Successful in 7s
Build images / build-web (push) Successful in 10s
CI / backend-lint-and-test (push) Successful in 25s
CI / frontend-build (push) Successful in 32s
CI / integration (push) Successful in 3m1s
Build images / sign-extension (push) Successful in 2s
CI / lint (push) Successful in 3s
2026-06-06 12:57:31 -04:00
bvandeusen 7395e77d75 Merge pull request 'Smarter backfill: time-boxed chunks, run-until-done (plan #693)' (#71) from dev into main
CI / lint (push) Successful in 3s
Build images / sign-extension (push) Successful in 3s
Build images / build-ml (push) Successful in 8s
CI / backend-lint-and-test (push) Successful in 13s
Build images / build-web (push) Successful in 10s
CI / frontend-build (push) Successful in 18s
CI / integration (push) Successful in 2m58s
2026-06-05 16:33:06 -04:00
bvandeusen 575d817919 Merge pull request '#70 dev→main: cursor-paged backfill + mobile modal fixes' from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 3s
Build images / build-ml (push) Successful in 8s
CI / backend-lint-and-test (push) Successful in 13s
Build images / build-web (push) Successful in 9s
CI / frontend-build (push) Successful in 18s
CI / integration (push) Successful in 2m59s
2026-06-05 11:10:02 -04:00
bvandeusen 2a8f7cd8b6 Merge pull request '#69 dev→main: release v26.06.04.0' from dev into main
CI / lint (push) Successful in 2s
CI / backend-lint-and-test (push) Successful in 12s
CI / frontend-build (push) Successful in 19s
CI / integration (push) Successful in 3m2s
Build images / sign-extension (push) Has been skipped
Build images / build-ml (push) Successful in 6s
Build images / build-web (push) Successful in 6s
2026-06-04 23:16:12 -04:00
bvandeusen 83f8af8090 Merge pull request 'dev→main: surface near-duplicate (pHash) control + reorder import tab' (#68) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 2s
Build images / build-ml (push) Successful in 6s
CI / backend-lint-and-test (push) Successful in 11s
Build images / build-web (push) Successful in 10s
CI / frontend-build (push) Successful in 20s
CI / integration (push) Successful in 2m56s
2026-06-04 21:39:44 -04:00
bvandeusen 9a2617c1a2 Merge pull request 'dev→main: post-card redesign (images→modal, in-place text expand)' (#67) from dev into main
CI / lint (push) Failing after 2s
Build images / sign-extension (push) Successful in 3s
Build images / build-ml (push) Successful in 6s
CI / backend-lint-and-test (push) Successful in 11s
Build images / build-web (push) Successful in 9s
CI / frontend-build (push) Successful in 24s
CI / integration (push) Successful in 2m57s
2026-06-04 17:32:50 -04:00
bvandeusen 81688815a0 Merge pull request 'dev→main: similar-search render fix + reset-content-tagging + scan persistence' (#66) from dev into main
Build images / sign-extension (push) Successful in 2s
CI / lint (push) Successful in 2s
Build images / build-ml (push) Successful in 9s
CI / backend-lint-and-test (push) Successful in 13s
Build images / build-web (push) Successful in 11s
CI / frontend-build (push) Successful in 22s
CI / integration (push) Successful in 2m58s
2026-06-04 16:59:52 -04:00
bvandeusen 773128c3bf Merge pull request 'dev→main: purpose-built mobile layout for subscriptions hub' (#65) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 3s
Build images / build-ml (push) Successful in 6s
CI / backend-lint-and-test (push) Successful in 14s
Build images / build-web (push) Successful in 9s
CI / frontend-build (push) Successful in 38s
CI / integration (push) Successful in 2m56s
2026-06-04 13:43:56 -04:00
bvandeusen ce7b154ae9 Merge pull request 'dev→main: subscriptions table mobile card layout' (#64) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 4s
Build images / build-ml (push) Successful in 8s
CI / backend-lint-and-test (push) Successful in 14s
Build images / build-web (push) Successful in 10s
CI / frontend-build (push) Successful in 22s
CI / integration (push) Successful in 2m57s
2026-06-04 12:56:58 -04:00
bvandeusen 9430a9d9c3 Merge pull request 'dev→main: gallery similarity search (Phase 3) + UI/mobile polish' (#63) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 3s
Build images / build-ml (push) Successful in 8s
CI / backend-lint-and-test (push) Successful in 12s
Build images / build-web (push) Successful in 9s
CI / frontend-build (push) Successful in 21s
CI / integration (push) Successful in 2m56s
2026-06-04 11:21:26 -04:00
bvandeusen 23aee56ce3 Merge pull request 'dev→main: showcase cascade + filter styling + DB maintenance + gallery filter Phase 2 + showcase decode-gate + CI perf' (#62) from dev into main
Build images / sign-extension (push) Successful in 2s
CI / lint (push) Successful in 3s
Build images / build-ml (push) Successful in 9s
CI / backend-lint-and-test (push) Successful in 12s
Build images / build-web (push) Successful in 11s
CI / frontend-build (push) Successful in 20s
CI / integration (push) Successful in 3m0s
2026-06-04 08:27:47 -04:00
bvandeusen 711abea567 Merge pull request 'Gallery speed + fandom editing + filters + pinned filter bar' (#61) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 2s
Build images / build-ml (push) Successful in 8s
CI / backend-lint-and-test (push) Successful in 18s
CI / frontend-build (push) Successful in 19s
Build images / build-web (push) Successful in 11s
CI / intimp (push) Successful in 3m48s
CI / intapi (push) Successful in 7m45s
CI / intcore (push) Successful in 8m29s
2026-06-04 00:07:19 -04:00
bvandeusen 844bb86802 Merge pull request 'fix(download): release DB connections across the gallery-dl subprocess' (#60) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 2s
Build images / build-ml (push) Successful in 10s
Build images / build-web (push) Successful in 7s
CI / frontend-build (push) Successful in 19s
CI / backend-lint-and-test (push) Successful in 25s
CI / intimp (push) Successful in 3m46s
CI / intapi (push) Successful in 7m41s
CI / intcore (push) Successful in 8m24s
2026-06-03 22:11:36 -04:00
bvandeusen a8f6a464aa Merge pull request 'fix(download): salvage soft-time-limit kills + fix timeout ladder' (#59) from dev into main
CI / lint (push) Successful in 2s
Build images / sign-extension (push) Successful in 3s
CI / backend-lint-and-test (push) Successful in 21s
CI / frontend-build (push) Successful in 24s
Build images / build-web (push) Successful in 2m37s
Build images / build-ml (push) Successful in 3m21s
CI / intimp (push) Successful in 3m40s
CI / intapi (push) Successful in 7m47s
CI / intcore (push) Successful in 8m15s
2026-06-03 19:35:45 -04:00
bvandeusen ab9922ad2e Merge pull request 'feat(artist): "new since last visit" badge + banner' (#58) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 4s
CI / backend-lint-and-test (push) Successful in 23s
Build images / build-ml (push) Successful in 2m53s
CI / frontend-build (push) Successful in 21s
Build images / build-web (push) Successful in 2m9s
CI / intimp (push) Successful in 3m36s
CI / intapi (push) Successful in 7m32s
CI / intcore (push) Successful in 8m9s
2026-06-03 16:20:54 -04:00
bvandeusen 0533807669 Merge pull request 'feat(ext): verify cookies in-browser before uploading (1.0.7)' (#57) from dev into main
CI / backend-lint-and-test (push) Successful in 32s
Build images / build-ml (push) Successful in 3m28s
Build images / build-web (push) Successful in 3m42s
CI / intapi (push) Successful in 9m32s
CI / frontend-build (push) Successful in 20s
extension / lint (push) Successful in 14s
Build images / sign-extension (push) Successful in 3m17s
CI / intimp (push) Successful in 4m26s
CI / lint (push) Successful in 3s
CI / intcore (push) Successful in 10m18s
2026-06-03 14:17:42 -04:00
bvandeusen 279dff3fb6 Merge pull request 'feat(ml): normalize Camie suggestion names to human-readable' (#56) from dev into main
Build images / build-ml (push) Successful in 2m55s
CI / intimp (push) Successful in 3m36s
CI / intapi (push) Successful in 7m38s
CI / intcore (push) Successful in 8m24s
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 3s
CI / backend-lint-and-test (push) Successful in 15s
CI / frontend-build (push) Successful in 17s
Build images / build-web (push) Successful in 2m17s
2026-06-03 13:18:44 -04:00
bvandeusen 37e66cddc4 Merge pull request 'chore(modal): drop ?image=N soft-compat — pure overlay' (#55) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 4s
CI / frontend-build (push) Successful in 27s
CI / backend-lint-and-test (push) Successful in 38s
Build images / build-ml (push) Successful in 2m46s
Build images / build-web (push) Successful in 2m36s
CI / intimp (push) Successful in 3m42s
CI / intapi (push) Successful in 7m25s
CI / intcore (push) Successful in 8m12s
2026-06-02 19:35:04 -04:00
bvandeusen 9cf6b2d363 Merge pull request 'audit-g5 final + ML threshold default + kebab menu fix' (#54) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 4s
CI / backend-lint-and-test (push) Successful in 23s
CI / frontend-build (push) Successful in 27s
Build images / build-web (push) Successful in 3m0s
Build images / build-ml (push) Successful in 3m45s
CI / intimp (push) Successful in 3m46s
CI / intapi (push) Successful in 8m8s
CI / intcore (push) Successful in 8m34s
2026-06-02 19:09:49 -04:00
bvandeusen 6ef0fed41f Merge pull request 'audit-g5: architectural debt — 4 bundles (A/B/C/D)' (#53) from dev into main
CI / lint (push) Successful in 2s
Build images / sign-extension (push) Successful in 3s
Build images / build-ml (push) Successful in 9s
CI / backend-lint-and-test (push) Successful in 18s
Build images / build-web (push) Successful in 10s
CI / frontend-build (push) Successful in 26s
CI / intimp (push) Successful in 3m51s
CI / intapi (push) Successful in 7m38s
CI / intcore (push) Successful in 8m52s
2026-06-02 18:07:25 -04:00
bvandeusen 89b48f8f35 Merge pull request 'audit-g4: status-enum miss batch' (#52) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 3s
Build images / build-ml (push) Successful in 11s
CI / backend-lint-and-test (push) Successful in 13s
Build images / build-web (push) Successful in 10s
CI / frontend-build (push) Successful in 35s
CI / intimp (push) Successful in 3m41s
CI / intapi (push) Successful in 7m25s
CI / intcore (push) Successful in 8m10s
2026-06-02 16:15:00 -04:00
bvandeusen d60e0b9494 Merge pull request 'audit-g3: lifecycle batch — recovery sweeps, retention, timeouts' (#51) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 2s
Build images / build-ml (push) Successful in 9s
CI / backend-lint-and-test (push) Successful in 12s
Build images / build-web (push) Successful in 7s
CI / frontend-build (push) Successful in 36s
CI / intimp (push) Successful in 3m39s
CI / intapi (push) Successful in 7m39s
CI / intcore (push) Successful in 8m10s
2026-06-02 14:49:28 -04:00
bvandeusen 9c27a2d3c7 Merge pull request 'audit-g2: async race / state-leak fixes across eight stores' (#50) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 3s
Build images / build-ml (push) Successful in 8s
CI / backend-lint-and-test (push) Successful in 23s
CI / frontend-build (push) Successful in 37s
Build images / build-web (push) Successful in 13s
CI / intimp (push) Successful in 3m52s
CI / intapi (push) Successful in 8m15s
CI / intcore (push) Successful in 8m50s
2026-06-02 14:17:12 -04:00
bvandeusen 93e37681b7 Merge pull request 'audit-g1: six one-liner drift fixes from 2026-06-02 audit' (#49) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 2s
Build images / build-ml (push) Successful in 11s
CI / backend-lint-and-test (push) Successful in 11s
CI / frontend-build (push) Successful in 19s
Build images / build-web (push) Successful in 10s
CI / intapi (push) Successful in 7m28s
CI / intcore (push) Successful in 8m3s
CI / intimp (push) Successful in 3m42s
2026-06-02 13:29:17 -04:00
bvandeusen 64ca858574 Merge pull request 'UX fixes: suggestion-accept chip refresh, showcase endless feed, non-media downloads' (#48) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 2s
Build images / build-ml (push) Successful in 9s
CI / backend-lint-and-test (push) Successful in 12s
Build images / build-web (push) Successful in 9s
CI / frontend-build (push) Successful in 20s
CI / intimp (push) Successful in 3m52s
CI / intapi (push) Successful in 7m45s
CI / intcore (push) Successful in 8m20s
2026-06-02 08:26:58 -04:00
bvandeusen 9d0c0b7da8 Merge pull request 'fix(thumbnails): surface backfill counts + tighten validity check' (#47) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 3s
Build images / build-ml (push) Successful in 8s
Build images / build-web (push) Successful in 9s
CI / frontend-build (push) Successful in 18s
CI / backend-lint-and-test (push) Successful in 18s
CI / intimp (push) Successful in 3m45s
CI / intapi (push) Successful in 7m32s
CI / intcore (push) Successful in 8m4s
2026-06-01 22:34:38 -04:00
bvandeusen 8e4d252ae4 Merge pull request 'fix(downloads): enqueue thumbnail + ML per attached image' (#46) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / intimp (push) Successful in 3m40s
CI / lint (push) Successful in 3s
CI / intapi (push) Successful in 7m51s
CI / intcore (push) Successful in 8m46s
Build images / build-ml (push) Successful in 8s
Build images / build-web (push) Successful in 8s
CI / backend-lint-and-test (push) Successful in 19s
CI / frontend-build (push) Successful in 26s
2026-06-01 21:52:42 -04:00
bvandeusen fdd3e01f56 Merge pull request 'ux(failing-sources): visible row separators + clearer hover' (#45) from dev into main
CI / lint (push) Successful in 3s
Build images / sign-extension (push) Successful in 3s
Build images / build-ml (push) Successful in 10s
Build images / build-web (push) Successful in 10s
CI / backend-lint-and-test (push) Successful in 20s
CI / frontend-build (push) Successful in 33s
CI / intimp (push) Successful in 3m44s
CI / intapi (push) Successful in 7m48s
CI / intcore (push) Successful in 8m16s
2026-06-01 21:09:28 -04:00
bvandeusen c82fb308b6 Merge pull request 'Post.source_id refactor + tick/backfill modes + PARTIAL classifier + Mux fix + UX' (#44) from dev into main
Build images / build-ml (push) Failing after 2s
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 3s
CI / intimp (push) Successful in 3m47s
CI / intapi (push) Successful in 7m52s
CI / intcore (push) Successful in 8m16s
Build images / build-web (push) Successful in 12s
CI / backend-lint-and-test (push) Successful in 19s
CI / frontend-build (push) Successful in 31s
2026-06-01 20:44:23 -04:00
bvandeusen 8cf8d2ca4d Merge pull request 'Modal Esc/overflow polish, artist-scoped post scroll, failing-sources Logs button' (#43) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 8s
CI / frontend-build (push) Successful in 21s
CI / backend-lint-and-test (push) Successful in 31s
Build images / build-web (push) Successful in 2m45s
Build images / build-ml (push) Successful in 3m31s
CI / intimp (push) Successful in 3m48s
CI / intapi (push) Successful in 7m11s
CI / intcore (push) Successful in 7m47s
2026-06-01 12:10:11 -04:00
bvandeusen b1d58bc3b8 Merge pull request 'fix(ci): POSIX-safe SHORT_SHA in build.yml' (#42) from dev into main
CI / lint (push) Successful in 3s
CI / backend-lint-and-test (push) Successful in 15s
CI / frontend-build (push) Successful in 20s
Build images / sign-extension (push) Successful in 3s
Build images / build-web (push) Successful in 2m17s
Build images / build-ml (push) Successful in 3m24s
CI / intimp (push) Successful in 3m22s
CI / intapi (push) Successful in 7m51s
CI / intcore (push) Successful in 8m5s
2026-06-01 08:03:17 -04:00
bvandeusen 65386f02a0 Merge pull request 'View modal batch: autofocus, suggestions UX, post-title click, retire copyright/artist' (#41) from dev into main
Build images / sign-extension (push) Successful in 2s
CI / lint (push) Successful in 2s
Build images / build-ml (push) Failing after 4s
Build images / build-web (push) Failing after 3s
CI / backend-lint-and-test (push) Successful in 15s
CI / frontend-build (push) Successful in 17s
CI / intimp (push) Successful in 3m46s
CI / intapi (push) Successful in 7m17s
CI / intcore (push) Successful in 8m2s
2026-06-01 07:01:42 -04:00
bvandeusen 667b05f14e Merge pull request 'Extension probe-and-add (v1.0.6) + per-commit image tags' (#40) from dev into main
CI / lint (push) Successful in 2s
Build images / build-ml (push) Failing after 2s
CI / frontend-build (push) Successful in 19s
CI / backend-lint-and-test (push) Successful in 22s
extension / lint (push) Successful in 8s
Build images / sign-extension (push) Successful in 1m51s
Build images / build-web (push) Failing after 4s
CI / intimp (push) Successful in 3m38s
CI / intapi (push) Successful in 7m20s
CI / intcore (push) Successful in 7m45s
2026-06-01 01:44:03 -04:00
bvandeusen 856e9104b4 Merge pull request 'Sidecar synthetic anchor cleanup + tier-gated classifier fix' (#39) from dev into main
CI / lint (push) Successful in 5s
CI / frontend-build (push) Successful in 22s
CI / backend-lint-and-test (push) Successful in 28s
CI / intimp (push) Successful in 3m54s
Build images / sign-extension (push) Has been skipped
Build images / build-web (push) Successful in 1m1s
Build images / build-ml (push) Successful in 1m21s
CI / intapi (push) Successful in 7m31s
CI / intcore (push) Successful in 8m0s
2026-06-01 00:16:58 -04:00
bvandeusen 0397642b21 Merge pull request 'Showcase cadence tuning + cooldown-aware bulk retry' (#38) from dev into main 2026-05-30 23:50:36 -04:00
bvandeusen 237575447d Merge pull request 'Thumbnail URL fix + archive daemon fix + batched initial loads' (#37) from dev into main 2026-05-30 22:01:43 -04:00
bvandeusen ed358757dc Merge pull request 'Most-overdue-first scheduling + rich timeout diagnostics' (#36) from dev into main 2026-05-30 14:30:41 -04:00
bvandeusen d181f4afb8 Merge pull request 'Downloads burst-prevention + maintenance-menu fix + gdl timeout' (#35) from dev into main 2026-05-30 11:43:18 -04:00
bvandeusen 2886fa4997 Merge pull request 'Tooltip !important fix — 104cac5 follow-up after Vite CSS reorder' (#34) from dev into main 2026-05-30 00:02:24 -04:00
bvandeusen f256f587ee Merge pull request 'UI batch + I1–I6 service passes + download-event recovery sweep' (#33) from dev into main 2026-05-29 22:46:16 -04:00
bvandeusen 384d8d5e50 Merge pull request 'Dashboard insights + project-wide DRY pass' (#32) from dev into main 2026-05-28 15:38:26 -04:00
bvandeusen 319e8c1d18 Merge pull request 'v26.05.28.0: downloads dashboard + task-resilience overhaul (timeouts, archive split, 3-layer poison-pill defense)' (#31) from dev into main 2026-05-28 00:45:00 -04:00
bvandeusen 9075d8eadd Merge pull request 'v26.05.27.2: subscribestar + HF cookie quirks, platforms package refactor, showcase IR-parity, secure-context audit' (#30) from dev into main 2026-05-27 21:34:02 -04:00
bvandeusen 88e53e5b86 Merge pull request 'v26.05.27.1: subscriptions hub + post-card merge + sidecar audit' (#29) from dev into main 2026-05-27 17:12:48 -04:00
bvandeusen 37e8b796a1 Merge pull request 'v26.05.27.0: PostCard redesign + IR-style tag suffix + drop meta/rating + extension v1.0.4 CSP fix' (#28) from dev into main 2026-05-27 11:31:18 -04:00
bvandeusen 4e82208926 Merge pull request 'v26.05.26.5 — extension CORS unblock + UI gap closes + CI workflow cleanup' (#27) from dev into main 2026-05-26 20:15:07 -04:00
bvandeusen 52fff00353 Merge pull request 'v26.05.26.4 — hotfix: migration 0022 pre-DELETE colliding ImageProvenance before UPDATE' (#26) from dev into main 2026-05-26 18:06:20 -04:00
bvandeusen c14338cbce Merge pull request 'v26.05.26.3 — hotfix: migration 0022 pre-merge across ENTIRE (canonical+others) group' (#25) from dev into main 2026-05-26 17:52:59 -04:00
bvandeusen 8c36dd28b0 Merge pull request 'v26.05.26.2 — hotfix: alembic 0022 Post-collision pre-merge + ci.yml cache continue-on-error' (#24) from dev into main 2026-05-26 16:50:43 -04:00
bvandeusen 88cfb3dd02 Merge pull request 'v26.05.26.1 — thumb backfill, modal redesign, recovery sweep race-safety, artist view redesign, extension fixes' (#23) from dev into main 2026-05-26 16:32:00 -04:00
bvandeusen 5d4f223b71 Merge pull request 'Release v26.05.25.7 — FC-Cleanup tab + UniqueViolation fix + error modal + extension install fix' (#22) from dev into main 2026-05-26 08:26:46 -04:00
bvandeusen 05090c6e85 Merge pull request 'Release v26.05.25.7 — animated-WebP worker fix + FC-Cleanup backend' (#21) from dev into main 2026-05-26 01:48:13 -04:00
bvandeusen 3a577d5ade Merge pull request 'fix(ext-ci): use browser_download_url + curl -f + ZIP magic check (XPI silently corrupt)' (#20) from dev into main 2026-05-26 00:43:02 -04:00
bvandeusen f4fe02e346 Merge pull request 'fix(ext-ci): drop actions/upload-artifact (Forgejo doesn't support v4+ GHES)' (#19) from dev into main 2026-05-25 23:33:40 -04:00
bvandeusen e766197d99 Merge pull request 'fix(ext-ci): jq→python + bump ext to 1.0.3 + rollback-on-upload-failure' (#18) from dev into main 2026-05-25 23:14:51 -04:00
bvandeusen 3872e1dda9 Merge pull request 'fix(ext-ci): web-ext v8 .cjs config workaround' (#17) from dev into main 2026-05-25 22:49:14 -04:00
bvandeusen 9814f3dbaf Merge pull request 'Release v26.05.25.5 — Extension publish refactor, deep-scan IR-parity, archive-import perf, artist Settings tab' (#16) from dev into main 2026-05-25 22:44:59 -04:00
bvandeusen b214460fdb Merge pull request 'Release v26.05.25.4 — importer ext sanitize fix, CI shard split, BrowserExtensionCard on Overview' (#15) from dev into main 2026-05-25 21:11:50 -04:00
bvandeusen ac55d0e8d8 Merge pull request 'fix(ext-ci): match AMO-renamed signed XPI' (#14) from dev into main 2026-05-25 18:22:50 -04:00
bvandeusen 89a89e0ded Merge pull request 'Release v26.05.25.3 — ML embedder SigLIP fix, import-UX, extension publish' (#13) from dev into main 2026-05-25 17:56:50 -04:00
bvandeusen 4e9aac2c05 Merge pull request 'v26.05.25.2: supersede + sidecar enrichment, scan toast feedback, CI uv + pip cache + durations' (#12) from dev into main 2026-05-25 14:30:25 -04:00
bvandeusen 2879ac6f2b Merge pull request 'v26.05.25.1: maintenance sweep + Camie v2 + corrupt-file handling + post-date gallery + clear-stuck escape hatch' (#11) from dev into main 2026-05-25 12:57:46 -04:00
bvandeusen b8dce6c483 Merge pull request 'FC-3h + FC-3k: backup first-class + admin destructive actions' (#10) from dev into main 2026-05-25 01:41:53 -04:00
bvandeusen d1c0b82a22 Merge pull request 'v26.05.24.3: FC-3i System Activity dashboard + migration backup-gate retired + modal Escape' (#9) from dev into main 2026-05-24 21:47:53 -04:00
bvandeusen 5526b8dc78 Merge pull request 'v26.05.24.2: IR Post/Provenance restore + modal artist fallback' (#8) from dev into main 2026-05-24 14:30:06 -04:00
bvandeusen 16eb7075c4 Merge pull request 'v26.05.24.1: FC-3g Firefox extension + worker resilience + UI/migration fixes' (#7) from dev into main 2026-05-24 12:52:31 -04:00
bvandeusen 885dcf64f3 Merge pull request 'v26.05.24.0: TopNav re-fix (flex 1 1 0 side cells)' (#6) from dev into main 2026-05-23 22:49:29 -04:00
bvandeusen f2f6b6d25e Merge pull request 'v26.05.23.3: dogfood UX polish + accurate active-batch stats' (#5) from dev into main 2026-05-23 22:05:59 -04:00
bvandeusen 0822240fde Merge pull request 'v26.05.23.2: serve /images + artist cleanup migrator' (#4) from dev into main 2026-05-23 12:19:16 -04:00
bvandeusen 27f7f3fd01 Merge pull request 'v26.05.23.1: migration durability + dogfood UX' (#3) from dev into main 2026-05-23 11:21:33 -04:00
bvandeusen c5bf564f53 Merge dev: v26.05.23.0 migration follow-ups (#2)
pg_dump + zstd in runtime image, lift Quart body cap to 1 GiB. See PR #2.
2026-05-22 22:37:06 -04:00
bvandeusen 602c7d275d Merge dev: FC-1 → FC-5 v1 build (#1)
First merge of `dev` into `main` for FabledCurator. Brings FC-1 (Foundation) through FC-5 (Migration tooling) onto `main`. See PR #1 body for the full stage rollup.
2026-05-22 14:15:45 -04:00
416 changed files with 6453 additions and 46213 deletions
+4 -3
View File
@@ -56,8 +56,9 @@ SECRET_KEY=
# LINE. Leaving it set disarms the protection permanently, on an instance that
# by then has credentials worth protecting.
#
# Backups leave the key and the stored logins out on purpose: after restoring
# onto a new install, sign in to each account again.
# BACK UP ./images/secrets/ ALONGSIDE YOUR DATABASE. The key is the only thing
# that can read your stored credentials; a database restored without it needs
# every credential re-entered by hand.
CURATOR_BOOTSTRAP_NEW_KEY=1
@@ -92,7 +93,7 @@ DB_NAME=fabledcurator
#
# FabledCurator has no login, no accounts and no permission model. Anything
# that can reach PORT is an administrator and can read the platform session
# cookies the app stores for Patreon and SubscribeStar.
# cookies the app stores for Patreon, SubscribeStar and Pixiv.
#
# Bind it to a trusted network. See "Before you expose it" in README.md and
# the deployment posture section of SECURITY.md.
+8 -19
View File
@@ -79,27 +79,17 @@ jobs:
- name: Resolve the Postgres service and install deps
run: |
set -eux
# Same service-IP dance as build.yml's integration job; see the long
# Same service-IP dance as ci.yml's integration job; see the long
# comment there for why the job name must stay separator-free.
# Only THIS job's services. The runner's docker daemon is shared, so another
# repo's job can be running beside this one, and a job-name filter matches its
# containers too (Steward run 8358 and Inkwell run 8653 each took another repo's
# Postgres). act names every container of a task GITEA-ACTIONS-TASK-<n>-...: read
# <n> from our own container (its id is in the /etc/hostname bind mount) and
# require exactly one match.
SELF=$(grep -o '/containers/[0-9a-f]\{64\}/' /proc/self/mountinfo | head -n1 | cut -d/ -f3 || true)
SELF=${SELF:-$(hostname)}
TASK=$(docker inspect -f '{{.Name}}' "$SELF" | grep -o 'GITEA-ACTIONS-TASK-[0-9]*-')
test -n "$TASK"
PG=$(docker ps --filter "name=$TASK" --filter "ancestor=pgvector/pgvector:pg16" -q)
test "$(printf '%s\n' "$PG" | grep -c .)" -eq 1
PG=$(docker ps --filter "name=compare" --filter "ancestor=pgvector/pgvector:pg16" -q | head -n1)
test -n "$PG"
PG_IP=$(docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' "$PG")
test -n "$PG_IP"
echo "PG_CONTAINER=$PG" >> "$GITHUB_ENV"
echo "DB_HOST=$PG_IP" >> "$GITHUB_ENV"
# Socket probe in python, not bash's /dev/tcp — these steps run under
# `sh -e`, where that path does not exist. Same fix and same reasoning
# as build.yml's integration job; see the comment there.
# as ci.yml's integration job; see the comment there.
pg_ready=""
for i in $(seq 1 60); do
if python -c "import socket,sys; s=socket.socket(); s.settimeout(2); sys.exit(0 if s.connect_ex(('$PG_IP', 5432)) == 0 else 1)"; then
@@ -182,11 +172,10 @@ jobs:
mkdir -p /tmp/versions_held
mv alembic/versions/*.py /tmp/versions_held/ 2>/dev/null || true
DB_NAME=fc_gen alembic revision --autogenerate -m "baseline" || true
# Printed rather than uploaded: the repo dropped actions/upload-artifact
# in 2026-05, when the runner could not run v4+, and the job log is the
# retrieval channel this job has proven. (gitea/runner 3.x runs stock
# upload-artifact now — Scribe snippet #2271 — so an artifact is an
# option if the log ever stops being enough.)
# Printed rather than uploaded: ci-requirements.md records that this
# runner cannot do actions/upload-artifact@v4+, and the repo dropped
# the action entirely in 2026-05, so the job log is the retrieval
# channel actually proven here.
#
# base64, not the raw file. A plain `cat` of the ~33KB candidate was
# TRUNCATED MID-LINE by the runner on run 4964 — it stopped inside
+587 -1253
View File
File diff suppressed because it is too large Load Diff
+294
View File
@@ -0,0 +1,294 @@
name: CI
# CI lanes per FabledRulebook/forgejo.md "CI philosophy":
# - lint: ruff only, no dep install — fast-fail for the common lint bounce.
# - extension-version: the derived version resolves and is a shape AMO takes.
# - backend-lint-and-test: `pytest -m "not integration"`, no service containers.
# - frontend-build: vitest unit + vite build.
# - integration: pgvector + redis service containers; alembic + `pytest -m integration`.
on:
push:
branches: [dev, main]
# Renovate opens PRs from `renovate/*` branches into `dev`. Those branches
# never push to dev/main, so the push trigger above gives them NO pre-merge
# CI — a bump could only be validated after it was already merged. This
# pull_request trigger (base `dev` only) validates Renovate PRs before merge.
# It deliberately does NOT fire on dev→main PRs (base `main`), which still
# rely on the dev push run — so no duplicate runs. FC has no fork PRs
# (single-operator Forgejo repo), so secrets-on-PR is not a concern.
pull_request:
branches: [dev]
jobs:
# Fast-fail lint lane. ruff is pre-installed in the ci-python image, so
# this runs with NO dependency install and surfaces the most common bounce
# class (lint: I001 / UP037 / ASYNC109 / W293 …) in seconds — instead of
# after the backend job's ~30-60s wheel install. ruff is static analysis,
# so no DB/secret env is needed.
lint:
runs-on: python-ci
container:
image: git.fabledsword.com/bvandeusen/ci-python:3.14
steps:
- uses: actions/checkout@v4
- name: Ruff lint
# agent/ included so the GPU-agent is linted before its image is built
# (build.yml only `docker build`s it — this is where it gets checked).
# scripts/ likewise: release_notes.py runs only on a tag push, so a
# syntax or import error there would otherwise surface at the one
# moment nobody wants to debug a workflow.
run: ruff check backend/ tests/ alembic/ agent/ scripts/
- name: Agent syntax check
# The agent's runtime deps (torch/transformers/ultralytics) aren't in the
# CI image, so we can't import it — but compileall parses every module,
# catching syntax errors before the image build.
run: python -m compileall -q agent/fc_agent
# The extension version is DERIVED, not hand-maintained (milestone 271 step
# 4): build.yml computes it from the commit TIME of the newest packaged
# extension change and stamps it into manifest.json / package.json at build
# time. The guard that used to live here — "packaged files changed but nobody
# bumped the version" — was therefore checking a fact that had stopped
# existing. Worse than useless: it would have failed this lane on every real
# extension change, demanding a bump that decides nothing. Retired 2026-08-27
# rather than left running beside the new mechanism (rule 22).
#
# Two things are still worth asserting, and this is the only lane that can:
# the extension.yml suite runs on node:24-slim, which is exactly why
# version.spec.js sticks to packaging.sh's git-free subcommands.
# 1. the derivation actually resolves on this commit
# 2. the derived string is one AMO will accept, checked against Mozilla's
# own published grammar rather than a loose "digits and dots"
#
# The MAJOR.MINOR-agreement check that used to be (2) is gone with milestone
# 318 step 8: the committed version no longer seeds anything, so there is no
# hand-set part left for the two files to disagree about.
#
# Deliberately NOT checked here: that the derived value beats what has already
# been signed. That guard belongs in build.yml, where it compares against the
# real ext-* releases. Comparing against origin/main here would be wrong —
# dev legitimately derives a LOWER value whenever main is ahead on the
# extension, and a lane that fails for being behind is a lane people learn to
# ignore.
extension-version:
runs-on: python-ci
container:
image: git.fabledsword.com/bvandeusen/ci-python:3.14
steps:
- uses: actions/checkout@v4
with:
# The derivation needs real history: a depth-1 clone sees one commit
# and produces a wrong, too-low value RATHER THAN FAILING. Checking
# that here is half the point of the lane.
fetch-depth: 0
- name: Extension version derives cleanly
run: |
set -eu
# busybox sh on the act_runner — no bashisms (family rule).
VERSION=$(sh extension/scripts/packaging.sh version)
echo "derived: $VERSION"
# Mozilla's published grammar for AMO, transcribed verbatim from
# MDN's manifest.json/version page:
#
# ^(0|[1-9][0-9]{0,8})([.](0|[1-9][0-9]{0,8})){0,3}$
#
# Not the looser `^[0-9]+(\.[0-9]+)*$` this lane used to carry. That
# one passes `2026.08.29.0201`, which AMO REJECTS — a segment must be
# the single digit 0 or start 1-9 — and it also passes five segments,
# where AMO allows four. Both would surface as a failed sign with the
# version already burned: AMO 409s on re-signing, so a rejected value
# cannot be reclaimed and cannot be reused. This lane is the cheap
# place to find out. (#3138, milestone 318 step 8.)
if ! echo "$VERSION" | grep -qE '^(0|[1-9][0-9]{0,8})(\.(0|[1-9][0-9]{0,8})){0,3}$'; then
echo "ERROR: derived version '$VERSION' is not a version AMO accepts."
echo "AMO's grammar: ^(0|[1-9][0-9]{0,8})([.](0|[1-9][0-9]{0,8})){0,3}$"
echo "Most likely cause: a zero-padded segment (08, 0201). The rest"
echo "of the family pads; the extension must not — see packaging.sh."
exit 1
fi
# ...and the shape this project actually derives. AMO would happily
# take `1.0.3500147` too, so the grammar check alone would not notice
# a regression to the pre-318 shape — which orders BELOW everything
# signed since, and is unrecoverable once Firefox has the higher one.
if ! echo "$VERSION" | grep -qE '^20[0-9][0-9]\.[0-9]{1,2}\.[0-9]{1,2}\.[0-9]{1,4}$'; then
echo "ERROR: derived version '$VERSION' is not YYYY.M.D.HHMM."
echo "Rule 148's CalVer is what build.yml signs; the old"
echo "1.0.<minutes> shape would order below every ext-2026.* release."
exit 1
fi
echo "OK: derived version $VERSION"
backend-lint-and-test:
runs-on: python-ci
container:
image: git.fabledsword.com/bvandeusen/ci-python:3.14
env:
# DB_PASSWORD and SECRET_KEY are required by config.py at import time
# even though unit tests don't actually touch the DB or use the secret.
DB_PASSWORD: ci_unit_test_placeholder
SECRET_KEY: ci_unit_test_placeholder
steps:
- uses: actions/checkout@v4
with:
# Full history for tests/test_artifact_identity.py, which derives
# each artifact's revision to check the identity scheme. On a
# depth-1 clone that derivation either fails or returns the tip sha
# — so the lane would go green while asserting nothing, which is
# the one outcome worse than a red one.
fetch-depth: 0
# Cache step removed 2026-05-26: act_runner's cache backend has been
# broken on this homelab runner since 2026-05-15 (first as request-
# timeout warnings, then as hard "Cannot find module .../dist/restore/
# index.js" failures that tank the whole job). The cache step targeted
# ~/.cache/pip but the install below uses `uv pip install` primarily,
# whose own cache lives at ~/.cache/uv — so the cache step's real
# benefit was marginal even when working. Cost of removal: ~30s of
# wheel downloads per job. Future re-enable: mount ~/.cache/uv as a
# docker volume at the runner level (skips actions/cache entirely),
# or fix the runner-side cache backend (clear /var/run/act/actions/*,
# pin act_runner version, etc.).
- name: Install Python deps
# ruff is pre-installed in the ci-python image (see CI-Runner/CI-python/
# Dockerfile's RUFF_VERSION). Per FabledRulebook ci-runners.md, toolchain
# versions live on the runner image, not here.
# uv: 5-10x faster wheel resolve than pip for cold caches.
# Falls back to pip install on uv-missing runners (older images).
run: |
if command -v uv >/dev/null 2>&1; then
uv pip install --system -r requirements.txt pytest pytest-asyncio
else
pip install -r requirements.txt pytest pytest-asyncio
fi
# Ruff moved to the dedicated fast `lint` job above (fails in seconds,
# no dep install). This job is now unit tests only.
- name: Pytest (unit only — integration runs in the integration job)
run: pytest tests/ -v -m "not integration"
frontend-build:
runs-on: python-ci
container:
image: git.fabledsword.com/bvandeusen/ci-python:3.14
defaults:
run:
working-directory: frontend
steps:
- uses: actions/checkout@v4
# No package-lock.json is tracked yet (we don't run npm locally per
# feedback-no-local-runs). Using `npm install` instead of `npm ci`.
# If we want strict lockfile-based reproducibility later, commit a
# package-lock.json and flip this back to `npm ci`.
- run: npm install --no-audit --no-fund
# No type-check step: the frontend is pure JS (no .ts files, no JSDoc),
# so a type-checker has nothing to do. The vue-tsc devDep + its `check`
# script were dropped 2026-07-11 rather than bumped to v3. If we add
# TS/JSDoc later, re-add a tsconfig.json + vue-tsc + a type-check step.
- run: npm run test:unit
- run: npm run build
# Single integration job — collapsed from a 3-way shard split on 2026-06-04.
# The shards existed to parallelize ~8.5min of integration tests; once the
# throwaway Postgres runs with fsync OFF (the durability step below) the whole
# suite runs in ~45s, so the split only triplicated the ~2min fixed overhead
# (container + `uv pip install` + `alembic upgrade head`) and burned 3 of 6
# runner slots for no wall-clock gain. One job now: spin up once, install
# once, migrate once, run every integration test.
#
# The docker-ps filter scopes to THIS job's own Postgres/Redis service
# containers by job name. act_runner strips underscores from job names when
# labelling containers (`int_api` matched nothing on 2026-05-25), so the name
# stays separator-free (`integration`). The step prints `docker ps -a` first
# so a future naming-convention shift surfaces in the log without a
# guess-and-push cycle.
#
# Pre-baking requirements.txt into ci-python:3.14 is intentionally NOT done —
# per ci-requirements.md, FC is the only Python consumer of that image and the
# CI-Runner "add deps to image when used by >1 project" rule keeps it per-job.
integration:
runs-on: python-ci
container:
image: git.fabledsword.com/bvandeusen/ci-python:3.14
env:
DB_USER: fabledcurator
DB_PASSWORD: ci_integration
DB_PORT: "5432"
DB_NAME: fabledcurator_test
SECRET_KEY: ci_integration_placeholder
services:
postgres:
image: pgvector/pgvector:pg16
env:
POSTGRES_USER: fabledcurator
POSTGRES_PASSWORD: ci_integration
POSTGRES_DB: fabledcurator_test
options: >-
--health-cmd "pg_isready -U fabledcurator"
--health-interval 10s
--health-timeout 5s
--health-retries 10
redis:
image: redis:7-alpine
options: >-
--health-cmd "redis-cli ping"
--health-interval 10s
--health-timeout 5s
--health-retries 10
steps:
- uses: actions/checkout@v4
- name: Integration suite (resolve service IPs, migrate, test)
run: |
set -eux
echo "=== container landscape (diagnostic for filter scoping) ==="
docker ps -a --format '{{.ID}} {{.Image}} -> {{.Names}}'
echo "=== end landscape ==="
PG=$(docker ps --filter "name=integration" --filter "ancestor=pgvector/pgvector:pg16" -q | head -n1)
RD=$(docker ps --filter "name=integration" --filter "ancestor=redis:7-alpine" -q | head -n1)
test -n "$PG" && test -n "$RD"
PG_IP=$(docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' "$PG")
RD_IP=$(docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' "$RD")
test -n "$PG_IP" && test -n "$RD_IP"
export DB_HOST="$PG_IP"
export CELERY_BROKER_URL="redis://$RD_IP:6379/0"
export CELERY_RESULT_BACKEND="redis://$RD_IP:6379/0"
# These steps run under `sh -e`, not bash, so bash's /dev/tcp magic
# path does not exist here — the probe this loop used to run could
# never succeed and simply burned the full 120s on every run, green
# or red, then continued without having established anything. Python
# is in the image and needs no installed package for a socket
# connect, so it is the probe. Exhausting the budget is now a named
# failure rather than a silent fall-through (rule 156): if Postgres
# is genuinely not up, that is what the log should say, instead of
# whatever the first query happens to raise two minutes later.
pg_ready=""
for i in $(seq 1 60); do
if python -c "import socket,sys; s=socket.socket(); s.settimeout(2); sys.exit(0 if s.connect_ex(('$PG_IP', 5432)) == 0 else 1)"; then
pg_ready=1
break
fi
sleep 2
done
if [ -z "$pg_ready" ]; then
echo "postgres at $PG_IP:5432 did not accept a connection within 120s"
exit 1
fi
if command -v uv >/dev/null 2>&1; then
uv pip install --system -r requirements.txt pytest pytest-asyncio
else
pip install -r requirements.txt pytest pytest-asyncio
fi
# Relax durability on the throwaway CI Postgres so the per-test
# TRUNCATE's commit-fsync — the integration teardown's dominant cost
# (~1.5-2s/test, which collapsed the suite from ~13min to ~45s) — is
# skipped. fsync/full_page_writes are sighup GUCs and synchronous_commit
# is user-context, so ALTER SYSTEM + pg_reload_conf() applies them with
# NO restart. Ephemeral DB ⇒ fsync-off is safe. Non-fatal so a perms
# surprise can't red the job; fabledcurator is the postgres image's
# bootstrap superuser.
python -c "import os,psycopg; c=psycopg.connect(host=os.environ['DB_HOST'],port=5432,user=os.environ['DB_USER'],password=os.environ['DB_PASSWORD'],dbname=os.environ['DB_NAME'],autocommit=True); [c.execute(q) for q in ('ALTER SYSTEM SET fsync=off','ALTER SYSTEM SET synchronous_commit=off','ALTER SYSTEM SET full_page_writes=off','SELECT pg_reload_conf()')]; c.close()" || echo 'WARN: durability GUC relax failed (continuing)'
alembic upgrade head
pytest tests/ -v -m integration --durations=15
+87
View File
@@ -0,0 +1,87 @@
name: extension
# Lint + unit tests. The sign-and-publish dance moved into build.yml's
# `sign-extension` job (2026-05-25) — `:latest` now always bundles the XPI
# because sign-extension runs as a build-web dependency in the SAME workflow,
# eliminating the prior race between build.yml and a separate extension.yml.
# Signed XPIs are cached in Forgejo Release Assets named `ext-<version>`.
on:
push:
branches: [dev, main]
paths:
- 'extension/**'
- '.forgejo/workflows/extension.yml'
# test/version.spec.js asserts things ABOUT the other two workflows —
# that neither inlines the packaged-file set, and that build.yml derives
# the shipped version rather than reading it out of the repo. A
# workflow-only edit can therefore break this suite, so it has to trigger
# it. build.yml joined the list at milestone 271 step 5, when the spec
# started asserting against it.
- '.forgejo/workflows/ci.yml'
- '.forgejo/workflows/build.yml'
pull_request:
branches: [main]
paths:
- 'extension/**'
- '.forgejo/workflows/ci.yml'
- '.forgejo/workflows/build.yml'
workflow_dispatch:
jobs:
lint:
runs-on: python-ci
container:
image: node:24-bookworm-slim
steps:
- uses: actions/checkout@v4
# Not --no-save: vitest and web-ext are both real devDependencies now,
# and the suite needs vitest resolvable from node_modules.
- name: Install dev dependencies
run: cd extension && npm install --no-audit --no-fund
- name: Lint
run: cd extension && npm run lint
# Pure-logic specs over lib/url.js and lib/platforms.js plus manifest /
# package version-consistency checks. No browser, no network.
- name: Unit tests
run: cd extension && npm run test:unit
# Everything else about packaging is asserted against our own declaration
# of what ships. This is the only check that asks web-ext what it ACTUALLY
# put in the archive. Until now that was an unverified assumption about
# glob semantics — and a fragile one: `test/**` reaches web-ext intact
# only because callers `set -f` first, so losing that quoting would
# silently start shipping dev files with no other signal.
- name: Verify XPI contents
run: |
set -eu
command -v unzip >/dev/null 2>&1 || { apt-get update -qq && apt-get install -y -qq unzip; }
cd extension
npm run build
ZIP=$(ls web-ext-artifacts/*.zip | head -1)
echo "=== packaged entries in $ZIP ==="
unzip -Z1 "$ZIP" | sort
echo "=== end ==="
ENTRIES=$(unzip -Z1 "$ZIP")
fail=0
# Must NOT ship: repo infrastructure with no business in a user's browser.
for pat in 'test/' 'scripts/' 'vitest.config.js' 'package.json' 'package-lock.json' 'README.md' 'node_modules/' 'web-ext-artifacts/'; do
if echo "$ENTRIES" | grep -q "^$pat"; then
echo "ERROR: '$pat' was packaged into the XPI but must not be"
fail=1
fi
done
# Must ship: if an exclusion pattern ever over-matches, the extension
# breaks at runtime rather than at build time, so assert presence too.
for req in 'manifest.json' 'lib/url.js' 'lib/api.js' 'lib/platforms.js' 'lib/cookies.js'; do
if ! echo "$ENTRIES" | grep -q "^$req$"; then
echo "ERROR: '$req' is missing from the XPI"
fail=1
fi
done
for dir in 'background/' 'popup/' 'options/' 'content/' 'icons/'; do
if ! echo "$ENTRIES" | grep -q "^$dir"; then
echo "ERROR: nothing from '$dir' was packaged"
fail=1
fi
done
[ "$fail" -eq 0 ] || exit 1
echo "XPI contents verified."
-19
View File
@@ -70,22 +70,3 @@ alembic/versions/__pycache__/
*.sqlite
*.sqlite-journal
.superpowers/
# Raw platform captures (milestone 387 C0 and successors). These are real
# authenticated API responses taken from the operator's own account, so they
# carry account data — creator lists, pledge amounts, and (in Patreon's case)
# the account email inside the `card` resources. They are kept locally because
# re-capturing means re-authenticating by hand, and they are the ground truth a
# characterization gets re-checked against.
#
# The whole directory is ignored, not one filename, so a future capture is
# covered by this rule instead of needing a new line somebody has to remember.
#
# SANITIZED fixtures derived from these DO belong in git — put them somewhere
# else (tests/fixtures/, not here), with the account data stripped.
# Ignore the CONTENTS, not the directory: git does not descend into an
# excluded directory, so a negation for a file inside one never takes effect.
# Writing it this way lets README.md be committed while everything else here
# stays out.
tests/fixtures/captures/*
!tests/fixtures/captures/README.md
+3 -103
View File
@@ -28,10 +28,6 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
postgresql-client \
zstd \
megatools \
# PID 1 for every role. See the ENTRYPOINT note at the foot of this file:
# without it the image needs `init: true` in whatever runs it, which is a
# deployment remembering a flag for the image to behave correctly.
tini \
libjpeg62-turbo \
libwebp7 \
libpng16-16 \
@@ -40,59 +36,9 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
WORKDIR /app
COPY requirements.txt requirements-ml.txt ./
COPY requirements.txt ./
RUN pip install -r requirements.txt
# --- ML, merged from Dockerfile.ml (milestone 422 step 6) --------------------
#
# ONE image now serves every lane. It was two because the ML lane ran in its
# own container; with the single-container layout (step 5) running every lane
# in one process tree, a second image would mean the `ml` lane could never be
# enabled from the UI — there would be no worker in this container to enable.
#
# THE COST, MEASURED from run 7273 rather than guessed — and it is far
# smaller than the estimate this comment first carried, which said "everyone
# pulls ~4GB":
#
# torch 2.12.1+cpu wheel 192.3 MB
# torchvision 0.27.1+cpu 1.8 MB
# transformers / onnxruntime / opencv / sklearn and friends (opencv and
# onnxruntime since dropped, #1451 — nothing here imported them)
# 62.0, 35.3, 23.6, 16.7, 12.3, 9.2, 6.9 MB
# largest newly-pushed layer 222.07 MB
#
# So the ML code adds a few hundred MB to the pull, not gigabytes. The CPU
# index is what makes that true: the default PyPI torch wheel bundles the
# NVIDIA CUDA runtime and is ~2GB on its own.
#
# The GIGABYTES are in the MODEL — ~3.5GB of SigLIP weights — and those are
# NOT in this image. They arrive only when the operator enables the lane,
# which is what lets rule 164 permit a runtime fetch at all ("optional and
# clearly off"). That also settles the trade this step was asked to weigh:
# baking the weights in would add ~3.5GB to every pull for a feature many
# adopters never enable, against ~350MB for the code that makes the switch
# available. Off-by-default wins by an order of magnitude, which was NOT
# obvious before measuring — the estimate had the two costs within 15% of
# each other.
#
# `--index-url`, not `--extra-index-url`: the latter would let pip resolve a
# +cu wheel anyway, and the whole saving above depends on it not doing that.
#
# CPU-only torch from the PyTorch CPU index. Nothing here uses a GPU — the
# GPU agent is a separate service with its own image.
RUN pip install --index-url https://download.pytorch.org/whl/cpu \
"torch>=2.14" "torchvision>=0.29"
RUN pip install -r requirements-ml.txt
# Where the model lands. Deliberately NOT a VOLUME instruction: that mints an
# anonymous volume when nobody mounts one, which survives `docker rm` and
# accumulates 3.5GB copies nobody can find. The compose files mount it
# explicitly instead, so an unmounted run simply re-downloads — visible, and
# recoverable.
ENV HF_HOME=/models/.huggingface \
TRANSFORMERS_CACHE=/models/.huggingface \
ML_MODEL_DIR=/models
COPY backend/ ./backend/
COPY alembic/ ./alembic/
COPY alembic.ini ./
@@ -126,51 +72,5 @@ ENV FC_VERSION=${FC_VERSION}
EXPOSE 8080
# ONE healthcheck for every role, because the image knows which role it is
# running and a deployment should not have to repeat it. `healthcheck` reads
# the role entrypoint.sh recorded and asks the right question: HTTP for web,
# a self-addressed celery ping for a worker lane, both-for-every-lane for the
# consolidated `all`.
#
# start-period covers the SLOWEST role, which is `all`: alembic, then
# hypercorn, then four celery workers registering with the broker. A web-only
# container is ready long before this; the cost of the shared number is that
# a broken one takes a little longer to be called broken.
#
# A service may still declare its own healthcheck and docker will prefer it —
# the escape hatch for a deployment that wants something different.
HEALTHCHECK --interval=30s --timeout=15s --start-period=90s --retries=3 \
CMD ["python", "-m", "backend.app.scripts.healthcheck"]
# tini is PID 1, and the image brings its own rather than asking the
# deployment for one.
#
# 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, ffmpeg, pg_dump, the external fetchers) 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. The fix
# was `init: true` in the compose/stack file, which is out of the norm and
# put correct process handling in the hands of whoever deploys the image —
# the same mistake as declaring the healthcheck per service. A flag that is
# silently dropped (an older Swarm, a `docker run` without it) costs reaping
# with no signal at all.
#
# So the image owns it. `docker run <image>` is correct on its own, and
# nothing downstream has to know. The smoke asserts /proc/1/comm is tini.
ENTRYPOINT ["/usr/bin/tini", "--", "./entrypoint.sh"]
# The DEFAULT is the whole application, not one lane of it.
#
# `docker run fabledcurator` with no command starts hypercorn plus every
# worker lane under supervisord — the shape an adopter wants and the shape the
# consolidated stack runs. It was `web`, which meant the single-container
# layout only worked if you knew to ask for it by name, and a compose file
# that forgot `command:` got a web server with nothing processing its queues:
# a gallery that loads, accepts an import, and never finishes one.
#
# The multi-service stack is unaffected — every service there names its role
# explicitly, which is exactly what makes it the multi-service stack.
CMD ["all"]
ENTRYPOINT ["./entrypoint.sh"]
CMD ["web"]
+43
View File
@@ -0,0 +1,43 @@
# syntax=docker/dockerfile:1.25
FROM python:3.14-slim
ENV PYTHONUNBUFFERED=1 \
PYTHONDONTWRITEBYTECODE=1 \
PIP_NO_CACHE_DIR=1 \
PIP_DISABLE_PIP_VERSION_CHECK=1 \
HF_HOME=/models/.huggingface \
TRANSFORMERS_CACHE=/models/.huggingface \
ML_MODEL_DIR=/models
RUN apt-get update && apt-get install -y --no-install-recommends \
ffmpeg \
libpq5 \
libjpeg62-turbo \
libwebp7 \
libpng16-16 \
libgl1 \
libglib2.0-0 \
ca-certificates \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY requirements-ml.txt requirements.txt ./
# CPU-only torch: the default PyPI wheel bundles the CUDA runtime (~5.6GB
# layer); this pipeline never uses a GPU. --index-url (not --extra-index-url)
# guarantees only +cpu wheels are considered, so no nvidia-*-cu12 deps.
RUN pip install --index-url https://download.pytorch.org/whl/cpu \
"torch>=2.12,<3.0" "torchvision>=0.27,<0.28"
RUN pip install -r requirements-ml.txt
COPY backend/ ./backend/
COPY alembic/ ./alembic/
COPY alembic.ini ./
COPY entrypoint.sh ./
RUN chmod +x entrypoint.sh
# Models self-heal into /models on first start (FC-2 implements this)
VOLUME ["/models"]
ENTRYPOINT ["./entrypoint.sh"]
CMD ["ml-worker"]
+24 -88
View File
@@ -1,5 +1,3 @@
<img src="frontend/public/logo.svg" alt="" width="132" align="right" />
# FabledCurator
<!-- overview:start -->
@@ -13,17 +11,15 @@ tags it, and gives you something better than a folder full of images to look
through afterwards.
- **Gallery and browsing.** Images, videos and multi-page works, organised by
artist, tag, post and series. A newest-first feed of what just arrived as the
front page, a random Showcase, a filterable gallery, a similarity-driven
Explore view, and a page-turning reader for series.
- **Subscriptions.** Follows creators on Patreon, SubscribeStar, Discord and
HentaiFoundry, on a schedule. Handles paywalled posts using your own
logged-in session.
artist, tag, post and series. A Showcase front page, a filterable gallery, a
similarity-driven Explore view, and a page-turning reader for series.
- **Subscriptions.** Follows creators on Patreon, SubscribeStar, Pixiv and
anything `gallery-dl` supports, on a schedule. Handles paywalled posts using
your own logged-in session.
- **ML tagging.** Runs image models in-container to suggest tags, group
characters, find near-duplicates and power similarity search. Suggestions are
reviewable — it proposes, you confirm, and it learns which proposals you keep
rejecting. It ships switched off: turn it on under Settings → System when
you want it, and it fetches its model weights then.
rejecting.
- **Deduplication and provenance.** Everything that arrives is hashed and
deduplicated by content, metadata sidecars are read wherever the source
writes them, and every file keeps a record of where it came from.
@@ -40,8 +36,8 @@ is no config file to edit beyond a handful of bootstrap environment variables.
permission model. Anything that can reach the port is an administrator.
That matters more here than it would in most self-hosted apps, because of what
this one stores: **live platform session cookies for Patreon and
SubscribeStar** — accounts that usually have a payment method attached. Whoever reaches
this one stores: **live platform session cookies for Patreon, SubscribeStar and
Pixiv** — accounts that usually have a payment method attached. Whoever reaches
the port can read them, alongside your entire library.
So:
@@ -114,19 +110,15 @@ that cannot decrypt anything, leaving an instance that looks healthy while every
paywalled download fails. Making you say so once, on an empty install, is the
price of that not happening silently later.
**Your account logins are sealed with that key, and no backup carries either.**
FabledCurator's backups (below) leave the key and the logins out on purpose, so
a stolen backup holds no way into your accounts. After a restore, sign in to
each account again through the browser extension.
**Which means: back up `./images/secrets/` alongside your database.** It is the
only thing that can read your stored credentials. A database restored without it
needs every credential entered again by hand.
A few other things are worth knowing about the first few minutes:
- **ML tagging starts switched off, and nothing is downloaded at boot.** Give
the ML lane a slot under **Settings → System** and it fetches its model
weights then — several GB from HuggingFace into `./models`, shown as a job
under **Settings → Activity** that you can watch and retry. Until it
finishes, tagging is queued rather than broken, and the fetch only takes
what is missing, so turning the lane off and on again does not refetch.
- **The ML worker downloads its model weights on first boot**, several GB from
HuggingFace into `./models`. Until that finishes, tagging is queued rather
than broken. It is idempotent — a restart resumes rather than refetches.
- **The gallery starts empty**, and that is the expected state. Add a creator
under **Subscriptions** and it fills as posts come down.
- **If you already have a library on disk**, there is no screen that imports
@@ -178,52 +170,6 @@ Run it for a burst of tagging, stop it to get your card back. It deploys from
`agent/docker-compose.yml`, not the main stack — see
[agent/README.md](agent/README.md).
## Backing up and restoring
Backups live under **Settings → Maintenance → Backups**. With nothing else set
up, the database is dumped nightly into `./images/_backups`, which is on the
same disk as what it protects. Images and attachments are backed up only to an
off-site destination.
To keep a copy off the machine, set up an off-site destination on the same
screen: any S3 bucket (Fabled Archives, MinIO, AWS) and a key that may add and
read objects. Each night FabledCurator then sends the database as a dated dump
under `database/`, and every image and attachment once, named by its
fingerprint, under `media/`. It never deletes from the bucket; the bucket's own
retention thins old dumps. The first copy of a large library takes hours; a
restart along the way carries on where it stopped rather than starting over.
Once a week the bucket is checked against what was sent, and anything missing
goes again in the next run. When the newest database backup is more than a day
old or failed, or the check found files missing, the **Backups** row on
**Settings → System** and the dot beside the brand turn amber and say why.
**Test a backup** proves a dump restores without touching your install: the dump
is loaded into a separate, temporary database on the same Postgres server,
every table is checked against the row counts recorded as the dump was taken,
the copy is brought up to this version, and then it is deleted. It needs about
as much free space on the database's disk as the database itself, while it
runs. A dump taken before counts were recorded is shown beside the live
database instead.
To rebuild after losing the server, start a fresh install (first boot as
above), enter the same destination, and under **Restore from the bucket** choose
a dump. The database is replaced, then every file is fetched back to its place
and checked against its fingerprint as it arrives; progress shows on the same
screen. **Fetch missing files** does only the second half, for a library that
lost files but kept its database.
If the web page isn't reachable, the same restore runs from a shell:
```bash
docker compose exec web python -m backend.app.scripts.restore list
docker compose exec web python -m backend.app.scripts.restore database --dump latest --yes
```
On an install with no destination saved yet, add `--endpoint`, `--bucket` and
`--access-key`, and put the secret key in `FC_BACKUP_SECRET_KEY` (or type it at
the prompt).
## Upgrading
```bash
@@ -296,9 +242,9 @@ reasoning is note #3127 §5). Rolling back is `docker pull …:c-<sha>`.
Each artifact still has a version, derived rather than chosen: the commit time
of the newest change to that artifact's *own* shipped files, as
`YYYY.MM.DD.HHMM` UTC (rule 148). Three artifacts, three independent versions
— a push touching only `agent/` re-versions the agent and leaves web and the
extension alone, and CI skips the builds whose content did not move.
`YYYY.MM.DD.HHMM` UTC (rule 148). Four artifacts, four independent versions —
a push touching only `agent/` re-versions the agent and leaves web and ml
alone, and CI skips the builds whose content did not move.
Because no registry name carries it, the running instance's own report is the
only answer to "which build is this?". The foot of Settings shows
@@ -311,32 +257,22 @@ commits since the previous tag; it builds no image.
## What's in here
Four deployable pieces, built by `.forgejo/workflows/build.yml`:
Five deployable pieces, built by `.forgejo/workflows/build.yml`:
| Piece | Built from | Image | Role |
| --- | --- | --- | --- |
| **Web / workers** | `Dockerfile` | `fabledcurator` | Quart API + the built Vue SPA in one image. `entrypoint.sh` picks the role: `web`, `worker`, `scheduler`, `ml-worker`, or `all` (every lane under supervisord, the single-container layout). The `maintenance-long` service is a second `worker` pinned to the long-running maintenance queue. |
| **Web / workers** | `Dockerfile` | `fabledcurator` | Quart API + the built Vue SPA in one image. `entrypoint.sh` picks the role: `web`, `worker`, `scheduler`. The `maintenance-long` service is a second `worker` pinned to the long-running maintenance queue. |
| **ML worker** | `Dockerfile.ml` | `fabledcurator-ml` | Same app, plus `requirements-ml.txt` — tagging and embedding models that run in-container. |
| **GPU agent** | `agent/Dockerfile` | `fabledcurator-agent` | Optional desktop-GPU worker (`agent/`). Leases jobs over **HTTP only** — never touches the database or Redis. See `agent/README.md`. |
| **Firefox extension** | `extension/` | signed XPI | MV3 extension: pushes platform session cookies into FC and adds a creator as a Source in one click. AMO-signed on both `dev` and `main` (one signature per extension change, shared by the two channels), bundled into that channel's web image and served from Settings → Maintenance. See `extension/README.md`. |
| **Data** | — | `pgvector/pgvector:pg16`, `redis:7-alpine` | Postgres with pgvector for embeddings; Redis as the Celery broker. |
## CI / Forgejo setup
Two workflows that matter here: `build.yml` (the six verification lanes — lint,
extension-version check, backend unit tests, frontend build, extension lint +
vitest + XPI content check, integration — and then sign + publish), and
`release.yml`, which runs only on a `v*` tag and publishes a changelog without
building anything. The extension lane was its own `extension.yml` until
milestone 429, which let a red extension suite sign and ship the XPI anyway.
**The lanes and the publish are one workflow on purpose.** They were two
(`ci.yml` and `build.yml`) until 2026-09-23, on the same push trigger, which
meant the build could not see the tests' verdict and published whatever it
built — a red unit lane and a fresh `:dev` image, in the same minute. A
`needs:` edge only exists inside one workflow graph, so the two are one graph
and the gate is that edge: a lane that fails, **or that merely skips**, leaves
the publishing jobs unrun. Pull-request runs (Renovate bumps into `dev`) are
the lanes and nothing else.
Four workflows: `ci.yml` (lint, extension-version check, backend unit tests,
frontend build, integration), `extension.yml` (extension lint, vitest, XPI
content verification), `build.yml` (sign + publish), and `release.yml`, which
runs only on a `v*` tag and publishes a changelog without building anything.
**The toolchain each job runs in is its `container.image`, not its `runs-on`
label.** `runs-on: python-ci` only schedules the job onto a runner; every job
+2 -2
View File
@@ -26,7 +26,7 @@ FabledCurator is self-hosted and holds things worth stating plainly, because
they shape what counts as a serious bug here:
- **Platform credentials.** The app captures and stores session cookies for
third-party subscription sites (Patreon, SubscribeStar) so it can
third-party subscription sites (Patreon, SubscribeStar, Pixiv) so it can
download on the operator's behalf. These are live credentials for accounts
that usually carry a payment method. Anything that discloses them, decrypts
them, or lets one user of a shared instance read another's is high severity.
@@ -56,7 +56,7 @@ reverse proxy. It also does not authenticate anyone — see above. These are
documented design decisions, not oversights.
Putting this on the public internet, with or without TLS, hands whoever finds
it your Patreon and SubscribeStar sessions. A reverse proxy that adds
it your Patreon, SubscribeStar and Pixiv sessions. A reverse proxy that adds
TLS but not an authentication layer does not change that.
Reports that reduce to "the application is served over HTTP", "there is no
+10 -40
View File
@@ -1,21 +1,10 @@
# FabledCurator GPU agent — runs on the desktop with the GPU.
#
# The `base` flavour, not `cudnn-runtime`: CUDA and cuDNN arrive as the
# `nvidia-*` pip packages torch and onnxruntime-gpu depend on, so the base only
# has to hand the container the driver (it sets NVIDIA_VISIBLE_DEVICES /
# NVIDIA_DRIVER_CAPABILITIES for the Container Toolkit). Until #1451 this was
# `12.9.2-cudnn-runtime` under a `torch==2.6.0+cu124` — and requirements.txt then
# REPLACED that torch with PyPI's CUDA-13 build (ultralytics pulls torchvision,
# which pulls its matching torch), beside a CUDA-13 onnxruntime-gpu. The image
# ran CUDA 13 on a CUDA-12 base, carrying ~3 GB of base libraries and a ~3 GB
# torch nothing loaded: 10 GB compressed.
#
# 13.0 because that is the line both wheels are built for (torch's cu130 index,
# onnxruntime-gpu's `nvidia-cuda-runtime~=13.0`). Needs an NVIDIA driver that
# supports CUDA 13 (580+); fc_agent/accel.py logs at startup whether torch and
# onnxruntime actually got the GPU, since both fall back to the CPU silently.
# ffmpeg for video frames. Ubuntu 24.04 → Python 3.12.
FROM nvidia/cuda:13.0.3-base-ubuntu24.04
# CUDA 12.9 + cuDNN 9 runtime so onnxruntime-gpu can use the card (it needs
# cuDNN 9 — the plain -runtime image lacks it: "libcudnn.so.9: cannot open
# shared object file"); ffmpeg for video frames. Ubuntu 24.04 → Python 3.12.
# Stays on the CUDA-12 / cuDNN-9 line the default onnxruntime-gpu + torch are
# built against (CUDA 13 has only nascent ONNX Runtime support).
FROM nvidia/cuda:12.9.2-cudnn-runtime-ubuntu24.04
# PIP_BREAK_SYSTEM_PACKAGES: Ubuntu 24.04 marks its system Python as externally
# managed (PEP 668), so a global `pip install` errors without this. It's a
@@ -27,12 +16,10 @@ RUN apt-get update \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
# torch AND torchvision from the cu130 index, together and first. Installing
# torch alone is what let the next step swap it out: ultralytics needs
# torchvision, PyPI's torchvision pins its own torch, and pip replaced ours to
# match. With both present, requirements.txt finds them satisfied.
RUN pip3 install --no-cache-dir --index-url https://download.pytorch.org/whl/cu130 \
torch torchvision
# torch from the CUDA-12.4 wheel index; its wheels bundle their own CUDA + cuDNN
# so they run on the 12.9 base and coexist with onnxruntime-gpu. Installed first
# + separately so the GPU build of torch is deterministic and layer-cached.
RUN pip3 install --no-cache-dir torch==2.6.0 --index-url https://download.pytorch.org/whl/cu124
COPY requirements.txt .
RUN pip3 install --no-cache-dir -r requirements.txt
COPY fc_agent ./fc_agent
@@ -40,23 +27,6 @@ COPY fc_agent ./fc_agent
# imgutils ONNX models + the transformers SigLIP weights both cache here; mount
# a volume to persist them across restarts (the SigLIP download is ~3.5 GB once).
ENV HF_HOME=/models
# Declared LAST on purpose, exactly as the web Dockerfile does: an ARG/ENV
# invalidates every layer below it, and these are the only values that differ
# between builds of otherwise identical source. Any earlier and the ~6.3 GB
# CUDA + torch layers could never be shared between the dev and main builds of
# one commit — which is the cost #3114 measured at 9m26s cold.
#
# Three values, never folded together (rule 149) — the NAME a person reads, the
# CHANNEL it came from, and the REVISION that identifies the content. See
# fc_agent/build_info.py; CI derives all three from scripts/artifacts.sh.
ARG FC_CHANNEL=""
ENV FC_CHANNEL=${FC_CHANNEL}
ARG FC_VERSION=""
ENV FC_VERSION=${FC_VERSION}
ARG FC_REVISION=""
ENV FC_REVISION=${FC_REVISION}
EXPOSE 8770
# The control UI; the worker is started from it (or POST /start).
+2 -17
View File
@@ -15,28 +15,13 @@ sudo pacman -S nvidia-container-toolkit
sudo nvidia-ctk runtime configure --runtime=docker
sudo systemctl restart docker
# verify:
docker run --rm --gpus all nvidia/cuda:13.0.3-base-ubuntu24.04 nvidia-smi
# the header's CUDA version must be 13.0 or later (driver 580+)
```
### After a driver update: regenerate the CDI spec
If the agent's first log lines say `accel: torch is NOT on the GPU` or report
`cudaGetDeviceCount: unknown error (999)` while `nvidia-smi` still works, the
toolkit's saved device list (`/etc/cdi/nvidia.yaml`) is out of date. The
`nvidia-uvm` device number changes between driver versions, and a spec
generated before the update hands the container a device node that no longer
exists (2026-09-24: host `511,0`, container `235,0`). Compare
`ls -l /dev/nvidia-uvm` on the host with the same inside the container, then:
```sh
sudo nvidia-ctk cdi generate --output=/etc/cdi/nvidia.yaml
# if your toolkit ships it, this keeps it current on every driver update:
sudo systemctl enable --now nvidia-cdi-refresh.path
docker run --rm --gpus all nvidia/cuda:12.4.1-base-ubuntu22.04 nvidia-smi
```
## 1. Get a token
In FC: **Settings → Tagging → GPU agent → Generate token** (or Rotate). Copy it.
## 2. Pull (CI publishes it alongside the web image)
## 2. Pull (CI publishes it alongside the web/ml images)
```sh
docker pull git.fabledsword.com/bvandeusen/fabledcurator-agent:latest
```
-124
View File
@@ -1,124 +0,0 @@
"""Which accelerator each runtime actually got — reported once, at startup.
The agent has two GPU runtimes and both fall back to the CPU without raising:
torch when the driver is too old for its CUDA build, and onnxruntime (the imgutils
detector + CCIP models) when its CUDA provider cannot load its libraries. A
fallback shows up only as slower work, and nothing reported it. On 2026-09-24 the
image turned out to be running a CUDA-13 torch and onnxruntime on a CUDA-12 base
(#1451), and whether the ONNX half was on the GPU could not be answered from
anything the agent had ever logged.
Also the fix for the likeliest way the ONNX half misses: onnxruntime-gpu's CUDA
provider finds libcudart/cuBLAS/cuDNN only on the loader path, and in this image
they live in the `nvidia-*` pip packages torch installs. `preload_dlls()` (ORT
1.21+) loads them from there, so the provider resolves them by soname.
Stdlib-only at import, so the unit suite can import it — torch and onnxruntime
are imported inside the functions.
"""
from __future__ import annotations
import ctypes
import importlib
import logging
from pathlib import Path
log = logging.getLogger("fc_agent.accel")
# Filled by report(); /status carries it so the page can show it too.
LAST: dict = {}
def torch_status(imp=importlib.import_module) -> dict:
try:
torch = imp("torch")
except Exception as e:
return {"device": "unavailable", "error": str(e)}
out = {"version": torch.__version__, "cuda_build": torch.version.cuda}
if torch.cuda.is_available():
out["device"] = "cuda"
out["gpu"] = torch.cuda.get_device_name(0)
else:
out["device"] = "cpu"
return out
def onnx_status(imp=importlib.import_module, load=ctypes.CDLL) -> dict:
try:
ort = imp("onnxruntime")
except Exception as e:
return {"device": "unavailable", "error": str(e)}
out = {"version": ort.__version__, "providers": list(ort.get_available_providers())}
if "CUDAExecutionProvider" not in out["providers"]:
out["device"] = "cpu"
return out
preload = getattr(ort, "preload_dlls", None)
if preload is not None:
try:
preload()
except Exception as e:
out["preload_error"] = str(e)
# "Available" only means the build HAS the provider. Loading its library is
# what resolves libcudart/cuBLAS/cuDNN — the step that fails when they are
# missing, and the one a session would otherwise fail silently on.
capi = Path(ort.__file__).parent / "capi"
try:
load(str(capi / "libonnxruntime_providers_shared.so"), mode=ctypes.RTLD_GLOBAL)
load(str(capi / "libonnxruntime_providers_cuda.so"))
except OSError as e:
out["device"] = "cpu"
out["error"] = str(e)
return out
# Loading proves the libraries resolve, NOT that a GPU can be used: on
# 2026-09-24 this reported "onnx on GPU" beside torch failing cuInit with
# "CUDA unknown error" (a driver update awaiting a reboot). Asking the CUDA
# runtime for a device initialises the driver the provider would use.
error = _cuda_device_error(load)
out["device"] = "cpu" if error else "cuda"
if error:
out["error"] = error
return out
def _cuda_device_error(load=ctypes.CDLL) -> str | None:
"""None when the CUDA runtime can reach a device, else why it cannot."""
try:
cudart = load("libcudart.so.13")
except OSError as e:
return str(e)
count = ctypes.c_int(0)
rc = cudart.cudaGetDeviceCount(ctypes.byref(count))
if rc != 0:
cudart.cudaGetErrorString.restype = ctypes.c_char_p
return f"cudaGetDeviceCount: {cudart.cudaGetErrorString(rc).decode()} ({rc})"
return None if count.value > 0 else "no CUDA device visible"
def summary() -> dict | None:
"""The report as FabledCurator stores it: each runtime's device, and why
when it is not the GPU. Sent on every lease and heartbeat, so the System
view can call a running agent that fell back to the CPU "degraded" rather
than "running" — the 2026-09-24 fallback went unseen for weeks because
only this agent's own log said so. None before report() has run."""
if not LAST:
return None
out = {}
for name, s in LAST.items():
entry = {"device": s.get("device")}
if s.get("error"):
entry["error"] = str(s["error"])[:200]
out[name] = entry
return out
def report() -> dict:
"""Check both runtimes, log the result, and keep it for /status."""
LAST.clear()
LAST.update(torch=torch_status(), onnx=onnx_status())
for name, s in LAST.items():
if s.get("device") == "cuda":
log.info("accel: %s on GPU (%s)", name, s)
else:
log.warning("accel: %s is NOT on the GPU — work runs on the CPU (%s)", name, s)
return dict(LAST)
+11 -55
View File
@@ -11,22 +11,17 @@ import logging
from fastapi import FastAPI, Request
from fastapi.responses import HTMLResponse, JSONResponse
from . import accel, logbuf
from .build_info import FC_CHANNEL, FC_REVISION, FC_VERSION, build_id, display_version
from . import logbuf
from .config import Config
from .gpu import read_gpu
from .worker import Worker
log = logging.getLogger("fc_agent.app")
# DERIVED at image build time, not hand-maintained — see build_info. This was a
# literal an author was asked to bump on every agent change, and the September
# image printed the same "2026-07-17.1" as the July one, so the surface meant to
# answer "did my pull work?" answered the same either way.
#
# Two values with two jobs, kept apart (rule 149): the page SHOWS the version
# and COMPARES the build id. /status reports both, plus the raw fields, so a
# reader never has to take a formatted string apart to get at one of them.
# Bump on every agent change. The page embeds this and /status reports it; the UI
# warns to reload when they differ — so a stale browser-cached page can't be
# mistaken for "the new image didn't deploy". (Belt-and-braces with no-store.)
VERSION = "2026-07-17.1 · idle model-unload: after ~5 min idle the GPU models release their VRAM and reload on the next job (env IDLE_UNLOAD_SECONDS, 0=off) · sleep mode sheds to one downloader"
logbuf.install()
cfg = Config.from_env()
@@ -47,9 +42,6 @@ async def _no_store(request, call_next):
@app.on_event("startup")
def _maybe_autostart() -> None:
# Before the worker: the report also preloads the CUDA libraries the ONNX
# models need, and it says in the log which runtimes landed on the GPU.
accel.report()
# With AUTO_START set, a container restart (host reboot, or `restart:
# unless-stopped` after a crash) resumes the worker on its own — the slots
# then ride out a still-down curator via lease backoff. Lets the agent
@@ -60,14 +52,7 @@ def _maybe_autostart() -> None:
@app.get("/", response_class=HTMLResponse)
def index() -> str:
# Two substitutions, not one: `__VERSION__` is what a person reads in the
# meta line, `__BUILD_ID__` is what the script compares against /status to
# notice the page is a cached copy from a previous build.
return (
_PAGE
.replace("__VERSION__", display_version())
.replace("__BUILD_ID__", build_id())
)
return _PAGE.replace("__BUILD__", VERSION)
@app.post("/start")
@@ -132,15 +117,7 @@ def status():
s["fc_url"] = cfg.fc_url
s["configured"] = bool(cfg.token)
s["queue"] = worker.latest_queue()
# `build` is the comparison token the page checks — see build_info.
# `version`/`channel`/`revision` ride BESIDE it rather than inside it, so a
# reader wanting the version never has to parse it back out of something
# else. Absent rather than empty when the image carries no stamp.
s["build"] = build_id()
s["version"] = FC_VERSION or None
s["channel"] = FC_CHANNEL or None
s["revision"] = FC_REVISION or None
s["accel"] = accel.LAST or None
s["build"] = VERSION
return JSONResponse(s)
@@ -192,11 +169,7 @@ _PAGE = """<!doctype html><html><head><meta charset=utf-8>
width:30px;height:32px;font:700 16px system-ui;cursor:pointer}
.step:hover{border-color:var(--acc)}
#conc,#bw{width:3.4rem;height:32px;text-align:center;font:700 16px system-ui;background:#11151a;
color:var(--fg);border:1px solid var(--bd);border-radius:8px;appearance:textfield;-moz-appearance:textfield}
/* The browser's own spin arrows, hidden: the − / + beside each field are the
control, styled like the rest of the page (operator, 2026-09-24). */
#conc::-webkit-inner-spin-button,#conc::-webkit-outer-spin-button,
#bw::-webkit-inner-spin-button,#bw::-webkit-outer-spin-button{-webkit-appearance:none;margin:0}
color:var(--fg);border:1px solid var(--bd);border-radius:8px}
.unit{color:var(--mut);font-size:12px;font-weight:600}
.hint{color:var(--mut);font-size:12px;margin-top:12px}
.tiles{display:grid;grid-template-columns:repeat(6,1fr);gap:8px;margin-bottom:16px}
@@ -230,12 +203,11 @@ _PAGE = """<!doctype html><html><head><meta charset=utf-8>
<div class=brand><span class=logo>◆</span> FabledCurator <span class=sub>GPU agent</span></div>
<div class=conn><span class="dot" id=dot></span><span id=connlbl>—</span></div>
</header>
<p class=meta>Server <code id=fc>—</code> · token <code id=cfg>—</code> · build <code id=build>__VERSION__</code></p>
<p class=meta>Server <code id=fc>—</code> · token <code id=cfg>—</code> · build <code id=build>__BUILD__</code></p>
<div id=verbanner class=banner style="display:none;background:#3a1212;border-color:#5a1717;color:#ffb3b3">
a newer agent version is running — reload this page (Ctrl+Shift+R) to update the controls
</div>
<div id=accelbanner class=banner style="display:none;background:#3a1212;border-color:#5a1717;color:#ffb3b3"></div>
<div id=banner class=banner style=display:none>
curator unreachable — holding work + retrying, resumes on its own (no restart needed)
</div>
@@ -253,9 +225,7 @@ _PAGE = """<!doctype html><html><head><meta charset=utf-8>
<button class=step onclick=setc(1)>+</button>
</div>
<div class=stepper title="aggregate download cap, downloads + video streams combined — 0 = unlimited">
<button class=step onclick=stepbw(-1)>−</button>
<input id=bw type=number min=0 step=1 value=8 onchange="setbw(this.value)">
<button class=step onclick=stepbw(1)>+</button>
<span class=unit>MB/s</span>
</div>
</div>
@@ -292,7 +262,7 @@ _PAGE = """<!doctype html><html><head><meta charset=utf-8>
</section>
</div>
<script>
const PAGE_BUILD="__BUILD_ID__"
const PAGE_BUILD="__BUILD__"
let CAP=8
// Optimistic transitional state on click, then apply the POST's own status
// response (it returns worker.status()) for instant feedback — don't wait on the
@@ -323,14 +293,6 @@ _PAGE = """<!doctype html><html><head><meta charset=utf-8>
await fetch('/auto',{method:'POST',headers:{'Content-Type':'application/json'},
body:JSON.stringify({value:on})});refresh()
}
function stepbw(d){ setbw((parseFloat(bw.value)||0)+d) }
// Runtimes that did NOT get the GPU, from the startup report. Both fall back
// to the CPU without raising, so this banner and the pill are the only place
// on this page a slow, CPU-bound agent announces itself.
function cpuOnly(s){
const a=s.accel||{}
return Object.keys(a).filter(k=>a[k] && a[k].device!=='cuda')
}
async function setbw(v){
v=Math.max(0,parseFloat(v)||0); bw.value=v
await fetch('/bandwidth',{method:'POST',headers:{'Content-Type':'application/json'},
@@ -401,17 +363,11 @@ _PAGE = """<!doctype html><html><head><meta charset=utf-8>
// unreachable curator; grey when stopped; red with no token.
let dc='dot', lbl='stopped'
if(!ok){ dc='dot red'; lbl='no token' }
else if(st==='running'){ dc='dot '+(s.queue?'green':'amber'); lbl=s.queue?'running':'running · curator unreachable'
if(s.queue && cpuOnly(s).length){ dc='dot amber'; lbl='running · CPU only (degraded)' } }
else if(st==='running'){ dc='dot '+(s.queue?'green':'amber'); lbl=s.queue?'running':'running · curator unreachable' }
else if(st==='starting'){ dc='dot amber'; lbl='starting…' }
else if(st==='stopping'){ dc='dot amber'; lbl='stopping…' }
dot.className=dc; connlbl.textContent=lbl
banner.style.display=(st==='running' && !s.queue)?'block':'none'
const slow=cpuOnly(s)
accelbanner.style.display=slow.length?'block':'none'
accelbanner.textContent=slow.length?('degraded — '+slow.join(' + ')+' not on the GPU, so that work runs on the CPU: '
+slow.map(k=>k+': '+(s.accel[k].error||s.accel[k].device)).join(' · ')
+'. After a driver update, regenerate the CDI spec (agent README).'):''
queue.textContent=s.queue?('queue · pending '+s.queue.pending+' · in flight '+s.queue.leased+' · done '+s.queue.done+' · errored '+s.queue.error):'queue · unreachable'
}
}
-79
View File
@@ -1,79 +0,0 @@
"""What this agent build IS — stamped at image build time, not configurable.
The mirror of `backend/app/build_info.py`, for the same reasons and with the
same posture. Kept as its own module rather than as constants in `app.py`
because it is stdlib-only and therefore importable by the test suite, which
cannot import `app` (torch, transformers and ultralytics are not in the CI
image — see build.yml's "Agent syntax check").
## Why this replaced a hand-written string
`app.VERSION` used to be a literal an author was asked to bump, carrying a
version AND a changelog in one string:
VERSION = "2026-07-17.1 · idle model-unload: after ~5 min idle ..."
Nobody bumped it. The September image printed the identical string to the July
one, so the one surface that was supposed to answer *"did my pull work?"*
answered *"2026-07-17.1"* either way. An artifact that cannot identify itself
is worse than one that says nothing, because the stale value reads as an
answer.
The values are now derived by `scripts/artifacts.sh` from the commit its
shipped files last changed in — the same derivation the web image has used
since milestone 313, and the same one the reuse check already ran for the
agent and discarded.
## Three values, never folded together (rule 149)
* `FC_VERSION` — the NAME, `YYYY.MM.DD.HHMM` UTC, derived from COMMIT time.
For people to read and quote. Identical on `dev` and `main` for the same
source, which is the property that makes "am I running the same code as
production?" answerable at a glance.
* `FC_CHANNEL` — a SIBLING field, never a suffix inside the name.
* `FC_REVISION` — the 12-char commit sha, the artifact's IDENTITY. This is
what the reuse check already keys on as the `fc.revision` image label.
**Absent rather than empty when unknown.** A locally built image has no
stamp, and neither does any image predating this module. One spelling of
"cannot say" instead of two.
"""
import os
FC_VERSION = os.environ.get("FC_VERSION", "").strip()
FC_CHANNEL = os.environ.get("FC_CHANNEL", "").strip()
FC_REVISION = os.environ.get("FC_REVISION", "").strip()
def display_version() -> str:
"""The build, as a line for a human: `2026.09.24.1052 (dev)`.
`unknown` rather than a blank when unstamped — an empty slot in the meta
line reads as "no version", which is a different and false claim from "this
build does not carry one".
"""
if not FC_VERSION:
return "unknown"
return f"{FC_VERSION} ({FC_CHANNEL})" if FC_CHANNEL else FC_VERSION
def build_id() -> str:
"""The token the control page compares against `/status` to notice it is
showing a CACHED page from a previous build.
Deliberately NOT `display_version()`. That is the value for reading; this
is the value for deciding, and folding the two is what rule 149 is about.
The revision is the better discriminator of the two — two builds of the
same commit ARE the same agent and should not prompt a reload, and two
different commits always differ here even when they land in the same
minute and derive one version name.
A locally built image falls through to a constant, so the reload banner
cannot fire for it. That is honest rather than a gap: nothing in an
unstamped image knows what source it was built from, and a per-process
nonce would make every ordinary container RESTART claim a new version had
arrived — a false positive on the exact surface the banner exists to keep
trustworthy.
"""
return FC_REVISION or FC_VERSION or "local"
+2 -9
View File
@@ -7,8 +7,6 @@ import requests
from requests.adapters import HTTPAdapter
from urllib3.util.retry import Retry
from . import accel
class FcClient:
def __init__(self, base_url: str, token: str, agent_id: str):
@@ -74,10 +72,7 @@ class FcClient:
def lease(self, batch_size: int) -> list[dict]:
r = self.s.post(
f"{self.base}/api/gpu/jobs/lease",
json={
"agent_id": self.agent_id, "batch_size": batch_size,
"accel": accel.summary(),
},
json={"agent_id": self.agent_id, "batch_size": batch_size},
timeout=30,
)
r.raise_for_status()
@@ -95,9 +90,7 @@ class FcClient:
})
def heartbeat(self, job_ids: list[int]) -> None:
self._post_quiet(
"/api/gpu/jobs/heartbeat", {"job_ids": job_ids, "accel": accel.summary()},
)
self._post_quiet("/api/gpu/jobs/heartbeat", {"job_ids": job_ids})
def fail(self, job_id: int, error: str) -> None:
self._post_quiet("/api/gpu/jobs/fail", {"job_id": job_id, "error": error})
+5 -34
View File
@@ -342,44 +342,15 @@ class Worker:
# --- background loops ---------------------------------------------------
def _heartbeat_loop(self) -> None:
"""Keep every held lease alive, and say we are here even when holding none.
Leases: buffered jobs waiting on the GPU would otherwise be reclaimed by
curator's 180s TTL. Errors are swallowed by client.heartbeat; a reclaimed
lease just re-leases elsewhere — never fatal.
## Why this sends with an EMPTY list rather than skipping
Curator's roster records a check-in on this call (and on `lease`), and
calls an agent stopped after 300s of silence. This loop used to be
gated on `if ids:` — so an agent holding no leases sent nothing at all,
and the only check-in left was the lease poll, which sleep mode backs
off exponentially to a 900s ceiling (see IDLE_POLL_MAX_SECONDS).
900 against 300: an IDLE agent was structurally guaranteed to read as
stopped. 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 — twelve minutes ago, partway up the backoff ladder.
The two halves were written ten weeks apart and never reconciled: sleep
mode landed 2026-07-02, and the roster adopted the lease as its
check-in on 2026-09-02 without noticing the call it was piggybacking on
had been deliberately slowed.
An empty heartbeat 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.
"""
"""Keep every held lease alive so buffered jobs waiting on the GPU aren't
reclaimed by curator's 180s TTL. Errors are swallowed by client.heartbeat;
a reclaimed lease just re-leases elsewhere — never fatal."""
while True:
if self._running:
with self._held_lock:
ids = list(self._held)
self.client.heartbeat(ids)
if ids:
self.client.heartbeat(ids)
time.sleep(HEARTBEAT_INTERVAL)
def _queue_poll_loop(self):
+4 -6
View File
@@ -1,12 +1,10 @@
# CCIP + figure detection (ONNX models, auto-downloaded from HuggingFace).
dghs-imgutils>=0.4
# GPU inference for the ONNX models. Swap to onnxruntime (CPU) for a slow
# server-side fallback run. The extras declare the CUDA/cuDNN pip packages its
# CUDA provider loads (fc_agent/accel.py preloads them) rather than relying on
# torch happening to install the same ones.
onnxruntime-gpu[cuda,cudnn]
# The crop EMBEDDER (concept bag). torch + torchvision are installed separately
# in the Dockerfile from the cu130 wheel index, so pip never swaps them out;
# server-side fallback run.
onnxruntime-gpu
# The crop EMBEDDER (concept bag). torch is installed separately in the
# Dockerfile from the CUDA-12.4 wheel index so the GPU build is deterministic;
# transformers loads whatever SigLIP-family model the server announces.
transformers>=4.45
# Crop PROPOSERS — small YOLO detectors (booru_yolo anatomy, COCO person, comic
@@ -1,81 +0,0 @@
"""platform_membership — the learned roster of what the account actually pays for.
Milestone 387, phase C. FC knows which creators it was told to follow and
nothing about which ones the operator is subscribed to; this table is the
memory that makes the drift in both directions observable. See the model
docstring for why the roster is learned rather than looked up live, and why
`status` holds the platform's own word rather than a normalised FC value.
## Nothing populates this yet, on purpose
The sweep that fills it (C3) depends on a client seam (C2) that depends on
characterising Patreon's real membership response from a captured sample (C0),
which needs the operator's authenticated browser session. The table's SHAPE
does not wait on that: it is deliberately free-form where C0's findings would
otherwise dictate a column — `status` is an unconstrained String and `details`
keeps the raw payload — so no capture can invalidate what is created here.
An empty table is the correct intermediate state. It is not dead code: C5 reads
it to explain a tier-limited source, and C4 reads it to reconcile.
Revision ID: 0091
Revises: 0090
Create Date: 2026-09-10
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "0091"
down_revision: Union[str, None] = "0090"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
"platform_membership",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("platform", sa.String(length=64), nullable=False),
# Text, not a bounded String: an opaque upstream identifier we do not
# mint, and guessing a ceiling for one is how a walk dies on a silent
# truncation.
sa.Column("external_campaign_id", sa.Text(), nullable=False),
sa.Column("display_name", sa.Text(), nullable=True),
sa.Column("url", sa.Text(), nullable=True),
# No CHECK, deliberately (rule 36 considered and declined): the
# vocabulary is each platform's own and is not ours to fix before C0
# has characterised even one of them. The service owns the whitelist.
sa.Column("status", sa.String(length=32), nullable=True),
sa.Column("tier_names", sa.JSON(), nullable=True),
sa.Column("amount_cents", sa.Integer(), nullable=True),
sa.Column("currency", sa.String(length=8), nullable=True),
sa.Column(
"first_seen_at", sa.DateTime(timezone=True),
server_default=sa.text("now()"), nullable=False,
),
sa.Column(
"last_seen_at", sa.DateTime(timezone=True),
server_default=sa.text("now()"), nullable=False,
),
sa.Column("details", sa.JSON(), nullable=False),
sa.PrimaryKeyConstraint("id", name=op.f("pk_platform_membership")),
# The upsert's conflict target. Named explicitly because
# touch_membership references it by name in ON CONFLICT — an
# autogenerated name would make that call break on a rename nobody
# connected to it.
sa.UniqueConstraint(
"platform", "external_campaign_id",
name="uq_platform_membership_platform_campaign",
),
)
# No secondary indexes. This table holds one row per subscription — tens,
# not millions — so every query against it is a short scan and an index
# would be write cost buying nothing (#3301 removed seven of that shape).
# The unique constraint above already backs the only lookup that matters.
def downgrade() -> None:
op.drop_table("platform_membership")
-92
View File
@@ -1,92 +0,0 @@
"""Synthetic posts — FC authors a post for content that arrived as chat.
Milestone 388, step E2. Discord is a delivery channel, not a publisher: one
message is not one post, and today every message becomes its own `post` row
competing with authored work for the same surface. This adds the three columns
that let FC group a creator's variant drop into a post it wrote itself, while
keeping that fact visible and the grouping reversible.
## Why a flag and a back-pointer rather than a separate table
A synthetic post has to BE a post — same row, same columns — or every existing
surface (feed, provenance, translation, attachments, series) would need a
second code path for it. `synthesized_by` marks the ones FC authored;
`absorbed_by_post_id` points a member message-post at the post that replaced
it in the feed. The members are not deleted: they remain the images' true
origin, and destroying them would make the grouping un-auditable at exactly
the moment somebody wants to check it.
Reversal is one DELETE. `absorbed_by_post_id` is ON DELETE SET NULL, so
removing a synthetic post releases its members and they return to the feed
unaided.
Revision ID: 0092
Revises: 0091
Create Date: 2026-09-10
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "0092"
down_revision: Union[str, None] = "0091"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# No CHECK on synthesized_by (rule 36 considered and declined): there is one
# grouper today and a second would be a new VALUE, not a new invariant —
# matching source.error_type and service_seen.kind.
op.add_column("post", sa.Column("synthesized_by", sa.String(length=32), nullable=True))
op.add_column("post", sa.Column("synthesis_details", sa.JSON(), nullable=True))
op.add_column(
"post", sa.Column("absorbed_by_post_id", sa.Integer(), nullable=True),
)
op.create_index(
op.f("ix_post_absorbed_by_post_id"), "post", ["absorbed_by_post_id"],
)
# SET NULL, not CASCADE: deleting the synthetic post must RELEASE its
# members, never take them with it. The members are the real capture.
op.create_foreign_key(
"fk_post_absorbed_by_post_id_post", "post", "post",
["absorbed_by_post_id"], ["id"], ondelete="SET NULL",
)
# Grouping tunables. Every one of these is operator-facing (project rule
# 25) because the quality bar here is a judgement call no test can settle:
# too greedy merges distinct pieces, too shy leaves a drop scattered.
op.add_column(
"ml_settings",
sa.Column(
"discord_grouping_enabled", sa.Boolean(),
server_default="true", nullable=False,
),
)
op.add_column(
"ml_settings",
sa.Column(
"discord_group_max_distance", sa.Float(),
server_default=sa.text("0.10"), nullable=False,
),
)
op.add_column(
"ml_settings",
sa.Column(
"discord_group_window_minutes", sa.Float(),
server_default=sa.text("60"), nullable=False,
),
)
def downgrade() -> None:
op.drop_column("ml_settings", "discord_group_window_minutes")
op.drop_column("ml_settings", "discord_group_max_distance")
op.drop_column("ml_settings", "discord_grouping_enabled")
op.drop_constraint("fk_post_absorbed_by_post_id_post", "post", type_="foreignkey")
op.drop_index(op.f("ix_post_absorbed_by_post_id"), table_name="post")
op.drop_column("post", "absorbed_by_post_id")
op.drop_column("post", "synthesis_details")
op.drop_column("post", "synthesized_by")
-86
View File
@@ -1,86 +0,0 @@
"""An open grouping — a synthetic post that a later drop can still join.
Milestone 388, step E3. E2's synthetic post was sealed at creation: a creator
who added two more variants the next day started a second post. These two
columns let the group stay open and absorb the follow-up, without the post
either freezing or thrashing the feed.
## Why openness is derived rather than stored
There is no `closed_at` here on purpose. A group is open if it grew (or
started) within `ml_settings.discord_group_close_after_hours`, so openness is a
comparison rather than a state — which means lowering the setting closes old
groups and raising it reopens them, with nothing to repair either way. A stored
flag would need its own sweep to set it and its own repair path to ever change
the policy, for no gain.
## Why `resurfaced_at` is separate from `last_grew_at`
They answer different questions. `last_grew_at` is when the group last
absorbed something — it decides how long the group stays joinable and is what
the card shows. `resurfaced_at` is the FEED POSITION, advanced only when the
anti-thrash rule fires, so a group that gains one image a day updates in place
while a genuine second wave moves once. Folding them together would make every
addition a bump, which is the annoyance this step exists to avoid.
Both are NULL on every ordinary post, so the feed's sort key can COALESCE
through `resurfaced_at` without moving anything that is not a grouping.
Revision ID: 0093
Revises: 0092
Create Date: 2026-09-10
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "0093"
down_revision: Union[str, None] = "0092"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column(
"post", sa.Column("last_grew_at", sa.DateTime(timezone=True), nullable=True),
)
op.add_column(
"post", sa.Column("resurfaced_at", sa.DateTime(timezone=True), nullable=True),
)
# No index on either. The feed already sorts on an unindexed
# COALESCE(post_date, downloaded_at) expression, so adding resurfaced_at to
# that COALESCE changes nothing about how the query plans — and inventing a
# functional index here would be guessing at the fix for a cost nobody has
# measured. Measuring it is step B2's job.
op.add_column(
"ml_settings",
sa.Column(
"discord_group_close_after_hours", sa.Float(),
server_default=sa.text("168"), nullable=False,
),
)
op.add_column(
"ml_settings",
sa.Column(
"discord_group_resurface_min_images", sa.Integer(),
server_default="2", nullable=False,
),
)
op.add_column(
"ml_settings",
sa.Column(
"discord_group_resurface_cooldown_hours", sa.Float(),
server_default=sa.text("24"), nullable=False,
),
)
def downgrade() -> None:
op.drop_column("ml_settings", "discord_group_resurface_cooldown_hours")
op.drop_column("ml_settings", "discord_group_resurface_min_images")
op.drop_column("ml_settings", "discord_group_close_after_hours")
op.drop_column("post", "resurfaced_at")
op.drop_column("post", "last_grew_at")
-124
View File
@@ -1,124 +0,0 @@
"""post_association — "this Patreon post announced that Discord drop".
Milestone 388, step E5.
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 table holds the
proposed and accepted links between the announcement and the drop.
Directional and confirm-only. The pair is asymmetric (the teaser announces the
drop, not the reverse), the two posts are never merged (the creator published
twice, deliberately — flattening that hides the behaviour being modelled), and
nothing is linked until the operator accepts, following the FC-6.3 series
matcher. A wrongly-asserted association tells them two different pieces are
one, which is worse than no link at all.
Dismissed rows are KEPT. The row is what remembers the rejection, and
re-proposing a rejected pair on every scan is what makes a review queue get
ignored.
Revision ID: 0094
Revises: 0093
Create Date: 2026-09-10
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "0094"
down_revision: Union[str, None] = "0093"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
"post_association",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("announcement_post_id", sa.Integer(), nullable=False),
sa.Column("payload_post_id", sa.Integer(), nullable=False),
sa.Column("score", sa.Float(), nullable=False),
sa.Column("signals", sa.JSON(), nullable=True),
# No CHECK on status (rule 36 considered and declined), matching
# series_suggestion.status — the same review-queue vocabulary, and the
# same check-existing-enums lesson.
sa.Column(
"status", sa.String(length=16), server_default="pending", nullable=False,
),
sa.Column(
"created_at", sa.DateTime(timezone=True),
server_default=sa.text("now()"), nullable=False,
),
sa.Column(
"updated_at", sa.DateTime(timezone=True),
server_default=sa.text("now()"), nullable=False,
),
sa.PrimaryKeyConstraint("id", name=op.f("pk_post_association")),
# CASCADE on both sides: an association to a post that no longer exists
# is not a fact worth keeping, and E3's reversal path (delete the
# grouping) must not leave a dangling proposal behind.
sa.ForeignKeyConstraint(
["announcement_post_id"], ["post.id"], ondelete="CASCADE",
name=op.f("fk_post_association_announcement_post_id_post"),
),
sa.ForeignKeyConstraint(
["payload_post_id"], ["post.id"], ondelete="CASCADE",
name=op.f("fk_post_association_payload_post_id_post"),
),
sa.UniqueConstraint(
"announcement_post_id", "payload_post_id",
name="uq_post_association_pair",
),
)
op.create_index(
op.f("ix_post_association_announcement_post_id"),
"post_association", ["announcement_post_id"],
)
op.create_index(
op.f("ix_post_association_payload_post_id"),
"post_association", ["payload_post_id"],
)
op.create_index(
op.f("ix_post_association_status"), "post_association", ["status"],
)
op.add_column(
"import_settings",
sa.Column(
"discord_link_enabled", sa.Boolean(),
server_default="true", nullable=False,
),
)
# 0.60 sits ABOVE the largest single signal weight on purpose — see
# post_association_service.WEIGHTS. That is what makes "time proximity
# alone is never sufficient" arithmetic rather than aspirational.
op.add_column(
"import_settings",
sa.Column(
"discord_link_threshold", sa.Float(),
server_default="0.60", nullable=False,
),
)
op.add_column(
"import_settings",
sa.Column(
"discord_link_window_hours", sa.Float(),
server_default="24", nullable=False,
),
)
def downgrade() -> None:
op.drop_column("import_settings", "discord_link_window_hours")
op.drop_column("import_settings", "discord_link_threshold")
op.drop_column("import_settings", "discord_link_enabled")
op.drop_index(op.f("ix_post_association_status"), table_name="post_association")
op.drop_index(
op.f("ix_post_association_payload_post_id"), table_name="post_association",
)
op.drop_index(
op.f("ix_post_association_announcement_post_id"), table_name="post_association",
)
op.drop_table("post_association")
-63
View File
@@ -1,63 +0,0 @@
"""membership_sync — whether the roster actually synced, and when.
Milestone 387, step C3.
`platform_membership` (0091) records what was SEEN. This records whether
looking happened at all — a different fact, and the one that makes an empty
roster readable.
Without it, three situations collapse into one: the account subscribes to
nothing, the sweep never ran, or the sweep failed. 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, which is why C4 gates its
CONCLUSIONS on `last_success_at` rather than merely displaying it.
Two timestamps rather than one, deliberately: `last_attempt_at` moves every
run, `last_success_at` only on a clean walk, and the gap between them is what
lets the UI say "last synced 3 days ago, tried 20 minutes ago, failing".
Revision ID: 0095
Revises: 0094
Create Date: 2026-09-11
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "0095"
down_revision: Union[str, None] = "0094"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
"membership_sync",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("platform", sa.String(length=64), nullable=False),
sa.Column("last_attempt_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("last_success_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("last_count", sa.Integer(), nullable=True),
# No CHECK: this carries an exception class name, and the vocabulary is
# whatever the client raises — same call as source.error_type.
sa.Column("last_error_type", sa.String(length=64), nullable=True),
sa.Column("last_error_message", sa.Text(), nullable=True),
sa.Column(
"updated_at", sa.DateTime(timezone=True),
server_default=sa.text("now()"), nullable=False,
),
sa.PrimaryKeyConstraint("id", name=op.f("pk_membership_sync")),
# The upsert's conflict target, named explicitly because the service
# references it by name in ON CONFLICT.
sa.UniqueConstraint("platform", name="uq_membership_sync_platform"),
)
# No secondary indexes: one row per platform, so every read is a short scan
# and an index would be write cost buying nothing (#3301 removed seven of
# that shape). Same reasoning as platform_membership in 0091.
def downgrade() -> None:
op.drop_table("membership_sync")
@@ -1,106 +0,0 @@
"""artist_membership_suggestion — proposing that a creator and a membership match.
Milestone 388, step E4.
## What this migration deliberately does NOT add
No association table between Artist and Source, and no schema change to either.
E4's first job was to verify what was actually missing, and the answer was
neither the model nor the flows: `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 attaches to an EXISTING artist, and
`SourceService.reassign` already moves a source between artists with post and
image re-attribution. Building a parallel association table for a relationship
the schema already expresses would have been the mistake rule 28 names.
What was missing is the SUGGESTION, and that is all this table holds.
Accepting a suggestion adds a SOURCE under the existing artist — it never
merges two artists. Adding a source is trivially undone; a wrong merge silently
mixes two creators' work and corrupts tagging, series and provenance with
nothing left to separate them by.
Revision ID: 0096
Revises: 0095
Create Date: 2026-09-11
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "0096"
down_revision: Union[str, None] = "0095"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
"artist_membership_suggestion",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("platform_membership_id", sa.Integer(), nullable=False),
sa.Column("artist_id", sa.Integer(), nullable=False),
sa.Column("score", sa.Float(), nullable=False),
sa.Column("signals", sa.JSON(), nullable=True),
# No CHECK on status (rule 36 considered and declined), matching
# series_suggestion and post_association — the same review-queue
# vocabulary and the same check-existing-enums lesson.
sa.Column(
"status", sa.String(length=16), server_default="pending", nullable=False,
),
sa.Column(
"created_at", sa.DateTime(timezone=True),
server_default=sa.text("now()"), nullable=False,
),
sa.Column(
"updated_at", sa.DateTime(timezone=True),
server_default=sa.text("now()"), nullable=False,
),
sa.PrimaryKeyConstraint("id", name=op.f("pk_artist_membership_suggestion")),
# CASCADE both ways: a suggestion about a membership or an artist that
# no longer exists is not a fact worth keeping, and a dangling proposal
# would render as a broken row in the review queue.
sa.ForeignKeyConstraint(
["platform_membership_id"], ["platform_membership.id"],
ondelete="CASCADE",
name=op.f("fk_artist_membership_suggestion_membership"),
),
sa.ForeignKeyConstraint(
["artist_id"], ["artist.id"], ondelete="CASCADE",
name=op.f("fk_artist_membership_suggestion_artist_id_artist"),
),
sa.UniqueConstraint(
"platform_membership_id", "artist_id",
name="uq_artist_membership_suggestion_pair",
),
)
op.create_index(
op.f("ix_artist_membership_suggestion_platform_membership_id"),
"artist_membership_suggestion", ["platform_membership_id"],
)
op.create_index(
op.f("ix_artist_membership_suggestion_artist_id"),
"artist_membership_suggestion", ["artist_id"],
)
op.create_index(
op.f("ix_artist_membership_suggestion_status"),
"artist_membership_suggestion", ["status"],
)
def downgrade() -> None:
op.drop_index(
op.f("ix_artist_membership_suggestion_status"),
table_name="artist_membership_suggestion",
)
op.drop_index(
op.f("ix_artist_membership_suggestion_artist_id"),
table_name="artist_membership_suggestion",
)
op.drop_index(
op.f("ix_artist_membership_suggestion_platform_membership_id"),
table_name="artist_membership_suggestion",
)
op.drop_table("artist_membership_suggestion")
@@ -1,68 +0,0 @@
"""Disable sources on retired platforms, so the scheduler stops selecting them.
Milestone #406, phase 1 (switch pixiv off). Rule #171 records the scope decision.
## Why this is a migration and not a button
The live instance had one pixiv source still ENABLED when pixiv was retired
(read 2026-09-13, step 1) even though the operator believed it gone. Unregistering
a platform removes it from code; it does not touch the `source` rows that name it.
Left enabled, that row keeps being picked by the scheduler every interval, and
`download_backends` now refuses it with `unsupported_url` — forever, as a
climbing failure count on a source the operator has already given up.
A migration reaches the live instance on deploy without depending on anyone
finding the row and clicking it. The `run_download` guard is what makes a stale
enabled row SAFE; this is what makes it QUIET.
## Deliberately NOT done here
- **No rows are deleted.** Deleting a source sets its posts' `source_id` to NULL
(FK `ON DELETE SET NULL`), and `uq_post_artist_external_id_null_source` can
reject that if a source-less copy of one of those posts already exists. That
needs checking against real data first, which is phase 2's job (step 6). A
disable cannot collide with anything.
- **No posts or images are touched.** The art stays.
- **deviantart is included** because #3069 retired it and nothing disabled its
rows either. The read found none, so for it this is a no-op — written anyway,
so the statement names every retired platform rather than just the latest one.
## Hardcoded platform names
A migration is a record of one event, frozen in time, so it names the platforms
it acted on rather than importing today's registry — the registry will keep
changing and this revision must not.
Revision ID: 0097
Revises: 0096
Create Date: 2026-09-13
"""
from typing import Sequence, Union
from alembic import op
revision: str = "0097"
down_revision: Union[str, None] = "0096"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# Clears the failure state the same way `SourceService.update` does when a
# source is disabled through the app (issue #1285), so a retired source
# does not keep showing as failing after it stops being polled. A disable
# done here and one done by clicking must leave identical rows.
op.execute(
"UPDATE source SET enabled = false, last_error = NULL, "
"error_type = NULL, consecutive_failures = 0 "
"WHERE enabled AND platform IN ('pixiv', 'deviantart')"
)
def downgrade() -> None:
# Irreversible by design: which of these rows were enabled before is not
# recorded, and re-enabling every retired-platform source would resume
# polling services the product no longer supports. Rule #22 owes no
# migration story back to a dropped platform.
pass
@@ -1,90 +0,0 @@
"""Widen image_record.phash to 256-bit and re-hash the library (issue #4223).
The operator reported a 15-image variant pack landing as 3 records, and then
that variants were STILL being dropped with `phash_threshold` at 0. Zero was
already the floor of the dial, so no setting could have fixed it: at
`hash_size=8` a pHash is 64 bits of coarse light/dark layout, and variant
artwork sharing a composition produces the SAME 64 bits. Distance 0 meant
"identical hash", never "identical image".
`utils/phash.py` moves to `hash_size=16` (256 bits, what ImageRepo always
used) and adds an aspect-ratio gate plus a pixel-level confirm, so a merge is
accepted on the files rather than on the hash.
## Why this NULLs every phash
Widening the column does not correct the values already in it. Every stored
hash is a 64-bit hash of an image the app will now hash at 256 bits, and the
two cannot be compared — `find_similar` skips a mismatched-length candidate
rather than guessing, so leaving them would silently mean "no dedup, forever,
for everything imported before today". NULL is the state `backfill_phash`
already knows how to repair: it is NULL-only, keyset-paginated and
restart-safe, and the beat schedule runs it daily.
Until that backfill finishes, image dedup degrades to sha256 only —
duplicates may be kept. That is the safe direction, and the only one
available: the alternative is comparing hashes of different widths, which
would drop artwork. NOTHING here deletes or supersedes a file.
## Why the threshold is reset rather than carried over
`phash_threshold` counts bits, and the denominator went from 64 to 256. The
stored number would keep its value while meaning something four times
tighter. There is no honest carry-over, so every row goes to the new default
of 24 — including the operator's 0, which was a workaround for the bug this
revision fixes.
Revision ID: 0098
Revises: 0097
Create Date: 2026-09-21
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "0098"
down_revision: Union[str, None] = "0097"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# varchar(32) -> varchar(64): widening a length limit is a catalog-only
# change in Postgres, so this does not rewrite the table or its index.
op.alter_column(
"image_record", "phash",
existing_type=sa.String(32),
type_=sa.String(64),
existing_nullable=True,
)
op.execute("UPDATE image_record SET phash = NULL WHERE phash IS NOT NULL")
op.alter_column(
"import_settings", "phash_threshold",
existing_type=sa.Integer(),
server_default="24",
existing_nullable=False,
)
op.execute("UPDATE import_settings SET phash_threshold = 24")
def downgrade() -> None:
# The 64-bit hashes this replaced are gone, and a 64-char value does not
# fit back into varchar(32) — so the column is cleared again on the way
# down and left for backfill_phash to refill at whatever HASH_SIZE the
# code is running. Rule #22: no legacy to preserve.
op.execute("UPDATE image_record SET phash = NULL WHERE phash IS NOT NULL")
op.alter_column(
"image_record", "phash",
existing_type=sa.String(64),
type_=sa.String(32),
existing_nullable=True,
)
op.alter_column(
"import_settings", "phash_threshold",
existing_type=sa.Integer(),
server_default="10",
existing_nullable=False,
)
op.execute("UPDATE import_settings SET phash_threshold = 10")
@@ -1,99 +0,0 @@
"""library_placement_run — the placement reconciler's plan/apply/undo ledger.
Milestone #421 step 3. The survey (#4245) measured 33,789 ImageRecord rows
sitting outside their artist's canonical directory, across 56 artists. This
table holds one run of the sweep that trues them up: the plan, what it did,
and where every file came from.
## Why the moves live in a table rather than a log line
`ImageRecord.path` is the only pointer at the bytes, so a move rewrites the
row. Once that write lands, the previous location exists nowhere — unless it
was recorded first. `moves` is that record, which is what makes a 33,789-file
operation something the operator can undo per artist after looking at the
result, rather than a one-way door.
An `applied` row is therefore HISTORY, not state (lesson #4226). Any future
retention on this table may prune `ready`, `cancelled` and `error` runs; an
`applied` one is only disposable once someone decides undo is no longer
wanted. That is deliberately not a timer's decision, and no pruning is added
here.
Revision ID: 0099
Revises: 0098
Create Date: 2026-09-21
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
from sqlalchemy.dialects import postgresql
revision: str = "0099"
down_revision: Union[str, None] = "0098"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
"library_placement_run",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column(
"status", sa.String(length=16), server_default="running",
nullable=False,
),
# SET NULL, not CASCADE: deleting an artist must not destroy the
# record of where their files were moved.
sa.Column("artist_id", sa.Integer(), nullable=True),
sa.Column(
"started_at", sa.DateTime(timezone=True),
server_default=sa.text("now()"), nullable=False,
),
sa.Column("finished_at", sa.DateTime(timezone=True), nullable=True),
sa.Column(
"planned_count", sa.Integer(), server_default="0", nullable=False,
),
sa.Column(
"moved_count", sa.Integer(), server_default="0", nullable=False,
),
sa.Column(
"refused_count", sa.Integer(), server_default="0", nullable=False,
),
sa.Column(
"moves", postgresql.JSONB(astext_type=sa.Text()),
server_default=sa.text("'[]'::jsonb"), nullable=False,
),
sa.Column(
"refusals", postgresql.JSONB(astext_type=sa.Text()),
server_default=sa.text("'[]'::jsonb"), nullable=False,
),
sa.Column("error", sa.Text(), nullable=True),
sa.ForeignKeyConstraint(
["artist_id"], ["artist.id"],
name="fk_library_placement_run_artist_id", ondelete="SET NULL",
),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(
"ix_library_placement_run_status", "library_placement_run", ["status"],
)
op.create_index(
"ix_library_placement_run_artist_id", "library_placement_run",
["artist_id"],
)
def downgrade() -> None:
# Dropping this table destroys the only record of where moved files came
# from. That is correct for a downgrade — the code that reads it is going
# away too — but it is worth saying out loud rather than discovering.
op.drop_index(
"ix_library_placement_run_artist_id",
table_name="library_placement_run",
)
op.drop_index(
"ix_library_placement_run_status", table_name="library_placement_run",
)
op.drop_table("library_placement_run")
@@ -1,109 +0,0 @@
"""Drop library_placement_run — the placement reconciler is removed.
Milestone #421 built a sweep that compared each image's `artist_id` to the
name of the directory its file sat in, and called every mismatch a misplaced
file. On the operator's library that reported 33,789 of 63,605 images as
wrongly filed.
That number was an artefact of the comparison, not a fact about the library:
- **97.1%** of it was one artist's own folder spelled differently —
`Telepurte/` versus `telepurte/`. Same artist, same art, nothing wrong.
- Of the 1% that sat in a differently-named folder, querying `ImageProvenance`
— which records the post and source each file was actually downloaded from —
showed 87 where provenance agreed with the FOLDER and not the record, and 40
genuinely posted by several creators. The sweep would have misfiled or
arbitrarily picked for roughly 41% of that set.
The system already knows where every file came from. The reconciler inferred
it from a column and a directory name instead, and manufactured work out of a
naming convention. Operator's call, 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."*
Rule #22 — no legacy to preserve. The table goes with the code.
## What is deliberately kept
`utils.paths.canonical_subdir` stays: new filesystem imports derive their
directory from the artist's slug, matching what the downloader has always
done. It is not part of this tool and removing it would be churn for no fix.
Run 1's 327 moved files (`InsoUwu/` -> `insouwu/`) also stay where they are —
same artist either way, and the gallery renders them correctly.
Revision ID: 0100
Revises: 0099
Create Date: 2026-09-21
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
from sqlalchemy.dialects import postgresql
revision: str = "0100"
down_revision: Union[str, None] = "0099"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.drop_index(
"ix_library_placement_run_artist_id",
table_name="library_placement_run",
)
op.drop_index(
"ix_library_placement_run_status", table_name="library_placement_run",
)
op.drop_table("library_placement_run")
def downgrade() -> None:
# Recreates the table only. The three runs it held (one applied, two
# planned-and-never-run) are not restored and are not worth restoring —
# the code that reads them is gone.
op.create_table(
"library_placement_run",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column(
"status", sa.String(length=16), server_default="running",
nullable=False,
),
sa.Column("artist_id", sa.Integer(), nullable=True),
sa.Column(
"started_at", sa.DateTime(timezone=True),
server_default=sa.text("now()"), nullable=False,
),
sa.Column("finished_at", sa.DateTime(timezone=True), nullable=True),
sa.Column(
"planned_count", sa.Integer(), server_default="0", nullable=False,
),
sa.Column(
"moved_count", sa.Integer(), server_default="0", nullable=False,
),
sa.Column(
"refused_count", sa.Integer(), server_default="0", nullable=False,
),
sa.Column(
"moves", postgresql.JSONB(astext_type=sa.Text()),
server_default=sa.text("'[]'::jsonb"), nullable=False,
),
sa.Column(
"refusals", postgresql.JSONB(astext_type=sa.Text()),
server_default=sa.text("'[]'::jsonb"), nullable=False,
),
sa.Column("error", sa.Text(), nullable=True),
sa.ForeignKeyConstraint(
["artist_id"], ["artist.id"],
name="fk_library_placement_run_artist_id", ondelete="SET NULL",
),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(
"ix_library_placement_run_status", "library_placement_run", ["status"],
)
op.create_index(
"ix_library_placement_run_artist_id", "library_placement_run",
["artist_id"],
)
@@ -1,66 +0,0 @@
"""Clear failure state on sources that are disabled (#4279).
`failing_sources_clause()` now means "enabled AND erroring", so a disabled
source no longer counts as failing. That fixes what the surfaces REPORT; it
does not touch what the rows already CARRY, and the rows are the reason the
operator saw a banner for six days with no way to act on it (lesson #4202 —
a guard does not undo the value already stored).
## The row this exists for
Ebi77 (source 19): the membership sweep stopped it as `former_patron` at
02:50 on 2026-09-15 and correctly cleared its failure state. A deep scan was
armed twenty minutes later — `/backfill` had no `enabled` guard, which this
release also fixes — and could not complete without access, so the recovery
sweep stranded it:
consecutive_failures = 1
last_error = "stranded by recovery sweep (no terminal status after time_limit)"
Nothing could clear that. A disabled source is never scheduled, so no
successful run resets the counter; `SourceService.update` clears failure
state only on an explicit disable, and the source was already disabled; and
the card's Retry routes to `/check`, which refuses a disabled source.
## Why every disabled source, not just that one
The clear matches what `SourceService.update` already does when a source is
disabled through the app — "disable the subs you're not paying for without
them lingering as failing" — so this brings rows disabled by any OTHER path
(the membership sweep, a retired platform in 0097) into line with the rows
disabled by hand. Same shape as 0097: a repair migration reaches the live
instance on deploy rather than waiting for someone to find the row.
Enabled sources are untouched — a real failure on a live source must keep
showing.
Revision ID: 0101
Revises: 0100
Create Date: 2026-09-21
"""
from typing import Sequence, Union
from alembic import op
revision: str = "0101"
down_revision: Union[str, None] = "0100"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.execute(
"UPDATE source SET last_error = NULL, error_type = NULL, "
"consecutive_failures = 0 "
"WHERE NOT enabled "
"AND (last_error IS NOT NULL OR error_type IS NOT NULL "
" OR consecutive_failures <> 0)"
)
def downgrade() -> None:
# Irreversible by design: the cleared strings and counts are not recorded
# anywhere, and restoring a failure state nobody can act on would only
# re-create the banner this removes. Rule #22 owes no story backwards.
pass
@@ -1,138 +0,0 @@
"""Drop pixiv's ledgers, and delete credentials for platforms FC no longer has.
Milestone #406 step 6 (with issue #3980 folded in). Phase 1 unregistered pixiv
and the commit alongside this one deleted its client, downloader, ingester and
models. This removes the data those models described, and the stored secrets of
every platform that has been retired.
## The two ledger tables
`pixiv_seen_media` and `pixiv_failed_media` are the per-source seen / dead-letter
ledgers for a downloader that no longer exists. They were created in
`0089_baseline.py`, so dropping them needs a new revision rather than an edit
there.
## The credentials
Written as *delete every credential whose platform is not registered* rather
than as `platform = 'pixiv'`, at the explicit ask in this step's plan. That is
what makes one migration cover two retirements:
- **pixiv** — a live OAuth refresh token for a service FC no longer talks to.
- **deviantart** — issue #3980. #3069 retired DeviantArt in code on 2026-08-27
and left its stored session behind; seven weeks later it was still there.
And it is the only way either row can go. The credentials UI
(`subscriptions/SettingsTab.vue`) renders one card per platform returned by
`/api/platforms`, then looks the credential up by key — so a row whose platform
is unregistered has no card, no Remove button, and no way for the operator to
reach it. `CredentialService.list()` would return it; nothing asks.
The registered set is written out literally instead of importing
`known_platform_keys()`. A migration is a statement about one moment in the
schema's history: if it imported the live registry, retiring a fifth platform
in 2027 would silently change what this 2026 revision did on a fresh database.
The list below is the registry as of 2026-09-21.
## What is deliberately kept
**Every pixiv `Source` row.** The original plan deleted them; the operator's
call on 2026-09-21 was to keep them, and the reason is that `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 their
platform is unregistered, so nothing schedules them, nothing downloads through
them, and `POST /api/sources` will not make another. Keeping them costs
nothing and keeps the attribution the milestone's goal — *"the art already
downloaded from pixiv stays"* — is actually about.
Every pixiv `Post` and `ImageRecord` is likewise untouched.
Revision ID: 0102
Revises: 0101
Create Date: 2026-09-21
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "0102"
down_revision: Union[str, None] = "0101"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
# services/platforms/__init__.py's PLATFORMS as of this revision. See the
# docstring for why this is a literal and not an import.
_REGISTERED_PLATFORMS = ("patreon", "subscribestar", "hentaifoundry", "discord")
def upgrade() -> None:
op.execute(
sa.text(
"DELETE FROM credential WHERE platform NOT IN :registered"
).bindparams(
sa.bindparam("registered", value=_REGISTERED_PLATFORMS, expanding=True)
)
)
op.drop_index("ix_pixiv_failed_media_source_id", table_name="pixiv_failed_media")
op.drop_table("pixiv_failed_media")
op.drop_index("ix_pixiv_seen_media_source_id", table_name="pixiv_seen_media")
op.drop_table("pixiv_seen_media")
def downgrade() -> None:
# The tables come back empty, and the credentials do not come back at all:
# they were encrypted blobs, this migration does not copy them anywhere,
# and restoring a live token for a platform FC cannot talk to would only
# re-create the liability. Rule #22 owes no story backwards.
op.create_table(
"pixiv_seen_media",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("source_id", sa.Integer(), nullable=False),
sa.Column("filehash", sa.String(length=128), nullable=False),
sa.Column("url", sa.Text(), nullable=True),
sa.Column(
"created_at", sa.DateTime(timezone=True),
server_default=sa.text("now()"), nullable=False,
),
sa.ForeignKeyConstraint(
["source_id"], ["source.id"],
name=op.f("fk_pixiv_seen_media_source_id_source"), ondelete="CASCADE",
),
sa.PrimaryKeyConstraint("id", name=op.f("pk_pixiv_seen_media")),
sa.UniqueConstraint(
"source_id", "filehash", name="uq_pixiv_seen_media_source_id",
),
)
op.create_index(
op.f("ix_pixiv_seen_media_source_id"), "pixiv_seen_media", ["source_id"],
unique=False,
)
op.create_table(
"pixiv_failed_media",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("source_id", sa.Integer(), nullable=False),
sa.Column("filehash", sa.String(length=128), nullable=False),
sa.Column("url", sa.Text(), nullable=True),
sa.Column("error", sa.Text(), nullable=True),
sa.Column("attempts", sa.Integer(), server_default="1", nullable=False),
sa.Column(
"created_at", sa.DateTime(timezone=True),
server_default=sa.text("now()"), nullable=False,
),
sa.ForeignKeyConstraint(
["source_id"], ["source.id"],
name=op.f("fk_pixiv_failed_media_source_id_source"), ondelete="CASCADE",
),
sa.PrimaryKeyConstraint("id", name=op.f("pk_pixiv_failed_media")),
sa.UniqueConstraint(
"source_id", "filehash", name="uq_pixiv_failed_media_source_id",
),
)
op.create_index(
op.f("ix_pixiv_failed_media_source_id"), "pixiv_failed_media", ["source_id"],
unique=False,
)
@@ -1,121 +0,0 @@
"""worker_lane — settings-backed slots for each celery lane.
Milestone 422 step 1. One row per lane, holding only what an operator can
change: how many slots it runs, the ceiling they have set for themselves, and
whether it consumes its queues at all.
## What is deliberately not a column
**The queues.** They are decided by `celery_app.py`'s `task_routes`, not by
preference, so a stored copy could contradict the routing table with nothing
to notice until a queue had no consumer. They live in
`services/worker_lanes.py`.
**The derived ceiling.** Computed from the container's cgroup limits on every
read. A row written on a 32GB host and later run in a 4GB container must be
bounded by the 4GB; a stored ceiling would quietly authorise what the box can
no longer hold.
## The seeded values
Written out literally rather than imported from `worker_lanes.LANES`. A
migration is a statement about one moment in the schema's history — if it
imported the live defaults, changing them in 2027 would silently change what
this 2026 revision does on a fresh database. The two are allowed to diverge
afterwards, and that is correct: `LANES` supplies defaults for a lane added
later, this file records what was seeded today.
lane slots cap enabled
worker 1 4 yes
scheduler 1 2 yes
maintenance_long 1 2 yes
ml 0 1 NO
One of each, per the operator (2026-09-22: *"that starting value should be one
of each"*), and far below their own production numbers — worker 8 and ml 2 are
tuned for their hardware and are not a sane first boot for a stranger.
**ml ships at zero and disabled**, which is milestone 422 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". Seeding it on would make every fresh install reach HuggingFace.
The caps start low on purpose. A cap that begins at the ceiling is a rubber
stamp; starting at 4/2/2/1 means raising slots within the cap is ordinary and
raising the cap is a deliberate act.
## Existing installs
Nothing is migrated FROM. The `CELERY_QUEUES` / `CELERY_CONCURRENCY` env vars
stay exactly as they are and remain the baseline each lane boots at; these
rows are the adjustment applied on top (step 3). So this migration changes no
behaviour on a running stack — it only makes the numbers storable.
Revision ID: 0103
Revises: 0102
Create Date: 2026-09-22
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "0103"
down_revision: Union[str, None] = "0102"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
# (name, slots, slots_cap, enabled) — see the docstring for why these are
# literals and not an import.
_SEED = (
("worker", 1, 4, True),
("scheduler", 1, 2, True),
("maintenance_long", 1, 2, True),
("ml", 0, 1, False),
)
def upgrade() -> None:
worker_lane = op.create_table(
"worker_lane",
sa.Column("name", sa.String(length=32), nullable=False),
sa.Column("slots", sa.Integer(), nullable=False),
sa.Column("slots_cap", sa.Integer(), nullable=False),
sa.Column("enabled", sa.Boolean(), nullable=False),
sa.Column(
"updated_at", sa.DateTime(timezone=True),
server_default=sa.text("now()"), nullable=False,
),
sa.PrimaryKeyConstraint("name", name=op.f("pk_worker_lane")),
# Bare constraint names: Base.metadata's naming convention prepends
# ck_worker_lane_, and pre-prefixing doubles it — the defect alembic
# 0088 had to rename four constraints for (#3275). op.f() marks these
# as already-final so autogenerate does not propose renaming them.
sa.CheckConstraint("slots >= 0", name=op.f("ck_worker_lane_slots_non_negative")),
sa.CheckConstraint("slots_cap >= 0", name=op.f("ck_worker_lane_cap_non_negative")),
# The invariant that makes the cap mean anything, in the database
# rather than only in the service: a row violating it is not a
# rejected request, it is a lane that step 3's reconcile will drive UP
# to a number the operator capped.
sa.CheckConstraint("slots <= slots_cap", name=op.f("ck_worker_lane_slots_within_cap")),
)
# No index beyond the primary key, deliberately — four rows, forever. Same
# reasoning as service_seen, and the lesson of #3301, which removed seven
# indexes that were write cost buying nothing.
op.bulk_insert(
worker_lane,
[
{"name": name, "slots": slots, "slots_cap": cap, "enabled": enabled}
for name, slots, cap, enabled in _SEED
],
)
def downgrade() -> None:
# The rows go with the table. They are settings with shipped defaults, not
# operator data that predates this revision — a downgrade returns the stack
# to reading its concurrency from env, which is where it reads it from
# today anyway.
op.drop_table("worker_lane")
@@ -1,51 +0,0 @@
"""worker_lane.autoscale — may this lane grow itself?
Milestone 422 step 7. One boolean, defaulting FALSE on every existing row and
on every new one.
## Why the default is false and not "sensible"
This is the only part of the milestone that acts without anyone watching. The
manual dial (step 4) and the reconcile (step 3) both do exactly what someone
asked for; this one decides. Shipping it on would mean every install starts
with a process that changes its own resource usage based on a heuristic tuned
against nobody's workload.
Off also makes the failure mode benign: if the signal is wrong, nothing
happens until an operator opts a lane in, and they opted in while watching.
## Why per lane and not one global switch
The lanes are not alike in what a slot costs. A `worker` slot is a process;
an `ml` slot is another copy of a ~3.5GB model. A global switch would enable
growth on a lane whose behaviour under load nobody has observed, and the one
it would hurt most is the one whose cost is least visible.
Revision ID: 0104
Revises: 0103
Create Date: 2026-09-22
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "0104"
down_revision: Union[str, None] = "0103"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column(
"worker_lane",
sa.Column(
"autoscale", sa.Boolean(),
server_default=sa.text("false"), nullable=False,
),
)
def downgrade() -> None:
op.drop_column("worker_lane", "autoscale")
@@ -1,133 +0,0 @@
"""worker_lane — one number: the cap. `slots`, `enabled` and `autoscale` go.
Milestone 422, reshaped by the 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."
## What each dropped column was, and why it is not needed
**`slots`** — how many workers the lane should run. That is a MEASUREMENT,
not a preference: the autoscaler moves the live pool between one and the cap
according to the backlog, and reads it back from the worker every minute.
Storing it made it look like something to keep in agreement with the cap,
which is exactly what the operator had to do.
**`autoscale`** — whether the lane was allowed to size itself. It gated the
mechanism behind a per-lane opt-in, so a lane nobody enabled simply never
gave its slots back. Always on now, which is the only way "idle instances
quiet down" can be true of an install nobody has configured.
**`enabled`** — whether the lane consumes its queues. Derived from `cap > 0`.
It and `slots = 0` were two spellings of one fact and were free to disagree;
this migration picks the one an operator can see.
## Why the caps are rewritten rather than preserved
The old defaults were 4 / 2 / 2 / 1, chosen when the number meant "the most
you may raise SLOTS to" — a bound on a manual control, deliberately loose
because moving within it was the ordinary act. The number now means "the most
workers this lane may actually use", which is a different promise, and
carrying the old figure over would silently quadruple the worker lane on
every existing install at the moment this deploys.
So every row is reset to the new defaults: **one for each required lane, zero
for ML.** That loses whatever an operator had set — which is the honest
trade, because what they set was an answer to a different question. The UI
now tells a busy lane's operator to raise its cap, which is how the number
gets back up on an install that needs it.
ML at zero also keeps rule 164's carve-out intact: no consumers, so no model
download until someone raises the cap.
Revision ID: 0105
Revises: 0104
Create Date: 2026-09-23
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "0105"
down_revision: Union[str, None] = "0104"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
# (name, cap) — the same values `services/worker_lanes.LANES` declares. Seeded
# here as literals rather than imported: a migration must describe the schema
# at ITS point in history, and importing the live table would make this file
# change meaning every time that table does.
_CAPS = (
("worker", 1),
("scheduler", 1),
("maintenance_long", 1),
("ml", 0),
)
def upgrade() -> None:
# The constraints go first: they name `slots`, so dropping the column out
# from under them fails on Postgres.
#
# `op.f()` around each name, and it is load-bearing. Without it alembic
# runs the name through Base.metadata's naming convention, which prepends
# `ck_worker_lane_` to a string that already carries it — and the DROP
# goes looking for `ck_worker_lane_ck_worker_lane_slots_within_cap`, which
# no database has. That is #3275 exactly, from the other direction:
# alembic 0088 had to RENAME four constraints created with the same
# doubling. Caught here by the integration lane, run 7365.
op.drop_constraint(
op.f("ck_worker_lane_slots_within_cap"), "worker_lane", type_="check",
)
op.drop_constraint(
op.f("ck_worker_lane_slots_non_negative"), "worker_lane", type_="check",
)
op.drop_column("worker_lane", "slots")
op.drop_column("worker_lane", "enabled")
op.drop_column("worker_lane", "autoscale")
# Reset to the new meaning. See the docstring: the old value answered a
# different question, and carrying it over would raise every lane.
for name, cap in _CAPS:
op.execute(
sa.text("UPDATE worker_lane SET slots_cap = :cap WHERE name = :name")
.bindparams(cap=cap, name=name)
)
# A lane the old seed never wrote — or one an operator added by hand — is
# left alone rather than guessed at. `_rows_by_name` creates any missing
# row at the lane's default on first read.
def downgrade() -> None:
op.add_column(
"worker_lane",
sa.Column("slots", sa.Integer(), nullable=False, server_default="1"),
)
op.add_column(
"worker_lane",
sa.Column(
"enabled", sa.Boolean(), nullable=False, server_default=sa.text("true"),
),
)
op.add_column(
"worker_lane",
sa.Column(
"autoscale", sa.Boolean(), nullable=False, server_default=sa.text("false"),
),
)
# Restore the pre-0105 invariants. `slots` comes back as 1 everywhere and
# the caps are 1/1/1/0, so a lane at cap 0 would violate `slots <= cap` —
# hence the clamp before the constraint is added.
op.execute(sa.text("UPDATE worker_lane SET slots = 0 WHERE slots_cap = 0"))
op.execute(sa.text("UPDATE worker_lane SET enabled = (slots_cap > 0)"))
op.create_check_constraint(
op.f("ck_worker_lane_slots_non_negative"), "worker_lane", "slots >= 0",
)
op.create_check_constraint(
op.f("ck_worker_lane_slots_within_cap"), "worker_lane", "slots <= slots_cap",
)
@@ -1,70 +0,0 @@
"""service_seen — delete the roster rows the fixed code can no longer write.
Operator, 2026-09-23: *"clean up the stale service_seen rows"*. They were not
stale. They were PHANTOMS, written on purpose by code that identified a celery
worker from the queues it was consuming.
A lane at cap 0 has its consumers cancelled, so it answers `active_queues()`
with an empty list. The roster grouped on that empty set, wrote it under the
key `celery:` and rendered `role_display_name(())` as the display name — a row
called **`Worker ()`**, reported as running, beside the real lane's row going
stale because nothing updated it any more.
`worker_lanes.lane_for_node` fixes the cause: a worker is attributed by its
NODE NAME, which survives having no consumers. Nothing will write `celery:`
again.
## Why a migration and not a retention sweep
Lesson #4202: a guard that refuses to produce a bad value does not undo the
bad value already stored. The row is the thing that has to change.
And it must be deleted rather than aged out, because the roster deliberately
NEVER forgets — *"anything that has run at least once stays listed, that is
what lets a stopped one be noticed rather than simply vanishing"*. A row that
merely goes quiet is exactly what the roster is for. Only a row that cannot
correspond to anything real is safe to remove, and `celery:` is precisely
that: the empty queue set, which no correctly-attributed worker can produce.
## What is deliberately NOT deleted
**Celery rows with a real but unmatched queue set.** A deployment slicing
`CELERY_QUEUES` differently is supported and its rows are true. It is not this
migration's business to decide that somebody else's worker is obsolete.
**Agent rows, including a possible `agent:agent` from a build that omitted
`agent_id`.** Nothing here can tell an abandoned agent id from a second agent
that is currently down, and deleting a real one would hide a genuinely dead
GPU agent — the one thing the roster exists to show. If such a row is present
it needs a person to look at it, not a migration guessing.
Revision ID: 0106
Revises: 0105
Create Date: 2026-09-23
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "0106"
down_revision: Union[str, None] = "0105"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# Exactly the one key the empty queue set produced. Matched literally
# rather than by a LIKE or a prefix: `celery:` with nothing after it is
# the phantom, and `celery:ml` is a real lane.
op.execute(
sa.text("DELETE FROM service_seen WHERE key = :key").bindparams(key="celery:")
)
def downgrade() -> None:
# Nothing. The row carried no information — an empty queue set and a
# timestamp — and the roster re-learns anything real on its next refresh.
# Re-creating it would put a phantom back.
pass
@@ -1,63 +0,0 @@
"""worker_lane_sample — where the sizing sweep leaves what it measured.
Operator, 2026-09-23, on the System tab: *"there is a repull every time this
page loads — is there a reason this info isn't being tracked in the
background and stored in some way?"*
`/api/system/workers` ran a full celery inspect on every call — four
broadcasts on an eleven-second budget — and the page polls it every fifteen
seconds. `size_worker_lanes` was already inspecting on a timer to decide pool
sizes, computing exactly these numbers and discarding them. This table is
where they land instead, and the endpoint becomes a plain read.
## Why a new table rather than columns on `worker_lane`
`worker_lane` holds the one number an operator sets. Putting a measurement
beside it is the mistake alembic 0105 undid: `slots` sat next to `slots_cap`,
and a measurement next to a preference reads as a second preference.
No backfill. A row appears when the sweep first runs (within its period), and
until then the lane reads as not-yet-measured, which is true.
Revision ID: 0107
Revises: 0106
Create Date: 2026-09-23
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "0107"
down_revision: Union[str, None] = "0106"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
"worker_lane_sample",
sa.Column("lane", sa.String(length=32), primary_key=True),
# Nullable=False with no server_default: the sweep writes every column
# on every upsert, so a row only ever exists complete.
sa.Column("present", sa.Boolean(), nullable=False),
sa.Column("replicas", sa.Integer(), nullable=False),
# Nullable on purpose — unknown, never zero. A worker that answered
# without reporting its pool, and a queue the broker did not answer
# for, must not be summed as empty.
sa.Column("pool", sa.Integer(), nullable=True),
sa.Column("active", sa.Integer(), nullable=False),
sa.Column("reserved", sa.Integer(), nullable=False),
sa.Column("queue_depth", sa.Integer(), nullable=True),
sa.Column(
"measured_at",
sa.DateTime(timezone=True),
nullable=False,
server_default=sa.func.now(),
),
)
def downgrade() -> None:
op.drop_table("worker_lane_sample")
@@ -1,54 +0,0 @@
"""download_revisit_days — how far back a tick keeps looking for EDITED posts.
Operator, 2026-09-23, pointing at a Floppystack post: *"this post has been
updated as he implements hot fixes — any chance we have a way to scan for or
see updated posts so we can update ours to match and pull the new attachments
and pictures etc."*
A tick stopped after 20 contiguous already-have-it items. That is the right
instinct and the wrong unit: a post edited three days after publication sits
well below twenty seen items, so the walk turned around before reaching it. The
walk now needs BOTH a run of seen items and a post older than this many days
before it stops.
A settings row rather than a constant (rule 25) because the right window is a
property of the CREATOR, not of FabledCurator — one artist appends hotfix
builds for a fortnight, another never touches a post again. 0 turns the revisit
off entirely and restores the pure count early-out.
30 days is the operator's own number, 2026-09-23.
Revision ID: 0108
Revises: 0107
Create Date: 2026-09-23
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "0108"
down_revision: Union[str, None] = "0107"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# server_default so the existing single settings row gets the window without
# a data migration — and so an install that predates this column reads 30
# rather than 0. 0 is a real, meaningful value here (revisit off), so the
# column must never be allowed to arrive at it by omission.
op.add_column(
"import_settings",
sa.Column(
"download_revisit_days",
sa.Integer(),
nullable=False,
server_default="30",
),
)
def downgrade() -> None:
op.drop_column("import_settings", "download_revisit_days")
@@ -1,44 +0,0 @@
"""discord_link_auto — whether FC links a conclusive pair without asking.
Operator, 2026-09-24: *"I don't want this to be manual that defeats the
convenience that I'm going for."*
Confirm-only was right while every signal was circumstantial. Time proximity
and a body that mentions Discord can never be more than suggestive, so asking
was the honest response. A shared working name is different in kind: when the
creator's own name for a piece appears in exactly these two posts and nowhere
else in their library, there is nothing left for the operator to adjudicate,
and asking is just a chore FC invented for them.
Defaults ON, which is a real change of posture and deliberate. It only governs
the conclusive band — weaker evidence still queues — and a link is a row the
operator can dismiss, so the reversal is a click rather than a migration.
Revision ID: 0109
Revises: 0108
Create Date: 2026-09-24
"""
import sqlalchemy as sa
from alembic import op
revision = "0109"
down_revision = "0108"
branch_labels = None
depends_on = None
def upgrade():
op.add_column(
"import_settings",
sa.Column(
"discord_link_auto",
sa.Boolean(),
nullable=False,
server_default=sa.text("true"),
),
)
def downgrade():
op.drop_column("import_settings", "discord_link_auto")
@@ -1,59 +0,0 @@
"""The unified post card — fold window, family window, and who linked a pair.
Milestone 388, #4402 and #4401. A Patreon teaser's card shows the Discord drop
it announced, and the rest of that piece's variants, by REFERENCE: nothing is
absorbed, nothing changes owner, and every Discord post keeps its own place.
Three columns:
* `import_settings.discord_link_fold_hours` — a linked drop leaves the feed
only when it is this close to its teaser (the same release, shown twice).
* `import_settings.discord_family_window_days` — how far from the teaser the
card reaches for variants. 60 is measured: named families spread up to 44
days on artist 8, every collision found over 500.
* `post_association.linked_by` — "fc" or "operator", so a link FC made by
itself can say so on the card and offer the undo the operator asked for.
Revision ID: 0110
Revises: 0109
Create Date: 2026-09-24
"""
import sqlalchemy as sa
from alembic import op
revision = "0110"
down_revision = "0109"
branch_labels = None
depends_on = None
def upgrade():
op.add_column(
"import_settings",
sa.Column(
"discord_link_fold_hours",
sa.Float(),
nullable=False,
server_default=sa.text("24"),
),
)
op.add_column(
"import_settings",
sa.Column(
"discord_family_window_days",
sa.Float(),
nullable=False,
server_default=sa.text("60"),
),
)
op.add_column(
"post_association",
sa.Column("linked_by", sa.String(length=16), nullable=True),
)
def downgrade():
op.drop_column("post_association", "linked_by")
op.drop_column("import_settings", "discord_family_window_days")
op.drop_column("import_settings", "discord_link_fold_hours")
@@ -1,75 +0,0 @@
"""Discord native ingester ledgers — seen and dead-letter, per source.
Milestone 428, #4415. Discord moves off gallery-dl onto the native core, which
keeps its memory of what a source has already fetched in these two tables
instead of gallery-dl's archive. Same shape as the SubscribeStar pair.
Revision ID: 0111
Revises: 0110
Create Date: 2026-09-24
"""
import sqlalchemy as sa
from alembic import op
revision = "0111"
down_revision = "0110"
branch_labels = None
depends_on = None
def upgrade():
op.create_table(
"discord_seen_media",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("source_id", sa.Integer(), nullable=False),
sa.Column("filehash", sa.String(length=128), nullable=False),
sa.Column("post_id", sa.String(length=64), nullable=True),
sa.Column(
"seen_at", sa.DateTime(timezone=True),
server_default=sa.text("now()"), nullable=False,
),
sa.ForeignKeyConstraint(
["source_id"], ["source.id"],
name=op.f("fk_discord_seen_media_source_id_source"), ondelete="CASCADE",
),
sa.PrimaryKeyConstraint("id", name=op.f("pk_discord_seen_media")),
sa.UniqueConstraint("source_id", "filehash", name="uq_discord_seen_media_source_id"),
)
op.create_index(
op.f("ix_discord_seen_media_source_id"), "discord_seen_media", ["source_id"],
)
op.create_table(
"discord_failed_media",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("source_id", sa.Integer(), nullable=False),
sa.Column("filehash", sa.String(length=128), nullable=False),
sa.Column("attempts", sa.Integer(), server_default="1", nullable=False),
sa.Column("last_error", sa.Text(), nullable=True),
sa.Column(
"first_failed_at", sa.DateTime(timezone=True),
server_default=sa.text("now()"), nullable=False,
),
sa.Column(
"last_failed_at", sa.DateTime(timezone=True),
server_default=sa.text("now()"), nullable=False,
),
sa.ForeignKeyConstraint(
["source_id"], ["source.id"],
name=op.f("fk_discord_failed_media_source_id_source"), ondelete="CASCADE",
),
sa.PrimaryKeyConstraint("id", name=op.f("pk_discord_failed_media")),
sa.UniqueConstraint(
"source_id", "filehash", name="uq_discord_failed_media_source_id",
),
)
op.create_index(
op.f("ix_discord_failed_media_source_id"), "discord_failed_media", ["source_id"],
)
def downgrade():
op.drop_index(op.f("ix_discord_failed_media_source_id"), table_name="discord_failed_media")
op.drop_table("discord_failed_media")
op.drop_index(op.f("ix_discord_seen_media_source_id"), table_name="discord_seen_media")
op.drop_table("discord_seen_media")
@@ -1,78 +0,0 @@
"""Retire the sketch/doodle WIP title tier — its tags, its review flags, its toggle.
Milestone 430, #4428. The soft tier (#1474) tagged `wip` on any post titled
sketch / doodle / scribble. Measured on the operator's library it was 6,096 of
8,876 wip tags, and its conflict audit filled the Gallery's review strip with
2,086 cards, because most finished art scores >= 0.5 on some content head. A
"sketch" is usually finished work, so the operator retired it (2026-09-25).
Data:
* A soft tag the operator stood behind is kept and relabelled `manual`: one they
confirmed (tag_positive_confirmation), or one whose review flag they resolved
while leaving the tag on (the strip's "Keep tag").
* Every other `wip_title_soft` row is deleted.
* Unresolved review flags whose tag is no longer on the image are deleted: the
question they ask no longer applies. That is the audit's cards, and any older
orphan the same way.
Then `import_settings.wip_soft_title_tagging_enabled` is dropped. The downgrade
restores the column only; deleted tags are not recreated.
Revision ID: 0112
Revises: 0111
Create Date: 2026-09-25
"""
import sqlalchemy as sa
from alembic import op
revision = "0112"
down_revision = "0111"
branch_labels = None
depends_on = None
def retire_soft_wip_tags(conn) -> None:
"""The data half, on a plain connection, so a test can run it directly."""
conn.execute(sa.text("""
UPDATE image_tag it SET source = 'manual'
WHERE it.source = 'wip_title_soft'
AND (
EXISTS (
SELECT 1 FROM tag_positive_confirmation c
WHERE c.image_record_id = it.image_record_id AND c.tag_id = it.tag_id
)
OR EXISTS (
SELECT 1 FROM presentation_review pr
WHERE pr.image_record_id = it.image_record_id AND pr.tag_id = it.tag_id
AND pr.resolved_at IS NOT NULL
)
)
"""))
conn.execute(sa.text("DELETE FROM image_tag WHERE source = 'wip_title_soft'"))
conn.execute(sa.text("""
DELETE FROM presentation_review pr
WHERE pr.resolved_at IS NULL
AND NOT EXISTS (
SELECT 1 FROM image_tag it
WHERE it.image_record_id = pr.image_record_id AND it.tag_id = pr.tag_id
)
"""))
def upgrade():
retire_soft_wip_tags(op.get_bind())
op.drop_column("import_settings", "wip_soft_title_tagging_enabled")
def downgrade():
op.add_column(
"import_settings",
sa.Column(
"wip_soft_title_tagging_enabled",
sa.Boolean(),
nullable=False,
server_default=sa.text("false"),
),
)
@@ -1,60 +0,0 @@
"""Re-date images whose post's date arrived after they were linked.
#4431. The native ingesters import a post's media before its record, and the
date travels in the record (`_post.json`). Every natively downloaded image was
therefore linked to an undated post and kept its download time in both gallery
date columns, while the post itself was dated correctly. The importer now
re-dates a post's images when its record lands; this repairs the images that
landed before that.
Both columns get back the rules the importer keeps:
* `effective_date` is the primary post's date (left alone when that post has
none, as the importer does);
* `earliest_post_date` is the earliest dated post the image is linked to.
Only rows that differ are written. The downgrade does nothing: the old values
were download times that no one chose.
Revision ID: 0113
Revises: 0112
Create Date: 2026-09-25
"""
import sqlalchemy as sa
from alembic import op
revision = "0113"
down_revision = "0112"
branch_labels = None
depends_on = None
def redate_images(conn) -> None:
"""The data step, on a plain connection, so a test can run it directly."""
conn.execute(sa.text("""
UPDATE image_record ir SET effective_date = p.post_date
FROM post p
WHERE p.id = ir.primary_post_id
AND p.post_date IS NOT NULL
AND ir.effective_date IS DISTINCT FROM p.post_date
"""))
conn.execute(sa.text("""
UPDATE image_record ir SET earliest_post_date = m.earliest
FROM (
SELECT ip.image_record_id, MIN(p.post_date) AS earliest
FROM image_provenance ip JOIN post p ON p.id = ip.post_id
WHERE p.post_date IS NOT NULL
GROUP BY ip.image_record_id
) m
WHERE m.image_record_id = ir.id
AND ir.earliest_post_date IS DISTINCT FROM m.earliest
"""))
def upgrade():
redate_images(op.get_bind())
def downgrade():
pass
@@ -1,79 +0,0 @@
"""Fold the undated shell posts a misfiled attachment created into the real post.
#4435. A non-image file (an archive, a pdf) downloaded for one of an artist's
Discord channels was filed under the artist's FIRST Discord source: the
attachment path looked the source up by (artist, platform), which takes the
lowest id. That created an undated, url-less post there holding only the
attachment, and the message's real post record then created the dated post
under the right source. The importer now uses the source it was downloading
for; this repairs the pairs it left.
A shell is folded only when all of this holds:
* it has no date and no url, and nothing synthesized it;
* another post of the same artist, platform and external id HAS a date;
* no image is linked to the shell (it held an attachment and nothing else).
Its attachments move to the dated post, dropping any the dated post already
has (same sha256, which the per-post unique forbids twice), and the shell is
deleted. A shell with no dated twin is left alone: which channel it belongs to
is not recorded anywhere but the file name. The downgrade does nothing.
Revision ID: 0114
Revises: 0113
Create Date: 2026-09-25
"""
import sqlalchemy as sa
from alembic import op
revision = "0114"
down_revision = "0113"
branch_labels = None
depends_on = None
_PAIRS = """
SELECT DISTINCT ON (shell.id) shell.id AS shell_id, real.id AS real_id
FROM post shell
JOIN source ss ON ss.id = shell.source_id
JOIN post real
ON real.artist_id = shell.artist_id
AND real.external_post_id = shell.external_post_id
AND real.id <> shell.id
AND real.post_date IS NOT NULL
JOIN source rs ON rs.id = real.source_id AND rs.platform = ss.platform
WHERE shell.post_date IS NULL
AND shell.post_url IS NULL
AND shell.synthesized_by IS NULL
AND NOT EXISTS (SELECT 1 FROM image_provenance ip WHERE ip.post_id = shell.id)
ORDER BY shell.id, real.id
"""
def fold_misfiled_attachment_posts(conn) -> int:
"""The data step, on a plain connection, so a test can run it directly.
Returns how many shells were folded."""
pairs = conn.execute(sa.text(_PAIRS)).all()
for shell_id, real_id in pairs:
conn.execute(sa.text("""
DELETE FROM post_attachment pa
WHERE pa.post_id = :shell
AND EXISTS (
SELECT 1 FROM post_attachment keep
WHERE keep.post_id = :real AND keep.sha256 = pa.sha256
)
"""), {"shell": shell_id, "real": real_id})
conn.execute(
sa.text("UPDATE post_attachment SET post_id = :real WHERE post_id = :shell"),
{"shell": shell_id, "real": real_id},
)
conn.execute(sa.text("DELETE FROM post WHERE id = :shell"), {"shell": shell_id})
return len(pairs)
def upgrade() -> None:
fold_misfiled_attachment_posts(op.get_bind())
def downgrade() -> None:
pass
@@ -1,27 +0,0 @@
"""source.display_name — what a source is called on its platform.
#4481. A Discord source's URL is a server id and a channel id, so the
Subscriptions page could only show two numbers. The Discord ingester already
loads the server and channel names to walk them; it now keeps them here,
refreshed on every walk. NULL until a walk has read it.
Revision ID: 0115
Revises: 0114
Create Date: 2026-09-28
"""
import sqlalchemy as sa
from alembic import op
revision = "0115"
down_revision = "0114"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column("source", sa.Column("display_name", sa.Text(), nullable=True))
def downgrade() -> None:
op.drop_column("source", "display_name")
@@ -1,34 +0,0 @@
"""The single-color filter's default becomes near-total: 0.95 → 0.995.
#4483. At 0.95 the filter rejected line art: a pencil doodle on white is
mostly white, and five of Todding's Discord doodles were skipped on import as
"single color", leaving their posts with text and no image. The predicate now
samples real pixels instead of a blurred thumbnail, and the default only
calls an image blank when it essentially is one.
A stored 0.95 is the old default, so it moves with it. Any other value was
chosen by hand and is left alone.
Revision ID: 0116
Revises: 0115
Create Date: 2026-09-28
"""
from alembic import op
revision = "0116"
down_revision = "0115"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.alter_column("import_settings", "single_color_threshold", server_default="0.995")
op.execute(
"UPDATE import_settings SET single_color_threshold = 0.995 "
"WHERE single_color_threshold = 0.95"
)
def downgrade() -> None:
op.alter_column("import_settings", "single_color_threshold", server_default="0.95")
@@ -1,33 +0,0 @@
"""external_link.started_at: when a fetch claimed the link.
A worker killed mid-fetch (the Celery hard limit, an OOM, a restart) never
reaches the code that moves its link out of 'downloading'. The external-link
sweep returns such a link to the retry path once it has been 'downloading'
longer than the task's hard limit, which needs the claim time.
A row in 'downloading' with no claim time is treated as stale: no worker
survives the deploy that adds this column.
Revision ID: 0117
Revises: 0116
Create Date: 2026-11-05
"""
import sqlalchemy as sa
from alembic import op
revision = "0117"
down_revision = "0116"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column(
"external_link",
sa.Column("started_at", sa.DateTime(timezone=True), nullable=True),
)
def downgrade() -> None:
op.drop_column("external_link", "started_at")
@@ -1,32 +0,0 @@
"""external_link.recoveries: how many times recovery has retried a dead link.
The daily recovery gives a dead link one more attempt, spaced further apart
each time, and leaves it dead once the count reaches its limit. A link that
can never succeed is then downloaded a handful of times in total, not every
day.
Revision ID: 0118
Revises: 0117
Create Date: 2026-11-05
"""
import sqlalchemy as sa
from alembic import op
revision = "0118"
down_revision = "0117"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column(
"external_link",
sa.Column(
"recoveries", sa.Integer(), nullable=False, server_default=sa.text("0")
),
)
def downgrade() -> None:
op.drop_column("external_link", "recoveries")
@@ -1,43 +0,0 @@
"""import_settings: the off-site backup destination (an S3 bucket).
Address, bucket, access key and region are plain settings; the secret key is
stored sealed with the credential key. All empty means no destination is set
up, which is every existing install's state.
Revision ID: 0119
Revises: 0118
Create Date: 2026-11-06
"""
import sqlalchemy as sa
from alembic import op
revision = "0119"
down_revision = "0118"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column("import_settings", sa.Column("backup_s3_endpoint", sa.Text(), nullable=True))
op.add_column(
"import_settings", sa.Column("backup_s3_bucket", sa.String(255), nullable=True),
)
op.add_column("import_settings", sa.Column("backup_s3_access_key", sa.Text(), nullable=True))
op.add_column(
"import_settings", sa.Column("backup_s3_secret_sealed", sa.Text(), nullable=True),
)
op.add_column(
"import_settings",
sa.Column(
"backup_s3_region", sa.String(64), nullable=False, server_default="us-east-1",
),
)
def downgrade() -> None:
for column in (
"backup_s3_region", "backup_s3_secret_sealed", "backup_s3_access_key",
"backup_s3_bucket", "backup_s3_endpoint",
):
op.drop_column("import_settings", column)
@@ -1,30 +0,0 @@
"""import_settings: a bandwidth limit for uploads to the off-site backup bucket.
In megabits per second; 0 means no limit. Every install starts at 200 Mbit/s,
which leaves room on a gigabit link for everything else the server does.
Revision ID: 0120
Revises: 0119
Create Date: 2026-11-07
"""
import sqlalchemy as sa
from alembic import op
revision = "0120"
down_revision = "0119"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column(
"import_settings",
sa.Column(
"backup_upload_limit_mbps", sa.Integer(), nullable=False, server_default="200",
),
)
def downgrade() -> None:
op.drop_column("import_settings", "backup_upload_limit_mbps")
-46
View File
@@ -1,46 +0,0 @@
"""backup_blob: which library files are already in the off-site bucket.
One row per (destination, sha256) sent. The media backup's work list is every
image and attachment sha with no row for the current destination, so a run is
incremental and resumable by construction, and pointing backups at a new
bucket starts that bucket's copy from the beginning.
backup_run.last_progress_at is the heartbeat of a run that spans hours; the
stall sweep measures a media run's silence from it, not from its start.
Revision ID: 0121
Revises: 0120
Create Date: 2026-11-07
"""
import sqlalchemy as sa
from alembic import op
revision = "0121"
down_revision = "0120"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"backup_blob",
sa.Column("target", sa.Text(), nullable=False),
sa.Column("sha256", sa.String(64), nullable=False),
sa.Column("key", sa.Text(), nullable=False),
sa.Column("size_bytes", sa.BigInteger(), nullable=False),
sa.Column(
"uploaded_at", sa.DateTime(timezone=True), nullable=False,
server_default=sa.text("now()"),
),
sa.PrimaryKeyConstraint("target", "sha256", name=op.f("pk_backup_blob")),
)
op.add_column(
"backup_run",
sa.Column("last_progress_at", sa.DateTime(timezone=True), nullable=True),
)
def downgrade() -> None:
op.drop_column("backup_run", "last_progress_at")
op.drop_table("backup_blob")
@@ -1,30 +0,0 @@
"""Retire the images tarball backup.
Images and attachments are backed up off-site, one object per file
(backup_blob). The tarball of /images that this replaces is gone, with its
retention setting, its path column, and any runs it left.
Revision ID: 0122
Revises: 0121
Create Date: 2026-11-08
"""
import sqlalchemy as sa
from alembic import op
revision = "0122"
down_revision = "0121"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.execute("DELETE FROM backup_run WHERE kind = 'images'")
op.drop_column("backup_run", "tar_path")
op.drop_column("import_settings", "backup_images_keep_last_n")
def downgrade() -> None:
op.add_column("import_settings", sa.Column(
"backup_images_keep_last_n", sa.Integer(), nullable=False, server_default="3"))
op.add_column("backup_run", sa.Column("tar_path", sa.Text(), nullable=True))
-2
View File
@@ -41,7 +41,6 @@ def all_blueprints() -> list[Blueprint]:
from .system_health import system_health_bp
from .tags import tags_bp
from .thumbnails import thumbnails_bp
from .workers import workers_bp
return [
api_bp,
attachments_bp,
@@ -53,7 +52,6 @@ def all_blueprints() -> list[Blueprint]:
showcase_bp,
settings_bp,
system_activity_bp,
workers_bp,
system_health_bp,
system_backup_bp,
admin_bp,
-29
View File
@@ -475,35 +475,6 @@ async def trigger_reclaim_attachments():
return _queued(async_result)
@admin_bp.route("/maintenance/import-unimported", methods=["POST"])
async def trigger_import_unimported():
"""Import files in the artists' folders that the library has no record of.
Body {"dry_run": bool}; dry_run (the DEFAULT) lists what was found, per
artist, and changes nothing. dry_run=false queues them as an import batch
that attaches each file where it lies. Returns the Celery task id — poll
/maintenance/task-result/<id> for the summary."""
from ..tasks.admin import import_unimported_files_task
body = await request.get_json(silent=True) or {}
dry_run = bool(body.get("dry_run", True))
async_result = import_unimported_files_task.delay(dry_run=dry_run)
return _queued(async_result)
@admin_bp.route("/maintenance/repair-discord-downloads", methods=["POST"])
async def trigger_repair_discord_downloads():
"""Clean re-download of the Discord files broken by the `None` naming
(#3999). Body {"dry_run": bool}; dry_run is the DEFAULT, because the apply
deletes files and makes gallery-dl forget every Discord download. Returns the
Celery task id — poll /maintenance/task-result/<id> for the summary."""
from ..tasks.admin import repair_discord_downloads_task
body = await request.get_json(silent=True) or {}
dry_run = bool(body.get("dry_run", True))
async_result = repair_discord_downloads_task.delay(dry_run=dry_run)
return _queued(async_result)
@admin_bp.route("/maintenance/dedup-videos", methods=["POST"])
async def trigger_dedup_videos():
"""Tier-1 video dedup (#871). Body {"dry_run": bool}: dry_run=true previews
-10
View File
@@ -65,16 +65,6 @@ async def autocomplete():
])
@artists_bp.route("/names", methods=["GET"])
async def names():
"""Every artist, id + name + slug, alphabetical. For filter pickers that
list artists before anything is typed; `autocomplete` deliberately returns
nothing for an empty query."""
async with get_session() as session:
rows = await ArtistService(session).all_names()
return jsonify([{"id": i, "name": n, "slug": s} for i, n, s in rows])
@artists_bp.route("/directory", methods=["GET"])
async def directory():
"""FC-3f: cursor-paginated artists directory.
+4 -88
View File
@@ -19,10 +19,7 @@ from ..models import AppSetting
from ..services.extension_service import (
ExtensionService,
InvalidUrlError,
PosterLookupError,
UnknownArtistError,
UnknownPlatformError,
UnknownSourceError,
)
from ..services.source_service import KNOWN_PLATFORMS
from ._responses import error_response as _bad
@@ -90,16 +87,10 @@ async def probe_source():
url = (request.args.get("url") or "").strip()
if not url:
return _bad("invalid_body", detail="url query parameter is required")
from .credentials import _get_crypto
async with get_session() as session:
if not await _ext_key_required(session):
return _bad("unauthorized", status=401)
# crypto lets a Discord probe name the server and channel with the
# stored token; every other platform ignores it.
result = await ExtensionService(session, _get_crypto()).probe(
url, names=request.args.get("names") in ("1", "true"),
)
result = await ExtensionService(session).probe(url)
return jsonify(result)
@@ -111,22 +102,6 @@ async def quick_add_source():
url = body.get("url")
if not isinstance(url, str) or not url.strip():
return _bad("invalid_body", detail="url is required")
# Optional: connect the new source to an existing artist (artist_id) or to
# the artist of that name (artist_name). A Discord channel names no
# creator, so the extension's Add panel always sends one of them.
artist_id = body.get("artist_id")
if artist_id is not None and (isinstance(artist_id, bool) or not isinstance(artist_id, int)):
return _bad("invalid_body", detail="artist_id must be an integer")
artist_name = body.get("artist_name")
if artist_name is not None and not isinstance(artist_name, str):
return _bad("invalid_body", detail="artist_name must be a string")
# Patreon is canon: adding a Patreon source to an existing artist can take
# the creator's Patreon display name (name only; the slug never moves).
use_platform_name = body.get("use_platform_name") is True
# #4491: a Discord add can carry the posters picked in the panel.
discord_authors = body.get("discord_authors")
if discord_authors is not None and not _valid_authors(discord_authors):
return _bad("invalid_body", detail="discord_authors must be a list of {id, name}")
from .credentials import _get_crypto
@@ -134,15 +109,9 @@ async def quick_add_source():
if not await _ext_key_required(session):
return _bad("unauthorized", status=401)
try:
# crypto lets an add resolve the artist's display name via the
# stored credential (else it falls back to the URL handle). #130.
result = await ExtensionService(session, _get_crypto()).quick_add_source(
url, artist_id=artist_id, artist_name=artist_name,
use_platform_name=use_platform_name,
discord_authors=discord_authors,
)
except UnknownArtistError as exc:
return _bad("not_found", detail=str(exc), status=404)
# crypto lets a pixiv add resolve the artist's display name via the
# stored OAuth token (else it falls back to the numeric id). #130.
result = await ExtensionService(session, _get_crypto()).quick_add_source(url)
except UnknownPlatformError as exc:
return _bad(
"unknown_platform",
@@ -154,59 +123,6 @@ async def quick_add_source():
return jsonify(result), (201 if result["created_source"] else 200)
def _valid_authors(value) -> bool:
"""[{id, name}]: the id is what the list keys on, the name only shows it."""
return isinstance(value, list) and all(
isinstance(a, dict) and isinstance(a.get("id"), str | int)
and not isinstance(a.get("id"), bool)
and (a.get("name") is None or isinstance(a.get("name"), str))
for a in value
)
@extension_bp.route("/discord/posters", methods=["GET"])
async def discord_posters():
"""Who posts in the Discord channel `url` shows, lately, with the likely
creator marked (#4491). Reads the channel with the stored token; writes
nothing."""
url = (request.args.get("url") or "").strip()
if not url:
return _bad("invalid_body", detail="url query parameter is required")
from .credentials import _get_crypto
async with get_session() as session:
if not await _ext_key_required(session):
return _bad("unauthorized", status=401)
try:
result = await ExtensionService(session, _get_crypto()).discord_posters(url)
except (InvalidUrlError, UnknownPlatformError) as exc:
return _bad("invalid_url", detail=str(exc))
except PosterLookupError as exc:
return _bad("posters_unavailable", detail=str(exc), status=409)
return jsonify(result)
@extension_bp.route("/discord/posters", methods=["POST"])
async def set_discord_posters():
"""Set the poster list of the source following `url`'s channel."""
body = await request.get_json(silent=True)
if not isinstance(body, dict) or not isinstance(body.get("url"), str):
return _bad("invalid_body", detail="url is required")
authors = body.get("authors")
if not _valid_authors(authors):
return _bad("invalid_body", detail="authors must be a list of {id, name}")
async with get_session() as session:
if not await _ext_key_required(session):
return _bad("unauthorized", status=401)
try:
result = await ExtensionService(session).set_discord_posters(body["url"], authors)
except (InvalidUrlError, UnknownPlatformError, PosterLookupError) as exc:
return _bad("invalid_url", detail=str(exc))
except UnknownSourceError as exc:
return _bad("not_found", detail=str(exc), status=404)
return jsonify(result)
def _read_manifest_sync() -> dict | None:
"""All the filesystem-touching work for /api/extension/manifest,
in a sync helper so the async route can dispatch it via
+2 -31
View File
@@ -245,29 +245,6 @@ async def errors_recover(image_id: int):
# --- Agent (bearer token): lease / submit / heartbeat / fail ------------
def _accel_detail(body: dict) -> dict:
"""The agent's own report of which runtime got the GPU, kept on its roster
row so the System view can call a CPU-bound agent degraded (#4410).
Only a dict of {runtime: {device, error?}} is kept, and each value is
reduced to those two short strings: this is written on every lease, by a
client the server does not control. An agent that sends nothing (an
older build) simply has no `accel`, which reads as not-yet-reported.
"""
raw = body.get("accel")
if not isinstance(raw, dict):
return {}
accel = {}
for name, entry in list(raw.items())[:4]:
if not isinstance(entry, dict):
continue
clean = {"device": str(entry.get("device") or "")[:16]}
if entry.get("error"):
clean["error"] = str(entry["error"])[:200]
accel[str(name)[:16]] = clean
return {"accel": accel} if accel else {}
@gpu_bp.route("/jobs/lease", methods=["POST"])
async def lease():
body = await request.get_json(silent=True) or {}
@@ -290,10 +267,7 @@ async def lease():
key=f"agent:{agent_id}",
kind="agent",
display_name="GPU agent" if agent_id == "agent" else f"GPU agent ({agent_id})",
details={
"agent_id": agent_id, "last_call": "lease", "leased": len(jobs),
**_accel_detail(body),
},
details={"agent_id": agent_id, "last_call": "lease", "leased": len(jobs)},
)
ml = await MLSettings.load(session)
# image rows for url/mime in one shot
@@ -373,10 +347,7 @@ async def heartbeat():
key=f"agent:{agent_id}",
kind="agent",
display_name="GPU agent" if agent_id == "agent" else f"GPU agent ({agent_id})",
details={
"agent_id": agent_id, "last_call": "heartbeat", "extended": n,
**_accel_detail(body),
},
details={"agent_id": agent_id, "last_call": "heartbeat", "extended": n},
)
await session.commit()
return jsonify({"extended": n})
-29
View File
@@ -48,17 +48,6 @@ _EDITABLE = (
"process_conflict_threshold",
"embedder_model_name",
"embedder_model_version",
# Discord drop grouping (#388 E2). Operator-facing because the quality bar
# is a judgement no test can settle: too greedy merges distinct pieces, too
# shy leaves a drop scattered.
"discord_grouping_enabled",
"discord_group_max_distance",
"discord_group_window_minutes",
# E3: how long a grouping stays open, and the anti-thrash rule that keeps
# a growing one from monopolising the feed.
"discord_group_close_after_hours",
"discord_group_resurface_min_images",
"discord_group_resurface_cooldown_hours",
*_DETECTOR_FIELDS,
)
@@ -159,24 +148,6 @@ def _validate(p: dict) -> str | None:
return f"process_auto_apply_threshold must be between {AUTO_APPLY_THRESHOLD_MIN} and {AUTO_APPLY_THRESHOLD_MAX}"
if not (0.0 <= float(p["process_conflict_threshold"]) <= 1.0):
return "process_conflict_threshold must be between 0 and 1"
# Discord drop grouping (#388 E2). max_distance is a cosine DISTANCE, so
# unlike the *_threshold family above it is not on the auto-apply scale:
# 0 is identical and 1 is unrelated, and both ends are legal. The upper
# bound is 1.0 rather than AUTO_APPLY_THRESHOLD_MAX for that reason.
if not (0.0 <= float(p["discord_group_max_distance"]) <= 1.0):
return "discord_group_max_distance must be between 0 and 1"
if float(p["discord_group_window_minutes"]) <= 0:
return "discord_group_window_minutes must be > 0"
# A group must stay open at least as long as the drop window it was cut
# with, or the joiner could never reach a message the grouper deferred —
# the two would fight, and the symptom (drops that never grow) would look
# like the predicate failing rather than a settings contradiction.
if float(p["discord_group_close_after_hours"]) * 60 < float(p["discord_group_window_minutes"]):
return "discord_group_close_after_hours must be at least the drop window"
if int(p["discord_group_resurface_min_images"]) < 1:
return "discord_group_resurface_min_images must be >= 1"
if float(p["discord_group_resurface_cooldown_hours"]) < 0:
return "discord_group_resurface_cooldown_hours must be >= 0"
# Embedder model swap (#1190): both must be non-empty. Changing them means a
# different embedding space — the operator must re-embed + retrain after.
for key in ("embedder_model_name", "embedder_model_version"):
-47
View File
@@ -5,8 +5,6 @@ from quart import Blueprint, jsonify, request
from ..extensions import get_session
from ..models import ImportSettings, Post
from ..services import interpreter_client as ic
from ..services.post_association_service import PostAssociationService
from ..services.post_association_service import rescan as association_rescan
from ..services.post_feed_service import PostFeedService
from ..services.source_service import KNOWN_PLATFORMS
from ..utils.text import html_to_plain
@@ -167,48 +165,3 @@ async def set_translation_override(post_id: int):
"translated_source_lang": post.translated_source_lang,
"applied": applied,
})
# --- #388 E5: the announcement review queue -------------------------------
#
# Confirm-only, following the series-suggestion routes (api/tags.py). Nothing
# here links anything on its own: the matcher proposes, the operator decides.
@posts_bp.route("/associations", methods=["GET"])
async def list_associations():
async with get_session() as session:
return jsonify({"items": await PostAssociationService(session).list_pending()})
@posts_bp.route("/associations/<int:association_id>/accept", methods=["POST"])
async def accept_association(association_id: int):
async with get_session() as session:
result = await PostAssociationService(session).accept(association_id)
if result is None:
return _bad("association not found", 404)
await session.commit()
return jsonify(result)
@posts_bp.route("/associations/<int:association_id>/dismiss", methods=["POST"])
async def dismiss_association(association_id: int):
async with get_session() as session:
result = await PostAssociationService(session).dismiss(association_id)
if result is None:
return _bad("association not found", 404)
await session.commit()
return jsonify(result)
@posts_bp.route("/associations/rescan", methods=["POST"])
async def rescan_associations():
"""Manual re-scan. The beat sweep only looks at recent posts (a pair has to
be within the window to exist at all); this is the button for a first run
over a library that predates the feature."""
async with get_session() as session:
# full=True: the button reaches the whole history, which the hourly
# sweep's 48-hour horizon never does.
result = await association_rescan(session, full=True)
await session.commit()
return jsonify(result)
+7 -40
View File
@@ -36,17 +36,9 @@ _EDITABLE_FIELDS = (
"download_validate_files",
"download_schedule_default_seconds",
"download_event_retention_days",
"download_revisit_days",
"download_failure_warning_threshold",
"series_suggest_enabled",
"series_suggest_threshold",
# #388 E5 — the announcement matcher (Patreon teaser ↔ Discord drop).
"discord_link_enabled",
"discord_link_threshold",
"discord_link_window_hours",
"discord_link_auto",
"discord_link_fold_hours",
"discord_family_window_days",
"extdl_mega_enabled",
"extdl_gdrive_enabled",
"extdl_mediafire_enabled",
@@ -57,6 +49,7 @@ _EDITABLE_FIELDS = (
"translation_target_lang",
"translation_min_confidence",
"wip_title_tagging_enabled",
"wip_soft_title_tagging_enabled",
)
# Per-host external-download toggles — all plain booleans, validated uniformly.
@@ -116,12 +109,6 @@ async def update_import_settings():
v = body["download_schedule_default_seconds"]
if not isinstance(v, int) or isinstance(v, bool) or v < 60 or v > 86400:
return _bad_int("download_schedule_default_seconds", 60, 86400)
# 0 is a real value (revisit off), so the floor is 0, not 1 — and the
# ceiling is a year, past which a "tick" is a backfill wearing a hat.
if "download_revisit_days" in body:
v = body["download_revisit_days"]
if not isinstance(v, int) or isinstance(v, bool) or v < 0 or v > 365:
return _bad_int("download_revisit_days", 0, 365)
if "download_event_retention_days" in body:
v = body["download_event_retention_days"]
if not isinstance(v, int) or isinstance(v, bool) or v < 1 or v > 3650:
@@ -163,38 +150,18 @@ async def update_import_settings():
return jsonify(
{"error": "series_suggest_threshold must be a number in [0, 1]"}
), 400
if "discord_link_enabled" in body and not isinstance(
body["discord_link_enabled"], bool
):
return jsonify({"error": "discord_link_enabled must be a boolean"}), 400
if "discord_link_auto" in body and not isinstance(
body["discord_link_auto"], bool
):
return jsonify({"error": "discord_link_auto must be a boolean"}), 400
if "discord_link_threshold" in body:
v = body["discord_link_threshold"]
if not isinstance(v, (int, float)) or isinstance(v, bool) or v < 0 or v > 1:
return jsonify(
{"error": "discord_link_threshold must be a number in [0, 1]"}
), 400
if "discord_link_window_hours" in body:
v = body["discord_link_window_hours"]
if not isinstance(v, (int, float)) or isinstance(v, bool) or v <= 0:
return jsonify(
{"error": "discord_link_window_hours must be a positive number"}
), 400
# Zero is meaningful for both: fold nothing, or reference no variants.
for key in ("discord_link_fold_hours", "discord_family_window_days"):
if key in body:
v = body[key]
if not isinstance(v, (int, float)) or isinstance(v, bool) or v < 0:
return jsonify({"error": f"{key} must be a number >= 0"}), 400
if "wip_title_tagging_enabled" in body and not isinstance(
body["wip_title_tagging_enabled"], bool
):
return jsonify(
{"error": "wip_title_tagging_enabled must be a boolean"}
), 400
if "wip_soft_title_tagging_enabled" in body and not isinstance(
body["wip_soft_title_tagging_enabled"], bool
):
return jsonify(
{"error": "wip_soft_title_tagging_enabled must be a boolean"}
), 400
async with get_session() as session:
row = await ImportSettings.load(session)
+2 -226
View File
@@ -1,18 +1,10 @@
"""FC-3a: CRUD over Source rows. FC-3c adds POST /<id>/check."""
from pathlib import Path
from quart import Blueprint, jsonify, request
from sqlalchemy import func, select
from sqlalchemy import select
from ..extensions import get_session
from ..models import DownloadEvent, MembershipSync, PlatformMembership, Source
from ..services import discord_poster_cleanup as poster_cleanup
from ..services.artist_membership_service import ArtistMembershipService
from ..services.artist_membership_service import rescan as membership_rescan
from ..services.artist_service import ArtistService
from ..services.membership_reconcile import reconcile_all
from ..services.membership_roster import roster_is_fresh, source_for_membership
from ..models import DownloadEvent, Source
from ..services.scheduler_service import active_platform_cooldowns, scheduler_status
from ..services.source_service import (
KNOWN_PLATFORMS,
@@ -204,16 +196,6 @@ async def set_backfill(source_id: int):
rec = await SourceService(session).get(source_id)
if rec is None:
return _bad("not_found", status=404)
# A disabled source must not be armable for a deep walk — the same
# rule /check has carried all along (see `source_disabled` below).
# Arming one anyway is how #4279 happened: the membership sweep had
# stopped Ebi77 as `former_patron`, a deep scan was armed twenty
# minutes later, the walk could not complete without access, and
# the recovery sweep stranded it with a failure count no surface
# could clear — a disabled source is never scheduled again, and
# Retry routes to /check, which refuses it.
if not rec.enabled:
return _bad("source_disabled", detail="enable the source first")
native = uses_native_ingester(rec.platform)
if native:
cred = CredentialService(session, _get_crypto())
@@ -306,209 +288,3 @@ async def check_source(source_id: int):
download_source.delay(source_id)
return jsonify({"download_event_id": event_id, "status": "pending"}), 202
# --- #387 C3: the membership roster's sync state --------------------------
#
# Rule 164's visibility requirement lives here. A roster that failed to sync,
# or never has, must be DISTINGUISHABLE from an account that subscribes to
# nothing — otherwise the reconciliation this unlocks would tell the operator
# to cancel sources they are actively paying for.
@sources_bp.route("/membership-sync", methods=["GET"])
async def membership_sync_status():
async with get_session() as session:
rows = (await session.execute(select(MembershipSync))).scalars().all()
counts = dict(
(await session.execute(
select(PlatformMembership.platform, func.count())
.group_by(PlatformMembership.platform)
)).all()
)
return jsonify({"platforms": [
{
"platform": r.platform,
"last_attempt_at": r.last_attempt_at.isoformat() if r.last_attempt_at else None,
# NULL here means NEVER, and the UI must say so in words. Rendering
# it as 0 or as "-" is the exact conflation this endpoint exists to
# prevent.
"last_success_at": r.last_success_at.isoformat() if r.last_success_at else None,
"last_count": r.last_count,
"last_error_type": r.last_error_type,
"last_error_message": r.last_error_message,
# Whether a CONCLUSION may be drawn from this roster — not merely
# whether it looks recent. C4 gates on this, and it is computed
# server-side so no caller can forget to.
"fresh": roster_is_fresh(r),
"known_memberships": counts.get(r.platform, 0),
}
for r in sorted(rows, key=lambda r: r.platform)
]})
@sources_bp.route("/membership-sync", methods=["POST"])
async def trigger_membership_sync():
"""Run the roster sweep now.
The beat schedule runs daily, which is right for a billing-cycle fact but
far too slow when the operator has just connected a credential and wants to
see whether it works. Queued rather than run inline: it crosses the network
to an external service and the request path is not where that belongs.
"""
from ..tasks.maintenance import sync_memberships
sync_memberships.delay()
return jsonify({"queued": True})
# --- #388 E4: creator/membership suggestions ------------------------------
#
# Confirm-only. Accepting ADDS A SOURCE under the existing artist — it never
# merges two artists, because adding a source is trivially undone and a wrong
# merge silently mixes two creators' work with nothing left to separate them by.
@sources_bp.route("/membership-suggestions", methods=["GET"])
async def list_membership_suggestions():
async with get_session() as session:
return jsonify({"items": await ArtistMembershipService(session).list_pending()})
@sources_bp.route("/membership-suggestions/<int:sid>/accept", methods=["POST"])
async def accept_membership_suggestion(sid: int):
async with get_session() as session:
result = await ArtistMembershipService(session).accept(sid)
if result is None:
return _bad("suggestion_not_found", status=404)
await session.commit()
return jsonify(result)
@sources_bp.route("/membership-suggestions/<int:sid>/dismiss", methods=["POST"])
async def dismiss_membership_suggestion(sid: int):
async with get_session() as session:
result = await ArtistMembershipService(session).dismiss(sid)
if result is None:
return _bad("suggestion_not_found", status=404)
await session.commit()
return jsonify(result)
@sources_bp.route("/membership-suggestions/rescan", methods=["POST"])
async def rescan_membership_suggestions():
async with get_session() as session:
result = await membership_rescan(session)
await session.commit()
return jsonify(result)
# --- #387 C4: reconciling the roster against what FC actually tracks -------
#
# Asymmetric on purpose. The "you subscribe but FC doesn't follow it" direction
# carries a per-row action, because adding a source is the reversible half. The
# "FC follows it but your roster doesn't show it" direction is REPORT ONLY by
# the operator's decision (2026-09-11): it says what it sees and links to the
# Subscriptions row, and offers no one-click disable.
@sources_bp.route("/reconciliation", methods=["GET"])
async def reconciliation():
async with get_session() as session:
return jsonify(await reconcile_all(session))
@sources_bp.route("/reconciliation/adopt", methods=["POST"])
async def adopt_membership():
"""Start tracking a creator the roster says the account already pays for.
One row, one click, never a sweep side effect: adding a source commits disk,
worker time and rate budget, and unwinding it means deleting files.
"""
body = await request.get_json()
if not isinstance(body, dict):
return _bad("invalid_body", status=400)
membership_id = body.get("membership_id")
if not isinstance(membership_id, int):
return _bad("membership_id_required", status=400)
async with get_session() as session:
membership = await session.get(PlatformMembership, membership_id)
if membership is None:
return _bad("membership_not_found", status=404)
if not membership.url:
return _bad("membership_has_no_url", status=400)
existing = await source_for_membership(session, membership)
if existing is not None:
# The operator got there by another route between the page load and
# the click. That is them being ahead of us, not an error.
return jsonify({"already_tracked": existing.id})
# The sweep already captured the creator's real display name, so the
# artist gets its true name with NO lookup on the request path. Task
# #1293 asked for `resolve_display_name` here; the roster satisfies that
# concern earlier in the pipeline than #1293 expected, which also keeps
# this route off the network entirely (rule 164). The vanity is the
# fallback, never the preferred value.
name = membership.display_name or membership.vanity_or_none()
if not name:
return _bad("membership_has_no_name", status=400)
artist, _created = await ArtistService(session).find_or_create(name)
try:
record = await SourceService(session).create(
artist_id=artist.id,
platform=membership.platform,
url=membership.url,
)
except DuplicateSourceError as exc:
return jsonify({"already_tracked": exc.existing_id})
artist_id = artist.id
return jsonify({"source_id": record.id, "artist_id": artist_id}), 201
# -- Discord: remove posts by people outside the poster list (#4486) --------------
_IMAGES_ROOT = Path("/images")
async def _poster_cleanup_preview(source_id: int):
async with get_session() as session:
return await session.run_sync(
lambda s: poster_cleanup.preview(s, source_id=source_id)
)
@sources_bp.route("/<int:source_id>/discord/other-posters", methods=["GET"])
async def other_posters_preview(source_id: int):
"""What removing other posters' posts would delete. Nothing is touched."""
try:
projection = await _poster_cleanup_preview(source_id)
except LookupError:
return _bad("not_found", status=404)
except poster_cleanup.PosterCleanupError as exc:
return _bad("not_applicable", detail=str(exc), status=409)
projection["confirm_token"] = poster_cleanup.confirm_token(projection)
return jsonify(projection)
@sources_bp.route("/<int:source_id>/discord/other-posters/remove", methods=["POST"])
async def other_posters_remove(source_id: int):
"""Delete them. `confirm` must be the token of the preview the operator saw:
a poster list edited since then changes the set, and the token with it."""
body = await request.get_json(silent=True) or {}
try:
projection = await _poster_cleanup_preview(source_id)
except LookupError:
return _bad("not_found", status=404)
except poster_cleanup.PosterCleanupError as exc:
return _bad("not_applicable", detail=str(exc), status=409)
expected = poster_cleanup.confirm_token(projection)
if body.get("confirm") != expected:
return _bad("confirm_mismatch", expected=expected)
async with get_session() as session:
result = await session.run_sync(
lambda s: poster_cleanup.apply(s, source_id=source_id, images_root=_IMAGES_ROOT)
)
return jsonify(result)
+7 -16
View File
@@ -21,22 +21,18 @@ from ..config import get_config
from ..extensions import get_session
from ..models import TaskRun
from ..services.scheduler_service import scheduler_status
from ..services.worker_lanes import LANES
system_activity_bp = Blueprint(
"system_activity", __name__, url_prefix="/api/system/activity",
)
# Every queue, grouped by the lane that consumes it. DERIVED from
# `worker_lanes.LANES` (milestone 422 step 1) rather than written out:
# this was a hand-kept third copy of "which queues exist", alongside
# celery_app.task_routes and service_roster.ROLE_NAMES, and its own comment
# admitted the coupling — "must match celery_app.task_routes".
#
# The rendered ORDER changes with this: lane order rather than the previous
# hand-chosen one. That is the better grouping for a lane-oriented UI, and
# queues with no LLEN response still show as null rather than absent.
_QUEUE_NAMES = tuple(q for lane in LANES for q in lane.queues)
# Canonical queue order — must match celery_app.task_routes. UI renders
# in this order; queues with no LLEN response show as null rather than
# absent.
_QUEUE_NAMES = (
"default", "import", "thumbnail", "ml",
"download", "scan", "maintenance", "maintenance_long",
)
# Cache module-level so all requests share the cache between polls.
# Tests can reset via direct dict mutation if needed.
@@ -152,8 +148,6 @@ async def list_runs():
queue=<name> filter to one queue
status=<status> filter to one status (running/ok/error/timeout/retry)
task=<substr> case-insensitive substring match on task_name
celery_task_id=<id> exactly one run — how a page follows a job it
started without having to know its lane
limit=<int> default 50, max 200
before_id=<int> cursor for keyset pagination
@@ -169,7 +163,6 @@ async def list_runs():
queue = request.args.get("queue")
status = request.args.get("status")
task = request.args.get("task")
celery_task_id = request.args.get("celery_task_id")
before_id_raw = request.args.get("before_id")
before_id = int(before_id_raw) if before_id_raw else None
@@ -179,8 +172,6 @@ async def list_runs():
stmt = stmt.where(TaskRun.queue == queue)
if status:
stmt = stmt.where(TaskRun.status == status)
if celery_task_id:
stmt = stmt.where(TaskRun.celery_task_id == celery_task_id)
if task:
# Task names contain literal underscores (download_source,
# vacuum_analyze) — escape LIKE wildcards so a search for
+28 -315
View File
@@ -1,5 +1,5 @@
"""/api/system/backup: database dumps, the off-site destination and its media
backup, restores, test restores, and the run history.
"""FC-3h: /api/system/backup — create/list/restore/delete/tag for
DB + image backups.
Read endpoints are public on FC (operator-facing internal API; same
posture as /api/system/activity). Write endpoints take a typed
@@ -20,12 +20,13 @@ system_backup_bp = Blueprint(
"system_backup", __name__, url_prefix="/api/system/backup",
)
_KINDS = frozenset({"db", "media", "restore", "reconcile", "test"})
_KINDS = frozenset({"db", "images"})
_TAG_MAX_LEN = 64
_BACKUP_SETTINGS_FIELDS = (
"backup_db_nightly_enabled",
"backup_db_nightly_hour_utc",
"backup_db_keep_last_n",
"backup_images_keep_last_n",
)
@@ -43,13 +44,11 @@ def _row_to_dict(r: BackupRun) -> dict:
if r.finished_at and r.started_at else None
),
"sql_path": r.sql_path,
"tar_path": r.tar_path,
"size_bytes": r.size_bytes,
"error": r.error,
"restored_from_id": r.restored_from_id,
"manifest": r.manifest or {},
# Where an off-site dump went ({endpoint, bucket, key}); None for a
# backup kept on this server.
"offsite": (r.manifest or {}).get("offsite"),
}
@@ -79,6 +78,10 @@ def _validate_backup_settings_patch(body: dict):
v = body["backup_db_keep_last_n"]
if not isinstance(v, int) or isinstance(v, bool) or not (1 <= v <= 365):
return _bad("invalid_value", detail="backup_db_keep_last_n must be 1..365")
if "backup_images_keep_last_n" in body:
v = body["backup_images_keep_last_n"]
if not isinstance(v, int) or isinstance(v, bool) or not (1 <= v <= 100):
return _bad("invalid_value", detail="backup_images_keep_last_n must be 1..100")
return None
@@ -93,173 +96,14 @@ async def trigger_db_backup():
return jsonify({"status": "dispatched"}), 202
@system_backup_bp.route("/media", methods=["POST"])
async def trigger_media_backup():
"""Send the images and attachments not yet in the off-site bucket. Refused
when no destination is set up; a run already going is left to finish."""
from ..services import backup_destination as dest
async with get_session() as session:
row = await ImportSettings.load(session)
if not dest.describe(row)["configured"]:
return _bad("no_destination",
detail="set up an off-site destination before sending files")
from ..tasks.backup import backup_media_task
backup_media_task.delay(triggered_by="manual")
return jsonify({"status": "dispatched"}), 202
@system_backup_bp.route("/media", methods=["GET"])
async def media_coverage():
"""How much of the library is in the off-site bucket: files and bytes,
sent and total, each file counted once however many posts hold it."""
from ..services import backup_destination as dest
from ..services import backup_media
async with get_session() as session:
row = await ImportSettings.load(session)
f = dest.fields_of(row)
configured = dest.describe(row)["configured"]
tid = backup_media.destination_id(f.endpoint, f.bucket) if configured else None
counts = await session.run_sync(lambda s: backup_media.coverage(s, tid))
return jsonify({"configured": configured, **counts})
# ── Restoring from the off-site bucket ──────────────────────────────────────
async def _saved_target():
"""(target, None) for the saved destination, or (None, error response)."""
from ..services import backup_destination as dest
from ..services.backup_s3 import S3Error
async with get_session() as session:
row = await ImportSettings.load(session)
try:
target = dest.saved_target(row)
except S3Error as exc:
return None, _bad("destination_unreadable", detail=str(exc))
if target is None:
return None, _bad("no_destination",
detail="set up an off-site destination before restoring from it")
return target, None
async def _busy_reason() -> str | None:
from ..tasks.backup import backup_busy
async with get_session() as session:
return await session.run_sync(backup_busy)
def restore_token(ts: str) -> str:
"""What the person types to confirm replacing the database with the dump
taken at `ts`."""
return f"restore-offsite-{ts}"
@system_backup_bp.route("/offsite/dumps", methods=["GET"])
async def list_offsite_dumps():
"""The database dumps in the off-site bucket, newest first, each with
the confirmation a restore of it asks for."""
import asyncio
from ..services import backup_restore
from ..services.backup_s3 import S3Error
target, err = await _saved_target()
if err:
return err
try:
dumps = await asyncio.to_thread(backup_restore.list_dumps, target)
except S3Error as exc:
return _bad("destination_unreachable", detail=str(exc), status=502)
return jsonify({
"dumps": [
{"key": d.key, "ts": d.ts, "size_bytes": d.size_bytes, "counted": d.counted,
"confirm": restore_token(d.ts)}
for d in dumps
],
# A test restore needs about this much free space on the database's
# disk while it runs.
"database_bytes": await _database_bytes(),
})
async def _database_bytes() -> int:
from sqlalchemy import text
async with get_session() as session:
return (await session.execute(
text("SELECT pg_database_size(current_database())"))).scalar_one()
async def _restore_test_blocked() -> str | None:
from ..tasks.backup import restore_test_blocked
async with get_session() as session:
return await session.run_sync(restore_test_blocked)
@system_backup_bp.route("/offsite/test", methods=["POST"])
async def restore_test_offsite():
"""Test a dump from the bucket without touching this install: it is
loaded into a temporary database, checked table by table, and dropped.
Body: {key}, a dump's key from /offsite/dumps, or {} for the newest.
Needs no confirmation; refused while a restore or another test is going."""
@system_backup_bp.route("/images", methods=["POST"])
async def trigger_images_backup():
body = await request.get_json(silent=True) or {}
key = body.get("key")
if key is not None and (not isinstance(key, str) or not key.startswith("database/")):
return _bad("invalid_key", detail="key must be a dump's key from /offsite/dumps")
_, err = await _saved_target()
if err:
return err
busy = await _restore_test_blocked()
if busy:
return _bad("busy", detail=f"{busy}; try again when it has finished", status=409)
from ..tasks.backup import restore_test_task
restore_test_task.delay(dump_key=key)
return jsonify({"status": "dispatched"}), 202
@system_backup_bp.route("/offsite/restore", methods=["POST"])
async def restore_from_offsite():
"""Replace this install's database with a dump from the bucket, then
fetch the library's files back. Body: {key, confirm}, where confirm is
the dump's token from /offsite/dumps. Refused while a backup or another
restore is going."""
body = await request.get_json(silent=True) or {}
key = body.get("key")
if not isinstance(key, str) or not key.startswith("database/"):
return _bad("invalid_key", detail="key must be a dump's key from /offsite/dumps")
ts = key.removeprefix("database/fabledcurator-").removesuffix(".dump")
expected = restore_token(ts)
if body.get("confirm") != expected:
return _bad("confirm_mismatch", detail=f"confirm must equal {expected!r}",
expected=expected)
_, err = await _saved_target()
if err:
return err
busy = await _busy_reason()
if busy:
return _bad("busy", detail=f"{busy}; try again when it has finished", status=409)
from ..tasks.backup import restore_offsite_task
restore_offsite_task.delay(dump_key=key)
return jsonify({"status": "dispatched"}), 202
@system_backup_bp.route("/offsite/fetch-files", methods=["POST"])
async def fetch_missing_files():
"""Fetch every library file that is missing from disk back from the
bucket, leaving the database as it is."""
_, err = await _saved_target()
if err:
return err
busy = await _busy_reason()
if busy:
return _bad("busy", detail=f"{busy}; try again when it has finished", status=409)
from ..tasks.backup import restore_offsite_task
restore_offsite_task.delay(files_only=True)
tag = _validate_tag(body.get("tag"))
if isinstance(tag, tuple):
return tag
from ..tasks.backup import backup_images_task
backup_images_task.delay(tag=tag, triggered_by="manual")
return jsonify({"status": "dispatched"}), 202
@@ -331,16 +175,10 @@ async def trigger_restore(run_id: int):
row = await session.get(BackupRun, run_id)
if row is None:
return _bad("not_found", status=404)
if (row.manifest or {}).get("offsite"):
if row.status != "ok":
return _bad(
"not_restorable",
detail=("this dump is kept in the off-site bucket, not on this server, "
"so it can't be restored from here"),
)
if row.kind != "db" or row.status != "ok" or not row.sql_path:
return _bad(
"not_restorable",
detail="only a finished database dump kept on this server can be restored here",
detail=f"source backup status={row.status!r}; only 'ok' rows are restorable",
)
expected = f"restore-{row.kind}-{row.id}"
if supplied != expected:
@@ -349,29 +187,15 @@ async def trigger_restore(run_id: int):
detail=f"confirm must equal {expected!r}",
expected=expected,
)
kind = row.kind
from ..tasks.backup import restore_db_task
restore_db_task.delay(source_backup_run_id=run_id)
return jsonify({"status": "dispatched", "kind": "db"}), 202
@system_backup_bp.route("/runs/<int:run_id>/test", methods=["POST"])
async def restore_test_local(run_id: int):
"""Test a database dump kept on this server, the same way as one in the
bucket. Needs no confirmation."""
async with get_session() as session:
row = await session.get(BackupRun, run_id)
if row is None:
return _bad("not_found", status=404)
if row.kind != "db" or row.status != "ok" or not row.sql_path:
return _bad("not_testable",
detail="only a finished database dump on this server can be tested here")
busy = await _restore_test_blocked()
if busy:
return _bad("busy", detail=f"{busy}; try again when it has finished", status=409)
from ..tasks.backup import restore_test_task
restore_test_task.delay(source_backup_run_id=run_id)
return jsonify({"status": "dispatched"}), 202
if kind == "db":
from ..tasks.backup import restore_db_task
restore_db_task.delay(source_backup_run_id=run_id)
else: # 'images' (the only other value _KINDS allows via the trigger path)
from ..tasks.backup import restore_images_task
restore_images_task.delay(source_backup_run_id=run_id)
return jsonify({"status": "dispatched", "kind": kind}), 202
@system_backup_bp.route("/runs/<int:run_id>", methods=["DELETE"])
@@ -392,7 +216,7 @@ async def delete_run(run_id: int):
)
from ..services import backup_service
backup_service.unlink_artifact_files(
sql_path=row.sql_path,
sql_path=row.sql_path, tar_path=row.tar_path,
manifest_path=(row.manifest or {}).get("manifest_path"),
)
await session.delete(row)
@@ -408,121 +232,10 @@ async def get_settings():
"backup_db_nightly_enabled": row.backup_db_nightly_enabled,
"backup_db_nightly_hour_utc": row.backup_db_nightly_hour_utc,
"backup_db_keep_last_n": row.backup_db_keep_last_n,
"backup_images_keep_last_n": row.backup_images_keep_last_n,
})
@system_backup_bp.route("/destination", methods=["GET"])
async def get_destination():
"""The off-site destination as the settings screen shows it. The secret
key is never returned, only whether one is saved."""
from ..services import backup_destination as dest
async with get_session() as session:
row = await ImportSettings.load(session)
return jsonify(dest.describe(row))
@system_backup_bp.route("/destination", methods=["PATCH"])
async def patch_destination():
"""Save the destination. Body {endpoint, bucket, access_key, region,
secret_key, upload_limit_mbps}; an empty field clears it, and a missing
upload_limit_mbps keeps the saved limit. A missing or empty secret_key
keeps the saved one, so the screen can save the other fields without the
secret being typed again."""
from ..services import backup_destination as dest
body = await request.get_json(silent=True)
if not isinstance(body, dict):
return _bad("invalid_body", detail="body must be a JSON object")
fields, why = dest.clean_fields(body)
if fields is None:
return _bad("invalid_destination", detail=why)
secret = body.get("secret_key")
if secret is not None and not isinstance(secret, str):
return _bad("invalid_destination", detail="secret_key must be text")
limit = body.get("upload_limit_mbps")
if limit is not None and (
not isinstance(limit, int) or isinstance(limit, bool) or not 0 <= limit <= 100_000
):
return _bad("invalid_destination",
detail="upload_limit_mbps must be a whole number from 0 (no limit) up")
async with get_session() as session:
row = await ImportSettings.load(session)
if limit is not None:
row.backup_upload_limit_mbps = limit
row.backup_s3_endpoint = fields.endpoint
row.backup_s3_bucket = fields.bucket
row.backup_s3_access_key = fields.access_key
row.backup_s3_region = fields.region
if secret and secret.strip():
row.backup_s3_secret_sealed = dest.seal(secret.strip())
await session.commit()
return jsonify(dest.describe(row))
@system_backup_bp.route("/destination", methods=["DELETE"])
async def delete_destination():
"""Forget the destination, secret included. What was already sent stays in
the bucket: FabledCurator never deletes there."""
from ..services import backup_destination as dest
async with get_session() as session:
row = await ImportSettings.load(session)
row.backup_s3_endpoint = None
row.backup_s3_bucket = None
row.backup_s3_access_key = None
row.backup_s3_secret_sealed = None
row.backup_s3_region = dest.DEFAULT_REGION
await session.commit()
return jsonify(dest.describe(row))
@system_backup_bp.route("/destination/test", methods=["POST"])
async def try_destination():
"""Reach the bucket with the fields in the body (the form as typed, saved
or not); a missing secret_key uses the saved one. Lists the bucket and
writes nothing. Answers {ok, message} either way: a destination that
can't be reached is a result to show, not an API error."""
import asyncio
from ..services import backup_destination as dest
from ..services.backup_s3 import S3Error
from ..services.credential_crypto import InvalidCredentialBlob
body = await request.get_json(silent=True) or {}
if not isinstance(body, dict):
return _bad("invalid_body", detail="body must be a JSON object")
fields, why = dest.clean_fields(body)
if fields is None:
return jsonify({"ok": False, "message": f"Not tested: {why}."})
secret = body.get("secret_key")
secret = secret.strip() if isinstance(secret, str) else ""
if not secret:
async with get_session() as session:
row = await ImportSettings.load(session)
sealed = row.backup_s3_secret_sealed
if sealed:
try:
secret = dest.unseal(sealed)
except InvalidCredentialBlob:
return jsonify({"ok": False, "message": (
"Not tested: the saved secret key can't be opened on this server. "
"Enter the secret key again."
)})
target = dest.target_for(fields, secret or None)
if target is None:
missing = [name for name, v in (
("address", fields.endpoint), ("bucket", fields.bucket),
("access key", fields.access_key), ("secret key", secret),
) if not v]
return jsonify({"ok": False, "message": f"Not tested: fill in the {', '.join(missing)}."})
try:
return jsonify(await asyncio.to_thread(dest.check_target, target))
except S3Error as exc:
return jsonify({"ok": False, "message": str(exc)})
@system_backup_bp.route("/settings", methods=["PATCH"])
async def patch_settings():
body = await request.get_json(silent=True)
+15 -78
View File
@@ -26,6 +26,7 @@ a true statement.
from __future__ import annotations
import asyncio
import logging
import time
from datetime import UTC, datetime
@@ -35,8 +36,9 @@ from sqlalchemy import select, text
from ..config import get_config
from ..extensions import get_session
from ..models import ServiceSeen
from ..services import backup_health
from ..services.worker_lanes import SWEEP_PERIOD_SECONDS
from ..services.service_roster import refresh_if_stale
log = logging.getLogger(__name__)
system_health_bp = Blueprint("system_health", __name__, url_prefix="/api/system")
@@ -51,36 +53,14 @@ system_health_bp = Blueprint("system_health", __name__, url_prefix="/api/system"
STALE_AFTER_SECONDS = 90
DOWN_AFTER_SECONDS = 300
# The celery roster is written by `size_worker_lanes` and by nothing else, so
# these thresholds are only meaningful against ITS cadence. Asserted at import
# rather than left to a reader, because this is precisely the comparison that
# was never made for the GPU agent: its lease poll backed off to 900s while
# the roster called it stopped at 300s, and both numbers were individually
# correct, in different directions, in different files (lesson #4355).
#
# Two clear sweeps before a part is even called STALE. One missed tick is
# routine — the sweep rides the maintenance queue and does an inspect that can
# take eleven seconds — and must not turn the page yellow.
_SWEEPS_BEFORE_STALE = 2
assert STALE_AFTER_SECONDS >= SWEEP_PERIOD_SECONDS * _SWEEPS_BEFORE_STALE, (
f"a {SWEEP_PERIOD_SECONDS}s sweep cannot keep a roster fresh against a "
f"{STALE_AFTER_SECONDS}s stale threshold: raise the threshold or shorten "
f"the sweep"
)
# Probes cross a process boundary, so they carry deadlines. A hung Postgres
# must make this endpoint say "postgres: down", not hang alongside it.
PROBE_TIMEOUT_SECONDS = 2.0
_OK, _STALE, _DOWN, _UNKNOWN = "ok", "stale", "down", "unknown"
# Checking in, but working at a fraction of its speed: a GPU agent whose
# runtimes fell back to the CPU (#4410). Below stale — a part that may have
# stopped is the more urgent question — and above unknown, because this one
# IS known to be wrong.
_DEGRADED = "degraded"
# Worst-first, so an overall verdict is just the max.
_SEVERITY = {_OK: 0, _UNKNOWN: 1, _DEGRADED: 2, _STALE: 3, _DOWN: 4}
_SEVERITY = {_OK: 0, _UNKNOWN: 1, _STALE: 2, _DOWN: 3}
def _age_state(age_seconds: float) -> str:
@@ -106,39 +86,6 @@ def _describe_learned(name: str, state: str, age: float, details: dict) -> str:
return f"{name} has not checked in for {ago} — treat it as stopped"
def _cpu_runtimes(details: dict) -> list[str]:
"""The runtimes an agent reported as NOT on the GPU, with why.
Both torch and onnxruntime fall back to the CPU without raising, so an
agent in that state leases, works and checks in exactly like a healthy
one. On 2026-09-24 one had been doing so since a driver update left a
stale CDI spec; the only sign was a line in the agent's own log.
"""
accel = details.get("accel")
if not isinstance(accel, dict):
return []
out = []
for name, entry in sorted(accel.items()):
if not isinstance(entry, dict) or entry.get("device") == "cuda":
continue
why = entry.get("error") or entry.get("device") or "unknown"
out.append(f"{name} ({why})")
return out
def _learned_state(name: str, state: str, age: float, details: dict) -> tuple[str, str]:
"""A roster row's state and its sentence, degraded included."""
if state == _OK:
cpu = _cpu_runtimes(details)
if cpu:
return _DEGRADED, (
f"{name} is running on the CPU — not on the GPU: {'; '.join(cpu)}. "
"After a driver update, regenerate the agent host's CDI spec "
"(agent README)."
)
return state, _describe_learned(name, state, age, details)
async def _probe_postgres(session) -> dict:
started = time.monotonic()
try:
@@ -204,41 +151,31 @@ async def system_health():
parts.append(pg)
if pg["state"] == _OK:
# A PURE READ since 2026-09-23. This used to refresh the celery
# roster here, rate-limited to once per 20s — so the roster only
# advanced while somebody had a browser open, and a broadcast rode
# on a request. `size_worker_lanes` writes it now, on a timer, and
# the assertion below is what keeps that cadence honest.
# Rate-limited inside; see service_roster on why the web process
# is the right observer.
try:
await refresh_if_stale(session)
await session.commit()
except Exception: # noqa: BLE001
log.warning("system health: roster refresh failed", exc_info=True)
rows = (
await session.execute(select(ServiceSeen).order_by(ServiceSeen.display_name))
).scalars().all()
for row in rows:
age = (now - row.last_seen_at).total_seconds()
state, detail = _learned_state(
row.display_name, _age_state(age), age, row.details or {},
)
state = _age_state(age)
parts.append({
"key": row.key,
"kind": row.kind,
"name": row.display_name,
"state": state,
"detail": detail,
"detail": _describe_learned(row.display_name, state, age, row.details or {}),
"last_seen_at": row.last_seen_at.isoformat(),
"first_seen_at": row.first_seen_at.isoformat(),
**{k: v for k, v in (row.details or {}).items() if k != "agent_id"},
})
# Whether the backups are keeping up. Read like the rest, and a
# failure to read it is reported as unknown rather than raised.
try:
backup = await session.run_sync(lambda s: backup_health.backup_part(s, now))
except Exception as exc: # noqa: BLE001 — a probe reports, it never raises
backup = {"key": "backup", "kind": "backup", "name": "Backups",
"state": _UNKNOWN,
"detail": f"could not be checked: {type(exc).__name__}"}
if backup is not None:
parts.append(backup)
parts.append(await _probe_redis())
overall = max((p["state"] for p in parts), key=lambda s: _SEVERITY[s], default=_UNKNOWN)
-170
View File
@@ -1,170 +0,0 @@
"""Worker lanes: what each is doing, and the dial that changes it.
Milestone 422 step 2. The write half of a surface `api/system_activity.py`
only reads.
## Why this is a separate blueprint
`system_activity` says in its own first line that it is read-only, and it
answers a different question: its `/workers` is keyed on celery HOSTNAME and
reports which nodes answered. That stays as it is — the existing
SystemActivityTab consumes it.
This is keyed on LANE, joins the stored cap to the live pool, and accepts
writes. Two endpoints answering "which celery processes exist" and "how much
work is each lane allowed to do" are not the same endpoint, and folding the
second into the first would make a read-only module a write one.
"""
from __future__ import annotations
from datetime import UTC, datetime
from functools import partial
from quart import Blueprint, current_app, jsonify, request
from ..extensions import get_session
from ..services.worker_control import (
LaneUpdateRefused,
lane_settings,
lane_view,
push_lane_cap,
store_lane_cap,
)
from ..services.worker_lanes import (
LANES_BY_NAME,
SWEEP_PERIOD_SECONDS,
Lane,
derived_ceiling,
)
from ._responses import error_response as _bad
workers_bp = Blueprint("workers", __name__, url_prefix="/api/system/workers")
@workers_bp.route("", methods=["GET"])
async def list_lanes():
"""Every lane: its cap, the ceiling above it, and what is live.
Response: {lanes: [...], fetched_at: iso8601}
One database read, and NO broker call. Operator, 2026-09-23: *"there is a
repull every time this page loads — is there a reason this info isn't
being tracked in the background and stored in some way?"*
It used to inspect the broker here, four broadcasts on an eleven-second
budget, four times a minute per open tab — while `size_worker_lanes` was
already inspecting on a timer and discarding the same numbers. The sweep
stores them now (`worker_lane_sample`) and this reads them.
So the live figures are up to `SWEEP_PERIOD_SECONDS` old, and each lane
carries the `measured_at` that says so. `sweep_period_seconds` is returned
alongside, so the UI can explain the age without hard-coding the cadence
in a second place.
"""
async with get_session() as session:
settings = await lane_settings(session)
return jsonify({
"lanes": lane_view(settings),
"fetched_at": datetime.now(UTC).isoformat(),
"sweep_period_seconds": SWEEP_PERIOD_SECONDS,
})
@workers_bp.route("/<name>", methods=["POST"])
async def update_lane(name: str):
"""Set a lane's cap. Stores it, answers, and makes the lane follow after.
ONE field, since 2026-09-23. It used to take `slots`, `slots_cap`,
`enabled` and `autoscale`; how many workers are running is now a
measurement the sizing pass owns, and `enabled` is `cap > 0`.
## The reply does not wait for the lane
Operator, 2026-09-23: *"when the number is changed the change should be
queued so that it isn't blocking of the webui or the system itself. we
shouldn't have to wait for the validation live."*
So the request does exactly one thing that can be slow — a row update —
and hands the broker work to a background task. Turning a lane off is
four `cancel_consumer` messages and a resize; lowering a cap is an
`inspect` on an eleven-second budget. Both used to happen between the
click and the response, with the stepper disabled the whole time.
Nothing is lost by not waiting: the cap in the database is what the
system obeys, the sizing pass re-reads it every minute, and the table
polls, so the live columns catch up on their own. If the web process dies
before the background task runs, that sweep is the backstop — which is
the same guarantee the awaited version had, since a push could fail
there too.
Refusals still happen inline, because they are decided from the value and
the machine's ceiling alone and never touch the broker:
* **400** — the value is not allowed (negative, or above what this
container can hold). Nothing was stored. The body carries `detail`,
which is the sentence the UI shows; a refused control with no reason
reads as a bug.
"""
lane = LANES_BY_NAME.get(name)
if lane is None:
return _bad("unknown_lane", detail=name, known=sorted(LANES_BY_NAME))
body = await request.get_json()
if not isinstance(body, dict):
return _bad("invalid_body", detail="body must be a JSON object")
if "slots_cap" not in body:
return _bad("invalid_body", detail="give slots_cap")
value = body["slots_cap"]
# Rejected rather than coerced: `True` is an int in Python, and silently
# reading it as a cap of 1 would be a control that appears to work and
# sets something nobody asked for.
if not isinstance(value, int) or isinstance(value, bool):
return _bad("invalid_body", detail="slots_cap must be an integer")
# Store, close the session, THEN hand off. The session must not be held
# across broker work — that is what made this page block the whole site
# (see `worker_control.LaneSettings`) — and now the request does not wait
# for that work either.
async with get_session() as session:
try:
was_cap = await store_lane_cap(session, lane, value)
except LaneUpdateRefused as exc:
return _bad("refused", detail=str(exc))
_schedule_push(lane, value, was_cap)
return jsonify({
"name": lane.name,
"slots_cap": value,
"ceiling": derived_ceiling(lane),
"enabled": value > 0,
# The value is stored; the live lane is being told separately. The UI
# patches its row from this and lets the next poll bring the live
# columns, rather than refetching and paying for an inspect it just
# avoided.
"queued": True,
# Raising the cap off zero is what downloads the model (step 6), and
# the background task does it. Reported here so the UI can say a
# download has started rather than leaving the operator to wonder why
# a lane they just turned on is busy.
"fetching_models": value > 0 and was_cap == 0 and bool(lane.models),
})
def _schedule_push(lane: Lane, slots_cap: int, was_cap: int) -> None:
"""Run the live push after the response has gone out.
A seam, not an abstraction: it is one call, and it exists so the tests can
hold the push still — a background task that outlived a test's patches
would reach the real broker during teardown.
Quart tracks the task on the app and awaits it at shutdown, so an
in-flight push survives a graceful restart. `partial` rather than passing
`was_cap=` through `add_background_task`, so nothing depends on how that
forwards keyword arguments.
"""
current_app.add_background_task(
partial(push_lane_cap, lane, slots_cap, was_cap=was_cap)
)
-77
View File
@@ -1,77 +0,0 @@
"""Celery beat that remembers when each job last ran — from task_run, not a file.
Celery's default PersistentScheduler keeps its memory in a shelve file in the
working directory. Nothing mounts that directory, so every container recreate
forgets it, and a scheduler that remembers nothing seeds every entry with
`last_run_at = now`: each job waits a FULL interval after startup. A daily job
therefore needs 24 hours without a redeploy to fire. Since the one-container
image (172e33d) every redeploy restarts beat, and on 2026-09-24 no daily or
weekly job had run since the 21st (#4408).
task_run already records every task that starts (celery_signals), indexed on
(task_name, started_at DESC), and prune_task_runs keeps the newest row of each
task however old it is. So on startup each entry takes its last_run_at from
there:
- a job that is overdue runs at once;
- a job that is not due waits only the remainder of its interval;
- a job that has never run is due now.
Beat keeps last_run_at in memory from then on, as the default scheduler does;
only the startup seed changes. If the database cannot be read, the entries keep
Celery's own default rather than beat failing to start.
"""
from __future__ import annotations
import logging
from datetime import UTC, datetime
from celery.beat import Scheduler
from sqlalchemy import func, select
log = logging.getLogger(__name__)
# Seed for a job with no recorded run: far enough back that any interval or
# crontab reads as due.
NEVER = datetime(2000, 1, 1, tzinfo=UTC)
def last_runs(session, task_names: list[str]) -> dict[str, datetime]:
"""The latest recorded start of each task, by task name."""
from .models import TaskRun
if not task_names:
return {}
rows = session.execute(
select(TaskRun.task_name, func.max(TaskRun.started_at))
.where(TaskRun.task_name.in_(sorted(set(task_names))))
.group_by(TaskRun.task_name)
).all()
return dict(rows)
def seed(entries, last: dict[str, datetime]) -> None:
"""Set each entry's last_run_at from `last`; a task with none is due now."""
for entry in entries:
entry.last_run_at = last.get(entry.task, NEVER)
class TaskRunScheduler(Scheduler):
"""An in-memory beat seeded from task_run history at startup."""
def setup_schedule(self):
super().setup_schedule()
try:
from .tasks._sync_engine import sync_session_factory
with sync_session_factory()() as session:
last = last_runs(session, [e.task for e in self.schedule.values()])
except Exception:
log.exception("beat: could not read task_run; every job waits a full interval")
return
seed(self.schedule.values(), last)
due = sum(1 for e in self.schedule.values() if e.is_due()[0])
log.info(
"beat: seeded %d job(s) from task_run, %d due now",
len(self.schedule), due,
)
+6 -79
View File
@@ -4,7 +4,7 @@ Queues:
import — filesystem import path (FC-2)
ml — WD14 + SigLIP inference (FC-2; runs in the ml-worker image)
thumbnail — image and video thumbnail generation (FC-2)
download — source walks: the native ingesters and gallery-dl (FC-3)
download — gallery-dl tasks (FC-3)
scan — periodic source checks (FC-3) — kept separate so long imports
don't starve the scheduler
maintenance — recovery sweeps, pHash backfill, GPU-queue coordination, etc.
@@ -14,7 +14,6 @@ Queues:
from celery import Celery
from .config import get_config
from .services.worker_lanes import SWEEP_PERIOD_SECONDS
def make_celery() -> Celery:
@@ -62,15 +61,6 @@ def make_celery() -> Celery:
# can never starve the quick self-healing sweeps (operator-flagged
# 2026-06-07: a 2h audit blocked vacuum/backup/normalize for hours).
"backend.app.tasks.maintenance.*": {"queue": "maintenance"},
# The one long job in maintenance.py: a whole-library phash
# recompute (35 min hard limit; the library was cleared for
# re-hashing by migration 0098). On the quick lane it held a
# scheduler process for its whole run, and the minute ticks queued
# up behind it (2026-09-24: 7 waiting, "all workers busy for 18
# minutes"). An exact name wins over the glob above.
"backend.app.tasks.maintenance.backfill_phash": {"queue": "maintenance_long"},
# Walks a folder tree per artist with undated posts (#4436).
"backend.app.tasks.maintenance.date_posts_from_records": {"queue": "maintenance_long"},
"backend.app.tasks.backup.*": {"queue": "maintenance_long"},
"backend.app.tasks.admin.*": {"queue": "maintenance_long"},
"backend.app.tasks.library_audit.*": {"queue": "maintenance_long"},
@@ -121,34 +111,6 @@ def make_celery() -> Celery:
"task": "backend.app.tasks.maintenance.recover_interrupted_tasks",
"schedule": 300.0, # every 5 minutes
},
"size-worker-lanes": {
"task": "backend.app.tasks.maintenance.size_worker_lanes",
"schedule": SWEEP_PERIOD_SECONDS,
#
# The number lives in `services/worker_lanes` because three
# places must agree on it: this schedule, the freshness of the
# sample the System tab reads, and the roster staleness
# thresholds in `api/system_health` — which now depend on this
# sweep rather than on a browser being open, and assert their
# headroom over it at import.
#
# ONE entry, replacing `autoscale-worker-lanes` (60s) and
# `reconcile-worker-lanes` (300s) on 2026-09-23. They were two
# sweeps over one number and most of the autoscaler's design
# existed to stop the reconcile undoing its work; with the
# stored `slots` gone there is nothing to disagree about.
#
# Fast enough to react to a BACKLOG — a five-minute reaction to
# a queue filling up is no reaction. It also carries what the
# reconcile was for: a worker restarted at its ENV concurrency
# is corrected on the next tick rather than after five.
#
# Cheap when settled: one inspect plus one LLEN sweep, and no
# control messages at all once every lane matches. It is also
# now the ONLY thing that inspects — nothing on a request path
# does — so this is the whole broker cost of the System tab,
# whether nobody or ten tabs are watching.
},
"cleanup-old-tasks": {
"task": "backend.app.tasks.maintenance.cleanup_old_tasks",
"schedule": 86400.0, # daily
@@ -158,19 +120,6 @@ def make_celery() -> Celery:
"schedule": 86400.0, # daily — sweep .part/.partial left by a
# download/import killed mid-write (graceful-shutdown fallout)
},
"date-posts-from-records-hourly": {
"task": "backend.app.tasks.maintenance.date_posts_from_records",
"schedule": 3600.0, # an empty query once every native post is
# dated; otherwise upserts the _post.json a killed walk left (#4436)
},
"backfill-phash-daily": {
"task": "backend.app.tasks.maintenance.backfill_phash",
"schedule": 86400.0, # daily — NULL-only, so a no-op once the
# library is hashed. This is what makes migration 0098's
# re-hash happen on its own: 0098 NULLs every phash, and
# without a scheduled refill the library would sit
# dedup-disabled until someone ran a deep scan (#4223).
},
"train-heads-nightly": {
"task": "backend.app.tasks.ml.scheduled_train_heads",
"schedule": 86400.0, # passive cadence; manual retrain stays available
@@ -230,6 +179,11 @@ def make_celery() -> Celery:
"schedule": 86400.0, # auto-tag wip/editor process art (#1464);
# no-op unless process_auto_apply_enabled (opt-in)
},
"soft-wip-conflict-audit-daily": {
"task": "backend.app.tasks.ml.scheduled_soft_wip_conflict_audit",
"schedule": 86400.0, # flag ring-loud soft-WIP (sketch/doodle) tags
# for review (#1474); no-op with no content heads
},
"prune-presentation-reviews-daily": {
"task": "backend.app.tasks.ml.prune_presentation_reviews",
"schedule": 86400.0, # retention: drop resolved review flags >30d
@@ -246,26 +200,6 @@ def make_celery() -> Celery:
"task": "backend.app.tasks.maintenance.snapshot_head_metrics",
"schedule": 86400.0,
},
"group-discord-drops-hourly": {
"task": "backend.app.tasks.maintenance.group_discord_drops",
"schedule": 3600.0, # hourly. Not daily: the grouping signal is
# the SigLIP embedding, which lands asynchronously AFTER import
# (#388 E2), so this sweep is what picks up a drop once its
# vectors have caught up. No-op unless discord_grouping_enabled.
},
"match-post-associations-hourly": {
"task": "backend.app.tasks.maintenance.match_post_associations",
"schedule": 3600.0, # hourly, and AFTER the grouper's own cadence
# by construction: a pair cannot be proposed until the drop it
# points at exists as a grouping (#388 E5). No-op unless
# discord_link_enabled.
},
"sync-memberships-daily": {
"task": "backend.app.tasks.maintenance.sync_memberships",
"schedule": 86400.0, # daily — memberships change on a BILLING
# cycle, not a download cadence (#387 C3). No-op per platform
# when the client lacks the seam or no credential exists.
},
"integrity-verify-weekly": {
"task": "backend.app.tasks.maintenance.verify_integrity",
"schedule": 604800.0, # weekly
@@ -302,10 +236,6 @@ def make_celery() -> Celery:
"task": "backend.app.tasks.backup.prune_backups",
"schedule": 86400.0, # daily
},
"reconcile-media-backup": {
"task": "backend.app.tasks.backup.reconcile_media_backup",
"schedule": 604800.0, # weekly: list the bucket, re-send what's missing
},
# Audit 2026-06-02 — three new per-entity recovery sweeps.
# Each runs every 5 min like the other recover_stalled_*
# sweeps; each is a no-op when nothing is stuck.
@@ -366,9 +296,6 @@ def make_celery() -> Celery:
},
},
timezone="UTC",
# Beat's memory of when each job last ran comes from task_run, not a
# shelve file nothing persists — see beat_scheduler (#4408).
beat_scheduler="backend.app.beat_scheduler:TaskRunScheduler",
)
# FC-3i: register task_run signal handlers (side-effect import).
from . import celery_signals # noqa: F401
+34 -89
View File
@@ -18,18 +18,11 @@ dark for that interval. Monitoring NEVER breaks the thing it's
monitoring.
"""
import functools
import logging
from datetime import UTC, datetime
from celery.exceptions import SoftTimeLimitExceeded
from celery.signals import (
task_failure,
task_postrun,
task_prerun,
task_retry,
worker_ready,
)
from celery.signals import task_failure, task_postrun, task_prerun, task_retry
from .models import TaskRun
from .tasks._sync_engine import sync_session_factory
@@ -60,29 +53,42 @@ _INT32_MIN = -2_147_483_648
def _queue_for(task) -> str:
"""The queue Celery routes this task to — asked of the router itself.
"""Reverse the task→queue routing from celery_app.task_routes.
Keep in sync if task_routes is reordered.
This was a hand-kept copy of `celery_app.task_routes`, and it drifted
twice (the 2026-06-02 audit, then #4432). Long-lane jobs were recorded as
`maintenance`, and translation and gpu_queue runs as `default`, where the
5-minute stall sweep failed healthy 35-minute translation runs. The router
answers from the same table the broker uses, so the two cannot disagree.
Audit 2026-06-02: backup/admin/library_audit prefixes were
missing here even though task_routes sent all three to
'maintenance'. The TaskRun.queue column then lied for those
rows (claimed 'default') so per-queue dashboard filters and
per-queue threshold overrides silently missed them.
"""
name = getattr(task, "name", "") or ""
app = getattr(task, "app", None)
if app is None:
from .celery_app import celery as app
return _routed_queue(app, name)
@functools.lru_cache(maxsize=1024)
def _routed_queue(app, name: str) -> str:
try:
queue = app.amqp.router.route({}, name).get("queue")
except Exception: # noqa: BLE001 — monitoring never breaks the task
log.warning("task_run: could not resolve the queue for %s", name)
return "default"
return getattr(queue, "name", None) or (queue if isinstance(queue, str) else "default")
if name.startswith("backend.app.tasks.import_file."):
return "import"
if name.startswith("backend.app.tasks.ml."):
return "ml"
if name.startswith("backend.app.tasks.thumbnail."):
return "thumbnail"
if name.startswith((
"backend.app.tasks.download.",
# External file-host fetches share the download lane (celery_app
# routes external.* → download). Mirror it here or TaskRun.queue
# lies 'default' for them, so per-queue dashboard filters and the
# per-queue threshold override miss them — the same gap the
# 2026-06-02 audit fixed for backup/admin/library_audit.
"backend.app.tasks.external.",
)):
return "download"
if name.startswith("backend.app.tasks.scan."):
return "scan"
if name.startswith((
"backend.app.tasks.maintenance.",
"backend.app.tasks.backup.",
"backend.app.tasks.admin.",
"backend.app.tasks.library_audit.",
)):
return "maintenance"
return "default"
def _target_id_from_args(args) -> int | None:
@@ -219,64 +225,3 @@ def _on_retry(sender=None, request=None, reason=None, einfo=None, **_):
error_message=str(reason) if reason else None,
retry_count=getattr(request, "retries", 0),
)
def _consumed_queues(consumer) -> set[str]:
"""The queue names this worker process consumes (its `-Q`), or empty when
they can't be read — which makes the boot hook below a no-op, not a guess."""
try:
return {q.name for q in consumer.task_consumer.queues}
except Exception: # noqa: BLE001 — shape varies across celery versions
return set()
@worker_ready.connect
def _on_worker_ready(sender=None, **_):
"""A lane picks up what its previous process left behind. Only the lane
that runs the work does it; the other lanes booting beside it must not,
or a lane restarting on its own would disturb work still running.
Best-effort: a failure is logged and the worker still starts."""
queues = _consumed_queues(sender)
if "download" in queues:
_clear_download_leftovers()
if "maintenance_long" in queues:
_resume_offsite_runs()
def _resume_offsite_runs() -> None:
"""The long-maintenance lane carries on an off-site backup or restore that
a restart cut off mid-chunk. With nothing queuing its next chunk, the run
would read "running" for hours, and block every new run, until the stall
sweep ended it."""
try:
from .tasks.backup import resume_offsite_runs
resume_offsite_runs()
except Exception: # noqa: BLE001 — never block the worker from starting
log.exception("long-maintenance lane boot: resuming off-site runs failed")
def _clear_download_leftovers() -> None:
"""The download lane clears what its previous process left behind.
A restart SIGKILLs any walk that outlives the stop grace, so its event is
never finalized and its platform lock is never released. Both used to wait
out timers — a 30-min stall sweep that then blamed the source, and a 27-min
lock TTL that stalled every other source on the platform.
"""
booted_at = datetime.now(UTC)
try:
from .services.download_recovery import interrupt_orphaned_download_events
from .services.platform_lock import release_all_platform_locks
with sync_session_factory()() as session:
closed = interrupt_orphaned_download_events(session, booted_at=booted_at)
session.commit()
released = release_all_platform_locks()
if closed or released:
log.info(
"download lane boot: closed %d orphaned download event(s) as "
"interrupted, released %d platform lock(s)", closed, released,
)
except Exception: # noqa: BLE001 — never block the worker from starting
log.exception("download lane boot recovery failed")
+2 -20
View File
@@ -46,14 +46,6 @@ async def serve_extension(filename: str):
The application/x-xpinstall MIME tells Firefox to show its native
install prompt instead of downloading the file as a blob.
Caching differs by name, and has to. A versioned name is one build's bytes
forever, so it can be cached for good. `fabledcurator-latest.xpi` is ONE
URL whose bytes change on every release, and Quart's default for a file
is `public, max-age=43200`: a browser that fetched it once reused those
bytes for 12 hours, so "install the latest" quietly reinstalled the
previous build (operator-flagged 2026-09-25). It is `no-cache` — the ETag
still makes an unchanged file a cheap 304.
"""
if not _XPI_NAME_RE.fullmatch(filename):
abort(404)
@@ -64,11 +56,10 @@ async def serve_extension(filename: str):
if not xpis:
abort(404)
latest = xpis[-1]
resp = await send_file(
return await send_file(
latest, mimetype="application/x-xpinstall",
attachment_filename=latest.name,
)
return _cache(resp, "no-cache")
target = (XPI_DIR / filename).resolve()
try:
target.relative_to(XPI_DIR)
@@ -76,19 +67,10 @@ async def serve_extension(filename: str):
abort(404)
if not target.is_file():
abort(404)
resp = await send_file(
return await send_file(
target, mimetype="application/x-xpinstall",
attachment_filename=filename,
)
return _cache(resp, "public, max-age=31536000, immutable")
def _cache(resp, policy: str):
"""Set the XPI's Cache-Control, dropping the Expires send_file adds so the
two can never disagree."""
resp.headers["Cache-Control"] = policy
resp.headers.pop("Expires", None)
return resp
@frontend_bp.route("/")
+4 -18
View File
@@ -2,15 +2,11 @@
from .app_setting import AppSetting
from .artist import Artist
from .artist_membership_suggestion import ArtistMembershipSuggestion
from .artist_visit import ArtistVisit
from .backup_blob import BackupBlob
from .backup_run import BackupRun
from .base import Base
from .character_prototype import CcipPrototypeState, CharacterPrototype
from .credential import Credential
from .discord_failed_media import DiscordFailedMedia
from .discord_seen_media import DiscordSeenMedia
from .download_event import DownloadEvent
from .external_link import ExternalLink
from .gpu_job import GpuJob
@@ -25,13 +21,12 @@ from .import_batch import ImportBatch
from .import_settings import ImportSettings
from .import_task import ImportTask
from .library_audit_run import LibraryAuditRun
from .membership_sync import MembershipSync
from .ml_settings import MLSettings
from .patreon_failed_media import PatreonFailedMedia
from .patreon_seen_media import PatreonSeenMedia
from .platform_membership import PlatformMembership
from .pixiv_failed_media import PixivFailedMedia
from .pixiv_seen_media import PixivSeenMedia
from .post import Post
from .post_association import PostAssociation
from .post_attachment import PostAttachment, attachment_download_url
from .presentation_review import PresentationReview
from .series_chapter import SeriesChapter
@@ -47,34 +42,28 @@ from .tag_head import TagHead
from .tag_positive_confirmation import TagPositiveConfirmation
from .tag_suggestion_rejection import TagSuggestionRejection
from .task_run import TaskRun
from .worker_lane import WorkerLane
from .worker_lane_sample import WorkerLaneSample
__all__ = [
"Base",
"AppSetting",
"Artist",
"ArtistMembershipSuggestion",
"ArtistVisit",
"BackupBlob",
"BackupRun",
"Source",
"Credential",
"DiscordFailedMedia",
"DiscordSeenMedia",
"PatreonFailedMedia",
"PatreonSeenMedia",
"PixivFailedMedia",
"PixivSeenMedia",
"SubscribeStarFailedMedia",
"SubscribeStarSeenMedia",
"Post",
"PostAssociation",
"PostAttachment",
"attachment_download_url",
"PresentationReview",
"SeriesChapter",
"SeriesPage",
"SeriesSuggestion",
"PlatformMembership",
"ServiceSeen",
"ImageRecord",
"ImageProvenance",
@@ -89,7 +78,6 @@ __all__ = [
"ImportTask",
"ImportSettings",
"LibraryAuditRun",
"MembershipSync",
"MLSettings",
"HeadAutoApplyRun",
"HeadMetric",
@@ -102,6 +90,4 @@ __all__ = [
"TagPositiveConfirmation",
"TagSuggestionRejection",
"TaskRun",
"WorkerLane",
"WorkerLaneSample",
]
@@ -1,83 +0,0 @@
"""artist_membership_suggestion — "this creator and that membership are the same".
Milestone 388, step E4.
## What was NOT needed here
E4's first job was to check what is actually missing, and the answer was: not
the schema, and not the flows. `Source.artist_id` is a plain FK, so many
sources per artist is already the data model; `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, which are
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 the SUGGESTION — FC proposing
the link from the roster instead of waiting to be told.
## Confirm-only, and what "accept" actually does
Accepting adds a SOURCE for the membership's platform under the artist that
already has the other channel. It does NOT merge two artists. That distinction
is the whole safety margin: adding a source is trivially undone, whereas a
wrong artist merge silently mixes two creators' work and corrupts tagging,
series and provenance downstream — with nothing left to tell them apart by.
Dismissed rows are kept, not deleted, for the same reason as every other review
queue here: the row is what remembers the rejection, and re-proposing a
rejected pair on every scan is what makes a queue get ignored.
"""
from datetime import datetime
from sqlalchemy import (
JSON,
DateTime,
Float,
ForeignKey,
Integer,
String,
UniqueConstraint,
func,
)
from sqlalchemy.orm import Mapped, mapped_column
from .base import Base
class ArtistMembershipSuggestion(Base):
__tablename__ = "artist_membership_suggestion"
__table_args__ = (
UniqueConstraint(
"platform_membership_id", "artist_id",
name="uq_artist_membership_suggestion_pair",
),
)
id: Mapped[int] = mapped_column(Integer, primary_key=True)
platform_membership_id: Mapped[int] = mapped_column(
ForeignKey("platform_membership.id", ondelete="CASCADE"),
nullable=False, index=True,
)
artist_id: Mapped[int] = mapped_column(
ForeignKey("artist.id", ondelete="CASCADE"), nullable=False, index=True
)
score: Mapped[float] = mapped_column(Float, nullable=False)
# Per-signal strengths as scored. Without it, "why was this suggested" is
# unanswerable the moment a weight or the threshold moves.
signals: Mapped[dict | None] = mapped_column(JSON, nullable=True)
# pending | linked | dismissed. Plain String, no CHECK — same call as
# series_suggestion.status and post_association.status.
status: Mapped[str] = mapped_column(
String(16), nullable=False, server_default="pending", index=True
)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False,
server_default=func.now(), onupdate=func.now(),
)
-26
View File
@@ -1,26 +0,0 @@
"""backup_blob: a library file already sent to the off-site bucket.
One row per (destination, sha256). `target` names the destination as
"<endpoint>/<bucket>", so pointing backups at another bucket leaves these rows
inert and that bucket's copy starts from the beginning. `key` is where the
file sits in the bucket (`media/ab/cd/<sha256><ext>`).
"""
from datetime import datetime
from sqlalchemy import BigInteger, DateTime, String, Text, func
from sqlalchemy.orm import Mapped, mapped_column
from .base import Base
class BackupBlob(Base):
__tablename__ = "backup_blob"
target: Mapped[str] = mapped_column(Text, primary_key=True)
sha256: Mapped[str] = mapped_column(String(64), primary_key=True)
key: Mapped[str] = mapped_column(Text, nullable=False)
size_bytes: Mapped[int] = mapped_column(BigInteger, nullable=False)
uploaded_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now(),
)
+2 -8
View File
@@ -1,8 +1,6 @@
"""FC-3h: backup_run — operator-facing artifact record for a backup run.
One row per run: kind 'db' (a database dump), 'media' (the off-site upload of
images and attachments, which spans many task chunks), 'restore', 'reconcile'
(the weekly bucket check) or 'test' (a test restore). Lifecycle
One row per backup attempt (kind='db' or 'images'). Lifecycle
tracking (started_at/finished_at/duration_ms/exception text) lives
in task_run from FC-3i — this row records artifact metadata: file
paths, sizes, tag (retention protection), and restore lineage via
@@ -56,6 +54,7 @@ class BackupRun(Base):
DateTime(timezone=True), nullable=True, index=True,
)
sql_path: Mapped[str | None] = mapped_column(Text, nullable=True)
tar_path: Mapped[str | None] = mapped_column(Text, nullable=True)
size_bytes: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
error: Mapped[str | None] = mapped_column(Text, nullable=True)
manifest: Mapped[dict] = mapped_column(
@@ -63,11 +62,6 @@ class BackupRun(Base):
)
# Self-referential FK, unindexed until 0089 (#3300): SET NULL has to find
# the rows pointing at a deleted run before it can null them.
# Heartbeat of a run that spans hours (the media backup): set as it makes
# progress, and what the stall sweep measures its silence from.
last_progress_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True,
)
restored_from_id: Mapped[int | None] = mapped_column(
ForeignKey("backup_run.id", ondelete="SET NULL"),
nullable=True, index=True,
-34
View File
@@ -1,34 +0,0 @@
"""DiscordSeenMedia — per-source ledger of Discord files already downloaded.
Mirror of SubscribeStarSeenMedia for the native Discord ingester (milestone
428). `filehash` holds the ingester's per-file key, `<message_id>:<media_id>`:
the attachment id, or for an embed a hash of its URL path. Not the file's
position in the message — an edit that removes a file renumbers the rest
(see `discord_client.MediaItem`). The message record's own gate is the
synthetic `message:<id>` key in the same column.
"""
from datetime import datetime
from sqlalchemy import ForeignKey, Integer, String, UniqueConstraint, func
from sqlalchemy.orm import Mapped, mapped_column
from sqlalchemy.types import DateTime
from .base import Base
class DiscordSeenMedia(Base):
__tablename__ = "discord_seen_media"
__table_args__ = (
UniqueConstraint("source_id", "filehash", name="uq_discord_seen_media_source_id"),
)
id: Mapped[int] = mapped_column(Integer, primary_key=True)
source_id: Mapped[int] = mapped_column(
ForeignKey("source.id", ondelete="CASCADE"), nullable=False, index=True
)
filehash: Mapped[str] = mapped_column(String(128), nullable=False)
post_id: Mapped[str | None] = mapped_column(String(64), nullable=True)
seen_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
-11
View File
@@ -80,23 +80,12 @@ class ExternalLink(Base):
Integer, nullable=False, server_default=text("0")
)
last_error: Mapped[str | None] = mapped_column(Text, nullable=True)
# Times the daily recovery has given this link another attempt after it
# went dead. Recovery stops at a limit, so a link that can never succeed
# isn't downloaded forever.
recoveries: Mapped[int] = mapped_column(
Integer, nullable=False, server_default=text("0")
)
attachment_id: Mapped[int | None] = mapped_column(
ForeignKey("post_attachment.id", ondelete="SET NULL"), nullable=True
)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
# When the current fetch claimed the link ('downloading'). The sweep uses it
# to recover a link whose worker died mid-fetch.
started_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
completed_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
+1 -5
View File
@@ -64,11 +64,7 @@ class ImageRecord(Base):
# that 0001 also built was an exact duplicate of it — dropped in 0089
# (#3301). Lookups by sha256 use the constraint's index.
sha256: Mapped[str] = mapped_column(String(64), nullable=False)
# 64 hex chars = the 256-bit hash utils.phash emits at hash_size=16. Was
# String(32) (64-bit) until migration 0098; the narrow column was the
# reason for the undersized hash, and the undersized hash was collapsing
# variant artwork into one record (#4223).
phash: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
phash: Mapped[str | None] = mapped_column(String(32), nullable=True, index=True)
size_bytes: Mapped[int] = mapped_column(BigInteger, nullable=False)
mime: Mapped[str] = mapped_column(String(64), nullable=False)
width: Mapped[int | None] = mapped_column(Integer, nullable=True)
+12 -102
View File
@@ -9,7 +9,6 @@ from sqlalchemy import (
CheckConstraint,
Float,
Integer,
String,
Text,
select,
text,
@@ -40,15 +39,10 @@ class ImportSettings(Base):
transparency_threshold: Mapped[float] = mapped_column(Float, nullable=False, default=0.9, server_default="0.9")
skip_single_color: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, server_default="false")
single_color_threshold: Mapped[float] = mapped_column(Float, nullable=False, default=0.995, server_default="0.995")
single_color_threshold: Mapped[float] = mapped_column(Float, nullable=False, default=0.95, server_default="0.95")
single_color_tolerance: Mapped[int] = mapped_column(Integer, nullable=False, default=30, server_default="30")
# Hamming distance over a 256-bit pHash (utils.phash, hash_size=16). The
# unit CHANGED in migration 0098 — it used to be bits out of 64 — so the
# old default of 10 is not this scale's 10, and 0098 resets every row.
# This is now the cheap PRE-FILTER: the aspect + pixel gates decide, which
# is what lets it be generous enough to catch a re-encoded rescale.
phash_threshold: Mapped[int] = mapped_column(Integer, nullable=False, default=24, server_default="24")
phash_threshold: Mapped[int] = mapped_column(Integer, nullable=False, default=10, server_default="10")
# FC-3c downloader knobs
download_rate_limit_seconds: Mapped[float] = mapped_column(
@@ -69,16 +63,6 @@ class ImportSettings(Base):
Integer, nullable=False, default=90,
server_default="90",
)
# How far back a routine tick keeps looking after it has run out of new
# posts, so a creator who EDITS an older post to attach a hotfix build is
# still reached (ingest_core.DEFAULT_REVISIT_DAYS carries the reasoning).
# A knob rather than a constant because how long a creator keeps editing is
# a property of the creator, not of FabledCurator: 0 turns the revisit off
# and restores the pure count early-out.
download_revisit_days: Mapped[int] = mapped_column(
Integer, nullable=False, default=30,
server_default="30",
)
download_failure_warning_threshold: Mapped[int] = mapped_column(
Integer, nullable=False, default=5,
server_default="5",
@@ -97,19 +81,9 @@ class ImportSettings(Base):
Integer, nullable=False, default=14,
server_default="14",
)
# Off-site backup destination: an S3 bucket (services/backup_destination).
# The secret key is stored sealed with the credential key, never in clear.
backup_s3_endpoint: Mapped[str | None] = mapped_column(Text, nullable=True)
backup_s3_bucket: Mapped[str | None] = mapped_column(String(255), nullable=True)
backup_s3_access_key: Mapped[str | None] = mapped_column(Text, nullable=True)
backup_s3_secret_sealed: Mapped[str | None] = mapped_column(Text, nullable=True)
backup_s3_region: Mapped[str] = mapped_column(
String(64), nullable=False, default="us-east-1",
server_default="us-east-1",
)
# Upload bandwidth to that bucket, in megabits per second; 0 = no limit.
backup_upload_limit_mbps: Mapped[int] = mapped_column(
Integer, nullable=False, default=200, server_default="200",
backup_images_keep_last_n: Mapped[int] = mapped_column(
Integer, nullable=False, default=3,
server_default="3",
)
# FC-6.3 series continuation matcher. enabled gates the rescan; threshold is
@@ -123,77 +97,6 @@ class ImportSettings(Base):
server_default="0.5",
)
# Milestone 388 E5 — the announcement matcher: "this Patreon post announced
# that Discord drop". Lives here rather than in MLSettings, with the series
# matcher it is modelled on, because it runs no inference: the signals are
# time proximity and whether the post says so.
discord_link_enabled: Mapped[bool] = mapped_column(
Boolean, nullable=False, default=True,
server_default="true",
)
# The weighted-score cut-off. 0.60 is not arbitrary: it is deliberately set
# ABOVE the largest single signal weight, which is what makes "time
# proximity alone must never be sufficient" an ARITHMETIC property rather
# than a hope. On a busy day an artist posts several times; if proximity
# could carry a pair by itself, every one of those days would produce false
# pairs and the review queue would be abandoned. See
# post_association_service.WEIGHTS — a guard test pins the relationship.
discord_link_threshold: Mapped[float] = mapped_column(
Float, nullable=False, default=0.60,
server_default="0.60",
)
# How far apart the announcement and the drop may be. The Patreon post
# exists IN ORDER TO announce the drop, so they are minutes-to-hours apart;
# a day is generous and still excludes "same week".
discord_link_window_hours: Mapped[float] = mapped_column(
Float, nullable=False, default=24.0,
server_default="24",
)
# Whether FC links a CONCLUSIVE pair without asking.
#
# Operator, 2026-09-24: *"I don't want this to be manual that defeats the
# convenience that I'm going for."* Confirm-only was the right default
# while the only signals were circumstantial — proximity and a body that
# mentions Discord can never be more than suggestive, and asking was the
# honest response to that. A shared working name is different in kind: when
# the name appears in exactly these two posts and nowhere else in the
# artist's library, there is nothing for the operator to adjudicate.
#
# Only the conclusive band is affected (post_association_service.
# AUTO_LINK_FLOOR). Everything weaker still queues, and an accepted link is
# a row the operator can dismiss, so this is reversible in the UI rather
# than only in the database.
discord_link_auto: Mapped[bool] = mapped_column(
Boolean, nullable=False, default=True,
server_default="true",
)
# The unified card (#4402). A linked Discord drop is NOT absorbed into its
# teaser — it keeps its own post, date and provenance, and the teaser's card
# shows it by REFERENCE. Operator, 2026-09-24: *"discord 'posts' land as
# normal and only hidden from the post view they're posted the same day."*
#
# So the drop's own card leaves the feed only when it sits within this many
# hours of the teaser that references it — the adjacency that reads as the
# same thing twice. Hours rather than a calendar day: a teaser at 23:00 and
# its drop at 01:00 are one release, and "the same day" has no timezone
# the server can know.
discord_link_fold_hours: Mapped[float] = mapped_column(
Float, nullable=False, default=24.0,
server_default="24",
)
# How far from the teaser the card reaches for the rest of a piece's
# variants — the wips, alts and censor passes a creator trickles out under
# one working name (#4401). Measured on artist 8: named families spread a
# median 5 days and up to 44, while every name collision found spreads
# over 500. A reference, not a regrouping, so a generous value costs one
# extra thumbnail at worst — never a post moved or hidden.
discord_family_window_days: Mapped[float] = mapped_column(
Float, nullable=False, default=60.0,
server_default="60",
)
# #830 off-platform file-host downloads — per-host enable lever (default on,
# rule #26). Column names are extdl_<host>_enabled so the worker reads them
# via getattr(settings, f"extdl_{host}_enabled", True).
@@ -249,6 +152,13 @@ class ImportSettings(Base):
wip_title_tagging_enabled: Mapped[bool] = mapped_column(
Boolean, nullable=False, default=True, server_default="true",
)
# Soft WIP title tier (#1474): also tag sketch/doodle/scribble titles, but with
# a PROVISIONAL source (`wip_title_soft`) that never trains the head, since these
# are lower-precision (a finished "sketch" isn't WIP). OFF by default — a lower-
# precision tier is opt-in (the ring-loud audit surfaces false positives).
wip_soft_title_tagging_enabled: Mapped[bool] = mapped_column(
Boolean, nullable=False, default=False, server_default="false",
)
@classmethod
async def load(cls, session) -> ImportSettings:
-77
View File
@@ -1,77 +0,0 @@
"""membership_sync — did the roster actually sync, and when.
Milestone 387, step C3.
`platform_membership` records what was SEEN. This records whether looking
happened at all, and that is a different fact — the one that makes an empty
roster readable.
## Why this table has to exist
Without it, three very different situations are one indistinguishable state:
* the account genuinely subscribes to nothing,
* the sweep has never run,
* the sweep ran and failed.
All three produce zero rows in `platform_membership`. Telling the operator
"you are tracking 12 sources you do not subscribe to" is correct in the first
case and catastrophic in the other two — it is an invitation to cancel things
they are actively paying for. C4 must therefore gate its CONCLUSIONS on
`last_success_at`, not merely display it.
`MAX(platform_membership.last_seen_at)` was the tempting shortcut and does not
work: it cannot distinguish "synced fine, found nothing" from "never synced".
`task_run` was the other candidate and is worse — its retention prunes ok rows
after 24h, so a sweep that last succeeded three days ago would leave no trace
at all.
## Separate attempt and success timestamps, deliberately
`last_attempt_at` moves every run; `last_success_at` moves only on a clean
walk. The GAP between them is the staleness signal, and keeping them apart is
what lets the UI say "last synced 3 days ago, last tried 20 minutes ago,
failing" — which is a different message from either half alone.
"""
from datetime import datetime
from sqlalchemy import DateTime, Integer, String, Text, UniqueConstraint, func
from sqlalchemy.orm import Mapped, mapped_column
from .base import Base
class MembershipSync(Base):
__tablename__ = "membership_sync"
__table_args__ = (
UniqueConstraint("platform", name="uq_membership_sync_platform"),
)
id: Mapped[int] = mapped_column(Integer, primary_key=True)
platform: Mapped[str] = mapped_column(String(64), nullable=False)
# Moves on EVERY run, success or not — so "we are trying" is visible even
# while "we are succeeding" is not.
last_attempt_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
# Moves only on a COMPLETE walk. This is the freshness signal C4 gates its
# conclusions on; NULL means never — which must never be rendered as zero.
last_success_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
# How many memberships the last SUCCESSFUL walk saw. Paired with
# last_success_at so "0" is only ever readable as a real zero.
last_count: Mapped[int | None] = mapped_column(Integer, nullable=True)
# Cleared on success. Plain String, no CHECK — this carries an exception
# class name (PatreonAuthError, PatreonDriftError, ...) and the vocabulary
# is whatever the client raises, exactly as source.error_type works.
last_error_type: Mapped[str | None] = mapped_column(String(64), nullable=True)
last_error_message: Mapped[str | None] = mapped_column(Text, nullable=True)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False,
server_default=func.now(), onupdate=func.now(),
)
-63
View File
@@ -252,69 +252,6 @@ class MLSettings(Base):
Integer, nullable=False, default=64,
server_default="64",
)
# -- Discord drop grouping (milestone 388) -----------------------------
# FC authors a post out of a creator's variant drop. The predicate is three
# axes ANDed together, and the time one does the real work: SIMILARITY
# ALONE OVER-GROUPS. 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". What makes a variant set a set is
# that it was dropped TOGETHER.
discord_grouping_enabled: Mapped[bool] = mapped_column(
# ON by default, matching the operator's standing opt-OUT preference for
# automatic behaviour (2026-06-29, recorded on the head/ccip auto-apply
# switches). Safe to default on because the act is reversible by one
# DELETE: removing a synthetic post un-absorbs its members.
Boolean, nullable=False, default=True,
server_default="true",
)
# Cosine DISTANCE, not similarity — this is the units gallery_service's
# `cosine_distance` already speaks, and converting at the query site is a
# step to get backwards. Lower = stricter. 0.10 is deliberately TIGHT: the
# two failure modes are not symmetric. Grouping too shy leaves a drop
# scattered, which is visible and fixable by raising this; grouping too
# greedy merges distinct pieces into a post that claims they belong
# together, which is the failure that would discredit the feature.
discord_group_max_distance: Mapped[float] = mapped_column(
Float, nullable=False, default=0.10,
server_default=text("0.10"),
)
# The gap that ENDS a drop, measured between CONSECUTIVE messages rather
# than from the first — an artist trickling variants out over an evening is
# one drop, and a window anchored on the first message would cut it in half
# at an arbitrary point.
discord_group_window_minutes: Mapped[float] = mapped_column(
Float, nullable=False, default=60.0,
server_default=text("60"),
)
# How long a synthetic post keeps accepting new members (#388 E3). This is
# NOT the drop window above: the window cuts one sweep's messages into
# drops, this decides how long a finished drop can still be REJOINED when a
# creator adds variants days later. A week by default — long enough for the
# "and here is the alt outfit" follow-up that motivated the feature, short
# enough that a group does not still be open when the same character comes
# round again months later and gets absorbed by mistake.
#
# Openness is DERIVED from this, not stored: a group is open if it grew (or
# started) within this period. So lowering it closes old groups and raising
# it reopens them, which is comprehensible and reversible — the alternative,
# a stored closed_at, would need its own repair path to ever change.
discord_group_close_after_hours: Mapped[float] = mapped_column(
Float, nullable=False, default=168.0,
server_default=text("168"),
)
# Anti-thrash (#388 E3). An updated post SHOULD be visible — that is the
# point of keeping it open — but a group gaining one image a day must not
# monopolise the feed. Growth smaller than this never moves the post, and
# no group moves more than once per cooldown, so a drip-feed updates in
# place while a real second wave resurfaces exactly once.
discord_group_resurface_min_images: Mapped[int] = mapped_column(
Integer, nullable=False, default=2,
server_default="2",
)
discord_group_resurface_cooldown_hours: Mapped[float] = mapped_column(
Float, nullable=False, default=24.0,
server_default=text("24"),
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
@@ -1,9 +1,16 @@
"""DiscordFailedMedia — per-source dead-letter ledger of Discord files that
keep failing to download or validate.
"""PixivFailedMedia — per-source dead-letter ledger of Pixiv media that keeps
failing to download/validate.
Mirror of SubscribeStarFailedMedia. After `attempts` reaches the dead-letter
threshold a routine walk skips the file (recovery still retries it); a later
clean download clears the row. `filehash` is the seen-ledger's key.
Mirror of PatreonFailedMedia/SubscribeStarFailedMedia. Media that fails every
walk (404'd pximg URL, deleted work, persistently-corrupt bytes) would
otherwise re-error forever and re-burn backfill chunks. After ``attempts``
reaches the dead-letter threshold the ingester skips it on routine
tick/backfill walks (recovery still re-attempts). A later clean download
clears the row.
`filehash` is the same synthesized ``<illust_id>:p<num>`` /
``<illust_id>:ugoira`` key the seen-ledger uses. UNIQUE (source_id, filehash)
is the upsert key.
"""
from datetime import datetime
@@ -15,10 +22,12 @@ from sqlalchemy.types import DateTime
from .base import Base
class DiscordFailedMedia(Base):
__tablename__ = "discord_failed_media"
class PixivFailedMedia(Base):
__tablename__ = "pixiv_failed_media"
__table_args__ = (
UniqueConstraint("source_id", "filehash", name="uq_discord_failed_media_source_id"),
UniqueConstraint(
"source_id", "filehash", name="uq_pixiv_failed_media_source_id"
),
)
id: Mapped[int] = mapped_column(Integer, primary_key=True)
+42
View File
@@ -0,0 +1,42 @@
"""PixivSeenMedia — per-source ledger of Pixiv media already
downloaded+processed.
Mirror of PatreonSeenMedia/SubscribeStarSeenMedia for the Pixiv native
ingester (replacing gallery-dl). One queryable row per (source, media) so
routine walks skip media we've already ingested; recovery mode bypasses the
ledger to re-walk.
Pixiv original URLs carry no content hash, so `filehash` is always the
synthesized ``<illust_id>:p<num>`` (page) / ``<illust_id>:ugoira`` (frame
zip) key — stable across any URL-shape drift. String(128) matches the sibling
ledgers.
"""
from datetime import datetime
from sqlalchemy import ForeignKey, Integer, String, UniqueConstraint, func
from sqlalchemy.orm import Mapped, mapped_column
from sqlalchemy.types import DateTime
from .base import Base
class PixivSeenMedia(Base):
__tablename__ = "pixiv_seen_media"
__table_args__ = (
# Dedup key the downloader upserts against: one ledger row per
# (source, media). A second sighting of the same media is a no-op.
UniqueConstraint(
"source_id", "filehash", name="uq_pixiv_seen_media_source_id"
),
)
id: Mapped[int] = mapped_column(Integer, primary_key=True)
source_id: Mapped[int] = mapped_column(
ForeignKey("source.id", ondelete="CASCADE"), nullable=False, index=True
)
filehash: Mapped[str] = mapped_column(String(128), nullable=False)
post_id: Mapped[str | None] = mapped_column(String(64), nullable=True)
seen_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
-152
View File
@@ -1,152 +0,0 @@
"""platform_membership — the learned roster of what the account actually pays for.
Milestone 387, phase C. FabledCurator knows which creators it has been TOLD to
follow (`source`), and nothing about which ones the operator is actually
subscribed to. Those two sets drift in both directions and the app cannot
currently see either drift:
* A subscription the operator pays for that FC does not track is content they
believe they are archiving and are not.
* A source FC keeps walking after the subscription lapsed is requests spent on
a wall, reported as a creator who has gone quiet.
This table is the memory that makes both visible — every membership the account
has been observed to hold, and when it was last seen.
## Why a learned roster rather than a live lookup
Same reasoning as `service_seen` (milestone 365), and the same shape: an
absence is only observable against a record of presence. A membership that
stops appearing in a sweep is the signal — "you were subscribed to this, now
you aren't" — and there is nowhere to read that from a live call, because a
live call returns what IS, never what stopped being.
It also means the reconciliation surface keeps working when Patreon is
unreachable, degraded to a stale roster with a visible age rather than an empty
page (rule 164).
## Roster truth, NOT per-post truth
The single most important thing about this table: `tier_names` says which tiers
the account holds. It does **not** say which posts those tiers unlock. A
creator can gate a post behind an access rule that maps onto no tier name at
all.
`current_user_can_view` — read per post by `patreon_client.post_is_gated` — is
the authoritative signal, and phase A already turned it into a durable
per-source state. This roster EXPLAINS that state ("you are no longer a patron"
vs "your tier doesn't cover these posts"). It must never be used to decide
whether to fetch something. Getting that backwards would make FC silently stop
fetching content the operator is paying for, which is the worst failure
available in this milestone.
## status is a plain String, and deliberately the platform's own word
Not a Postgres ENUM, not CHECK-gated — matching `service_seen.kind`,
`gpu_job.status` and `source.error_type`. Two reasons, and the first is the
real one:
1. **The vocabulary is not ours to invent.** Patreon says `active_patron` /
`former_patron` / `declined_patron`; SubscribeStar and FANBOX will say
something else. Storing each platform's own word verbatim and mapping to
FC's meaning at the READ site keeps this table a record of what was
observed rather than a lossy translation of it. A lowest-common-denominator
enum picked before any platform has been characterised (step C0) would be a
guess baked into the schema.
2. A constraint swap per new value (rule 36) would be cost with no invariant
behind it, exactly as `service_seen.kind` records.
The service layer owns the whitelist and the mapping; the column owns the
evidence.
## Retention: aged out, never deleted on disappearance
A membership that stops appearing in a sweep is NOT removed. Its disappearance
is the fact the reconciliation surface reads, and deleting the row would
destroy the signal at the moment it became interesting. `last_seen_at` is what
makes "gone" decidable, and a retention policy ages rows out on time rather
than on absence.
"""
from datetime import datetime
from sqlalchemy import JSON, DateTime, Integer, String, Text, UniqueConstraint, func
from sqlalchemy.orm import Mapped, mapped_column
from .base import Base
class PlatformMembership(Base):
__tablename__ = "platform_membership"
__table_args__ = (
# The natural key the sweep's upsert conflicts on. Named explicitly
# because `touch_membership` references it by name in ON CONFLICT.
UniqueConstraint(
"platform", "external_campaign_id",
name="uq_platform_membership_platform_campaign",
),
)
id: Mapped[int] = mapped_column(Integer, primary_key=True)
platform: Mapped[str] = mapped_column(String(64), nullable=False)
# The platform's own id for the thing subscribed to — a Patreon campaign
# id, whatever SubscribeStar and FANBOX call theirs. Text rather than a
# bounded String: these are opaque upstream identifiers and guessing a
# ceiling for a value we do not mint is how a walk dies on a truncation.
external_campaign_id: Mapped[str] = mapped_column(Text, nullable=False)
# For the reconciliation UI, and for matching against Source.url — the
# vanity/URL is what the two sides actually have in common.
display_name: Mapped[str | None] = mapped_column(Text, nullable=True)
url: Mapped[str | None] = mapped_column(Text, nullable=True)
# The platform's own word. See the module docstring — this is evidence,
# not a normalised FC status.
status: Mapped[str | None] = mapped_column(String(32), nullable=True)
# Nullable throughout: a free follow has no tier and no money attached, and
# a platform may not expose an amount at all. Absent must stay
# distinguishable from zero — "free" and "we don't know" are different
# answers to "what is this costing".
tier_names: Mapped[list | None] = mapped_column(JSON, nullable=True)
amount_cents: Mapped[int | None] = mapped_column(Integer, nullable=True)
currency: Mapped[str | None] = mapped_column(String(8), nullable=True)
# NEVER updated after insert. The one field that answers "has this ever
# been true", which is what makes a disappearance readable rather than
# indistinguishable from never having existed. `touch_membership`
# deliberately excludes it from the ON CONFLICT update set.
first_seen_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now(),
)
last_seen_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now(),
)
# The raw membership as the platform returned it, so a later question can
# be answered without re-fetching — and so a field we did not think to
# model is not lost. Displayed and never queried, like service_seen.details.
details: Mapped[dict] = mapped_column(JSON, nullable=False, default=dict)
def vanity_or_none(self) -> str | None:
"""The platform's URL slug for this creator, if it can be known.
NOT a column, and that is C1's design working as intended rather than
an omission: the roster was modelled before any platform had been
characterised, so `details` exists precisely to carry the fields we did
not know to model. The vanity turned out to be one of them (#3886), and
it is reachable without a migration.
Falls back to the URL's last segment, which is what a vanity IS on
every platform seen so far — but only as a fallback, because the
platform's own word for it is the better answer when present.
"""
campaign = (self.details or {}).get("campaign") or {}
vanity = campaign.get("vanity")
if isinstance(vanity, str) and vanity:
return vanity
if self.url:
tail = self.url.rstrip("/").rsplit("/", 1)[-1]
return tail or None
return None
-58
View File
@@ -102,61 +102,3 @@ class Post(Base):
downloaded_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
# -- Synthetic posts (milestone 388). ----------------------------------
# Discord is a delivery CHANNEL, not a publisher: one message is not one
# post. So FC authors the post itself, grouping a creator's variant drop
# into a single row (services/discord_grouping.py).
#
# NULL for every post a creator actually wrote — which is all of them until
# a grouper runs. Non-NULL names the grouper that authored this row, and is
# the ONE flag the UI keys off to say so. The honesty rule is the whole
# point: a synthetic post must never present itself as authored, and a
# column that is absent-or-a-name makes "was this us?" answerable from the
# row rather than inferred from its shape.
#
# Plain String, no CHECK (rule 36 considered and declined) — same reasoning
# as source.error_type and service_seen.kind. There is exactly one grouper
# today; a second would be a value, not an invariant.
synthesized_by: Mapped[str | None] = mapped_column(String(32), nullable=True)
# What it was built from, so the operator can audit a grouping FC invented:
# member post ids, message count, and the thresholds in force when the
# decision was made. That last part matters — the thresholds are operator-
# tunable, so "why did it group these" is unanswerable a month later
# without recording the values that produced it.
synthesis_details: Mapped[dict | None] = mapped_column(JSON, nullable=True)
# Set on a MEMBER post, pointing at the synthetic post that absorbed it.
# The feed hides absorbed posts (they are the chat lines the synthetic post
# replaced); every other surface still reaches them by id, because they
# remain the image's true origin and the grouping has to be inspectable.
#
# Self-FK, ON DELETE SET NULL: deleting a synthetic post un-absorbs its
# members and they return to the feed on their own. That is the reversal
# path, and it is one DELETE — nothing to undo by hand.
absorbed_by_post_id: Mapped[int | None] = mapped_column(
ForeignKey("post.id", ondelete="SET NULL"), nullable=True, index=True
)
# -- An OPEN grouping (milestone 388 E3) -------------------------------
# A synthetic post is not sealed at creation: a creator who adds two more
# variants the next day extends the existing post rather than starting a
# new one. These two columns are what make that possible without the post
# either freezing or thrashing the feed.
#
# `last_grew_at` is when the group last absorbed something. It answers two
# questions: how long the group stays JOINABLE (a group closes after a
# quiet period — artists reuse characters for years, and a group left open
# forever will eventually absorb something it shouldn't), and what the card
# shows as "updated N ago". NULL means it has never grown since creation.
last_grew_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
# The feed position, and ONLY set when the anti-thrash rule fires — see
# discord_grouping.should_resurface. A group that gains one image a day
# must not sit permanently at the top of the feed, so growth updates the
# post without necessarily moving it; a genuine second wave moves it once.
#
# NULL on every ordinary post, which is why the feed's sort key can
# COALESCE through it without changing where anything else lands.
resurfaced_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
-107
View File
@@ -1,107 +0,0 @@
"""PostAssociation — "this Patreon post announced that Discord drop".
Milestone 388, step E5, and 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. The Patreon post is
the announcement; the Discord grouping (milestone 388 E2) is the payload. This
row is the link between them.
## Directional, and NOT a merge
`announcement` → `payload` is asymmetric on purpose. The teaser announces the
drop; the drop does not announce the teaser, and a symmetric "related posts"
edge would lose the only thing that makes the pair interesting.
Nor are the two collapsed into one post. The creator published twice,
deliberately, on two platforms with different audiences — flattening that
hides the very behaviour being modelled, and would destroy the operator's
ability to see that the Patreon post is a teaser at all.
## Confirm-only, following FC-6.3 (task 737)
`status` starts at `pending` and nothing is linked until the operator accepts.
A wrongly-asserted association tells them two different pieces are one, which
is worse than no link: no link leaves them where they already are, a wrong one
actively misinforms. Same reason the series matcher writes to a review queue
instead of filing posts on its own.
`status` is a plain String, no CHECK — matching `series_suggestion.status`,
which records the same check-existing-enums lesson.
## Why pHash could not do this, and the correction matters
The original plan claimed this link was already sitting in `image_provenance`
via pHash dedup. It is not. `compute_phash` is `imagehash.phash` at
`hash_size=8` — a DCT hash over the WHOLE image, robust to rescaling and
recompression but NOT to cropping, because a crop changes the global
signature. Cross-platform provenance still links straight re-posts; it does
nothing for a cropped teaser and its full version, which is precisely the pair
the operator described. Hence a scored proposal rather than a lookup.
"""
from datetime import datetime
from sqlalchemy import (
JSON,
DateTime,
Float,
ForeignKey,
Integer,
String,
UniqueConstraint,
func,
)
from sqlalchemy.orm import Mapped, mapped_column
from .base import Base
class PostAssociation(Base):
__tablename__ = "post_association"
__table_args__ = (
UniqueConstraint(
"announcement_post_id", "payload_post_id",
name="uq_post_association_pair",
),
)
id: Mapped[int] = mapped_column(Integer, primary_key=True)
# The teaser — a real post the creator wrote (Patreon, today).
announcement_post_id: Mapped[int] = mapped_column(
ForeignKey("post.id", ondelete="CASCADE"), nullable=False, index=True
)
# What it announced — a synthetic Discord grouping, today. CASCADE on both
# sides: an association to a post that no longer exists is not a fact worth
# keeping, and E3's reversal path (delete the grouping) should not leave a
# dangling proposal behind.
payload_post_id: Mapped[int] = mapped_column(
ForeignKey("post.id", ondelete="CASCADE"), nullable=False, index=True
)
score: Mapped[float] = mapped_column(Float, nullable=False)
# Per-signal strengths as scored, so a proposal stays explicable after the
# weights or the threshold are tuned. Without it, "why was this suggested"
# is unanswerable the moment anything moves.
signals: Mapped[dict | None] = mapped_column(JSON, nullable=True)
# pending | linked | dismissed. A DISMISSED row is kept, not deleted — it
# is what stops the matcher proposing the same rejected pair on every
# subsequent scan, which is the behaviour that makes a review queue
# unusable.
status: Mapped[str] = mapped_column(
String(16), nullable=False, server_default="pending", index=True
)
# WHO linked it: "fc" when the matcher linked a conclusive pair by itself
# (discord_link_auto), "operator" when a person accepted it. The card needs
# this to be honest — a link FC asserted on its own says so and offers an
# undo, which the operator chose over a silent merge (#4402). NULL on a row
# that is not linked, and on rows linked before the column existed, all of
# which an operator accepted: auto-linking shipped in the same release.
linked_by: Mapped[str | None] = mapped_column(String(16), nullable=True)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False,
server_default=func.now(), onupdate=func.now(),
)
-5
View File
@@ -46,11 +46,6 @@ class Source(Base):
enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True, server_default="true")
config_overrides: Mapped[dict | None] = mapped_column(JSON, nullable=True)
# alembic 0115: what the source is called on its platform, where the URL
# alone doesn't say — a Discord link is two numbers. The ingester that
# walks it refreshes it every walk, so a rename reads as the new name.
# NULL until a walk has read it, and on platforms that don't write it.
display_name: Mapped[str | None] = mapped_column(Text, nullable=True)
last_checked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
last_error: Mapped[str | None] = mapped_column(Text, nullable=True)
-85
View File
@@ -1,85 +0,0 @@
"""worker_lane — the most workers the operator will let each lane use.
Milestone 422 step 1, reshaped 2026-09-23. One row per lane in
`services/worker_lanes.LANES`, and ONE COLUMN an operator sets.
## Why there is only one number now
There were three — `slots`, `slots_cap` and `autoscale` — because the manual
dial was built first and the autoscaler arrived last, beside a control that
already existed rather than in place of it.
Operator: *"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."*
So `slots` is gone. How many workers a lane is running right now is a
MEASUREMENT — read live from the worker, moved by the autoscaler, never
stored. Storing it made it look like a preference, which 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` is gone for the same reason: it gated the mechanism behind a
choice, and a lane nobody opted in simply never gave its slots back.
`enabled` is gone too, and is now DERIVED: a cap of zero means no consumers.
"Off" and "may use no workers" were two spellings of one fact, free to
disagree.
## What remains
1 <= live pool <= slots_cap <= derived_ceiling
(autoscaler) (this row) (computed)
The floor is one PROCESS, not zero: billiard will not run an empty pool, and
the parked process is what `add_consumer` lands on when the cap goes back up.
The DERIVED CEILING is deliberately absent from this table. It is computed
from the container's cgroup limits on every read, so a row written on a 32GB
host and later run in a 4GB container is bounded by the 4GB — a stored
ceiling would quietly authorise what the box can no longer hold.
"""
from datetime import datetime
from sqlalchemy import (
CheckConstraint,
DateTime,
Integer,
String,
func,
)
from sqlalchemy.orm import Mapped, mapped_column
from .base import Base
class WorkerLane(Base):
__tablename__ = "worker_lane"
__table_args__ = (
# Bare name — Base.metadata's naming convention prepends
# ck_worker_lane_. Pre-prefixing here doubles it, which is what
# alembic 0088 had to rename four constraints for (#3275).
CheckConstraint("slots_cap >= 0", name="cap_non_negative"),
)
# The lane name from services/worker_lanes.LANES — never a container
# hostname. See models/service_seen.py for why: celery's worker names here
# are `celery@<container id>`, minted fresh on every deploy.
name: Mapped[str] = mapped_column(String(32), primary_key=True)
# The most workers this lane may use. Zero means off — no consumers, so
# the lane takes no work and (for ML) downloads no model.
#
# There is no upper CHECK here, because the bound it would need is the
# derived ceiling, and no column holds that: it depends on the cgroup the
# container is running in right now. Enforced at write instead.
slots_cap: Mapped[int] = mapped_column(Integer, nullable=False)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
nullable=False,
server_default=func.now(),
onupdate=func.now(),
)
-83
View File
@@ -1,83 +0,0 @@
"""worker_lane_sample — the last thing the sizing sweep measured about a lane.
Milestone 422, 2026-09-23. A MEASUREMENT table, deliberately separate from
`worker_lane`, which holds the one number an operator sets.
## Why this exists
Operator, 2026-09-23, looking at the System tab: *"there is a repull every
time this page loads — is there a reason this info isn't being tracked in the
background and stored in some way?"*
There was not a good one. `/api/system/workers` ran a full celery inspect —
four broadcast round trips on an eleven-second budget — on every call, and
the page polls it every fifteen seconds. Meanwhile `size_worker_lanes` was
already inspecting on a timer to decide pool sizes, computing exactly these
numbers, using them, and throwing them away. The browser then asked the
broker for them again.
So the sweep writes what it saw here, and the endpoint reads this table. The
request path makes no broker call at all any more.
## Why NOT columns on `worker_lane`
Because that is the mistake this milestone already made once and undid. That
table used to carry `slots` — how many workers were running — beside
`slots_cap`, and a measurement sitting next to a preference reads as a second
preference: the operator had to keep two numbers in agreement, and the
autoscaler had to be granted permission to move one of them.
The distinction is the whole design, so it is a table boundary. Nothing an
operator sets lives here; nothing here is ever an input to a decision about
what they wanted.
## Freshness is a value, not an assumption
`measured_at` is returned to the UI, which says how old the reading is rather
than implying it is live. A sample is a fact about a moment, and a page that
presents a one-minute-old number as current is how an operator ends up
mistrusting the whole surface.
"""
from datetime import datetime
from sqlalchemy import Boolean, DateTime, Integer, String, func
from sqlalchemy.orm import Mapped, mapped_column
from .base import Base
class WorkerLaneSample(Base):
__tablename__ = "worker_lane_sample"
# The lane name from services/worker_lanes.LANES. One row per lane,
# overwritten in place: this is the LATEST reading, not a history. A time
# series would be a different table with a different retention problem,
# and nothing has asked for one.
lane: Mapped[str] = mapped_column(String(32), primary_key=True)
# Whether anything answered for this lane. NOT the same as "zero workers"
# — an unswept absence is not a verdict (snippet #3969). False here means
# the inspect came back without this lane, so every count below is
# meaningless and the UI must say "not answering" rather than "0".
present: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
replicas: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
# Pool size of ONE process, nullable because a worker that answered
# without reporting its pool is unknown rather than empty.
pool: Mapped[int | None] = mapped_column(Integer, nullable=True)
active: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
reserved: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
# Redis LLEN across the lane's queues. Nullable for the same reason as
# `pool`: a queue the broker did not answer for is unknown, and summing it
# as zero would report a buried lane as idle.
queue_depth: Mapped[int | None] = mapped_column(Integer, nullable=True)
measured_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
nullable=False,
server_default=func.now(),
)
-227
View File
@@ -1,227 +0,0 @@
"""Emit a supervisord config for the single-container layout.
Milestone 422 step 5. Writes to stdout; `entrypoint.sh all` redirects it to a
file and execs supervisord against it.
## Why this is generated and not a checked-in .conf
A static config would spell out each lane's `-Q` list, and that would be a
FIFTH hand-kept copy of the queue names — after `celery_app.task_routes`, and
the three collapsed in steps 1, 2 and 4 (`service_roster.ROLE_NAMES`,
`system_activity._QUEUE_NAMES`, and the Activity filter). Every one of those
had already drifted by the time it was found.
Generating from `worker_lanes.LANES` makes a stronger guarantee than "they
match today": the processes this container runs and the lanes the application
believes in are the same list, so a lane added to `LANES` gets a process
without anyone remembering to add one, and a queue can never end up with no
consumer because a config file was missed.
## Why supervisord
It is one pip dependency on an image that is already Python, and it does the
four things this needs without being clever: restart a program that exits,
give each one its OWN stop timeout, signal the process GROUP rather than the
leader, and put every program's output on one stdout.
The process-group part is not a detail. Celery's prefork pool forks children,
and a TERM delivered only to the parent leaves them running — which is how a
"graceful" shutdown turns into orphaned workers holding tasks. `stopasgroup`
and `killasgroup` are both set for every program.
s6-overlay is the other standard answer and would work; it needs a build-time
download and a second mental model, and its advantage (correct PID-1 signal
and zombie handling) is available here from `init: true` in compose, which
puts tini in front of supervisord. Neither choice reaches the application —
nothing in FC talks to the supervisor — so this is reversible without touching
a line of product code.
## Every lane, including ml
Step 6 merged the images, so this one carries torch and the ML requirements
and the `ml` lane gets a program like any other. It starts at one slot with
its consumers CANCELLED — `enabled=false` in the seeded settings — so it
holds a process and no model. That matters: `add_consumer` needs a running
worker to reach, and without one the UI switch would have nothing to switch.
Nothing is downloaded by starting it. The model fetch is enqueued when the
lane is enabled, which is what lets rule 164 permit a runtime fetch at all.
"""
from __future__ import annotations
import argparse
import shlex
import sys
from ..services.worker_lanes import LANES, MIN_POOL_SLOTS, Lane
# One number for the whole container, and it must cover the SLOWEST lane —
# docker gives the container a single stop timeout, where compose today gives
# each service its own (90/60/180/120s). `maintenance_long` is the 180s one:
# DB backups, library audits and translation backfill. Anything less turns a
# routine restart into a SIGKILL mid-backup.
#
# Per-program values below are the old per-service ones, preserved: supervisord
# waits `stopwaitsecs` for each, and they stop in parallel, so the container's
# own timeout needs to cover the max rather than the sum.
STOP_WAIT_SECONDS: dict[str, int] = {
"worker": 90,
"scheduler": 60,
"maintenance_long": 180,
"ml": 120,
}
DEFAULT_STOP_WAIT = 60
def _program(lane: Lane, *, slots: int) -> str:
"""One [program:x] block.
`stdout_logfile=/dev/fd/1` with maxbytes 0 puts the lane's output straight
on the container's stdout unbuffered, so `docker logs` shows every lane
interleaved rather than supervisord swallowing them into rotated files.
The output is prefixed through `sed` so a line can be attributed to a lane
— four celery workers and hypercorn on one stream are otherwise
indistinguishable. The shell that the pipe requires is exactly why
`stopasgroup` matters: the signal has to reach the celery process, not the
`sh` holding the pipeline.
"""
inner = f"./entrypoint.sh {lane.entrypoint_role}"
prefixed = f"{inner} 2>&1 | sed -u 's/^/[{lane.name}] /'"
stop_wait = STOP_WAIT_SECONDS.get(lane.name, DEFAULT_STOP_WAIT)
return "\n".join([
f"[program:{lane.name}]",
f"command=sh -c {shlex.quote(prefixed)}",
# QUOTED, and that is load-bearing. supervisord parses `environment`
# as a COMMA-separated KEY=VALUE list, so an unquoted queue list reads
# as CELERY_QUEUES=default followed by three malformed entries — and
# the lane would consume only its first queue. Silent: the worker
# starts, reports healthy, and simply never picks up `import`.
f'environment=CELERY_QUEUES="{",".join(lane.queues)}",'
f"CELERY_CONCURRENCY={slots},"
# A UNIQUE celery node name per lane, and the reason is not cosmetic.
# These processes share one hostname, so celery's default
# `celery@<hostname>` made all four the SAME node: inspect collapsed
# their replies, three lanes read as absent, and which three varied
# per call (run 7319). The healthcheck could never pass, and
# pool_grow's `destination` would have addressed an arbitrary lane.
f"CELERY_NODENAME={lane.name}",
"autostart=true",
"autorestart=true",
# A lane that dies instantly and repeatedly is a broken image, not a
# transient fault. Backing off stops it burning a core in a restart
# loop while still recovering from a one-off crash.
"startretries=3",
"startsecs=5",
f"stopwaitsecs={stop_wait}",
"stopasgroup=true",
"killasgroup=true",
"stdout_logfile=/dev/fd/1",
"stdout_logfile_maxbytes=0",
"redirect_stderr=true",
"",
])
# supervisord's control socket. /tmp for the same reason the generated config
# lives there — writable by every role, and per-container by nature.
SOCKET_PATH = "/tmp/supervisor.sock"
def _web_program() -> str:
"""hypercorn. Started FIRST (priority) because its role runs
`alembic upgrade head`, and a worker that boots against an un-migrated
schema fails in a way that looks like application breakage."""
prefixed = "./entrypoint.sh web 2>&1 | sed -u 's/^/[web] /'"
return "\n".join([
"[program:web]",
f"command=sh -c {shlex.quote(prefixed)}",
"priority=1",
"autostart=true",
"autorestart=true",
"startretries=3",
"startsecs=5",
# Short: HTTP requests and the occasional file download. Matches the
# 30s the operator's production stack gives the web service.
"stopwaitsecs=30",
"stopasgroup=true",
"killasgroup=true",
"stdout_logfile=/dev/fd/1",
"stdout_logfile_maxbytes=0",
"redirect_stderr=true",
"",
])
def render() -> str:
parts = [
"\n".join([
"[supervisord]",
# PID 1 in the container, so it must not daemonise.
"nodaemon=true",
# supervisord's OWN log. /dev/fd/1 keeps it on the container's
# stdout beside the programs rather than in a file nobody reads.
"logfile=/dev/fd/1",
"logfile_maxbytes=0",
"loglevel=info",
"",
]),
# THE CONTROL SOCKET, and it is not optional furniture.
#
# Without these three sections supervisord runs perfectly and
# `supervisorctl` cannot talk to it at all:
#
# Error: .ini file does not include supervisorctl section
#
# Which is the first thing anyone reaches for when a lane misbehaves
# in the consolidated container — `docker exec <c> supervisorctl
# status` to see which processes are up, or `restart ml` to bounce one
# without taking the whole application down with it. Consolidation
# took away `docker ps` as the way to see the lanes; this is what
# replaces it, and shipping without it would have left an operator
# with one container, five processes inside it, and no way to ask
# about any of them.
#
# Found by the smoke's own diagnostic line on run 7322, which printed
# this error instead of a process list. It was behind `|| true`, so it
# cost nothing and said so anyway — the argument for printing evidence
# even where nothing depends on it.
#
# /tmp, like the generated config itself: writable by every role
# without assuming a volume, and per-container state that must not
# outlive the container.
"\n".join([
"[unix_http_server]",
f"file={SOCKET_PATH}",
"chmod=0700",
"",
"[rpcinterface:supervisor]",
"supervisor.rpcinterface_factory = "
"supervisor.rpcinterface:make_main_rpcinterface",
"",
"[supervisorctl]",
f"serverurl=unix://{SOCKET_PATH}",
"",
]),
_web_program(),
]
# Lanes after web, in LANES order, so the log reads in a stable sequence.
for lane in LANES:
# A lane configured at zero slots still gets a PROCESS, at one slot
# with its consumers cancelled by the reconcile. Without a running
# worker there is nothing for `add_consumer` to reach, so enabling the
# lane from the UI could not work at all — the process has to exist for
# the switch to have something to switch.
parts.append(_program(lane, slots=MIN_POOL_SLOTS))
return "\n".join(parts)
def main(argv: list[str] | None = None) -> int:
ap = argparse.ArgumentParser(description=__doc__)
ap.parse_args(argv)
sys.stdout.write(render())
return 0
if __name__ == "__main__":
raise SystemExit(main())
-181
View File
@@ -1,181 +0,0 @@
"""The container's healthcheck. Picks the right check from the role it runs.
Exit 0 healthy, non-zero unhealthy.
## Why this is in the IMAGE and not in every compose file
Because the image is the only thing that knows what it is running. A
deployment had to declare a healthcheck per service, which meant every
compose file, stack file and README repeated the same knowledge:
web -> curl /api/health
worker -> celery inspect ping -d celery@$HOSTNAME
all -> both, for every lane
Three checks, written out by hand, once per service, in every file anyone
ever wrote — and none of them wrong until a role changed. Operator, 2026-09-23:
*"why isn't the healthcheck built into the image or base on what command runs
if one is passed in."* There was no reason. The role is already a fact the
container holds; asking the deployment to restate it is the same duplication
the lane table exists to remove one level down.
So `entrypoint.sh` records the role it started, the Dockerfile declares ONE
`HEALTHCHECK` that runs this, and a stack file says nothing at all. Declaring
one anyway still works — docker lets a service override the image's — which
is the escape hatch for a deployment that genuinely wants something else.
## What each role is asked
* **web** — hypercorn answers `/api/health`. No database: the endpoint is a
no-DB 200 that proves the app booted and is serving after `alembic upgrade
head`, which is what a rolling deploy needs to know.
* **worker / scheduler / ml-worker** — THIS container's celery node answers a
ping over the broker. Not "some worker answered": the node name is pinned
to this container, or a healthy sibling would keep a dead one looking alive.
* **all** — both halves, for every lane in the table. The failure mode
consolidation creates is that docker can no longer see the lanes as
separate services, so a web-only check reports a healthy container with
every worker dead.
* **shell / alembic / anything else** — nothing to check. These are one-shot
or interactive; a liveness probe on them has no meaning, so it passes
rather than inventing a verdict.
## An unrecorded role passes rather than failing
If the role file is missing, the entrypoint did not run — someone used
`--entrypoint` or ran a bare command. That is a debugging shape, and a
healthcheck that cannot tell what it is looking at must not assert that the
thing is broken (snippet #3969: an unswept read is not a verdict). It says so
on stdout and exits 0.
"""
from __future__ import annotations
import os
import socket
import sys
import urllib.error
import urllib.request
# Written by entrypoint.sh at boot. /tmp because it is the one path writable
# by every role without assuming a volume, and the value is per-container
# state that must NOT survive into a new container.
ROLE_FILE = os.environ.get("FC_ROLE_FILE", "/tmp/fc-role")
WEB_URL = "http://localhost:8080/api/health"
WEB_TIMEOUT = 5.0
# A broker round trip, so it gets a deadline (rule 156). Generous relative to
# `inspect`'s 2s elsewhere: this runs every 30s with retries, and a transient
# blip flagging a worker unhealthy would roll back a deployment that is fine.
PING_TIMEOUT = 10.0
CELERY_ROLES = {"worker", "scheduler", "ml-worker"}
# Roles with nothing to probe. Listed rather than treated as the default, so
# an unknown role takes the "I cannot tell" path and says so.
NO_CHECK_ROLES = {"shell", "bash", "alembic"}
def current_role() -> str | None:
"""The role this container was started with, or None if nothing recorded."""
env = os.environ.get("FC_ROLE")
if env:
return env.strip()
try:
with open(ROLE_FILE) as fh:
return fh.read().strip() or None
except OSError:
return None
def _web_ok() -> tuple[bool, str]:
try:
with urllib.request.urlopen(WEB_URL, timeout=WEB_TIMEOUT) as resp:
if resp.status == 200:
return True, ""
return False, f"web returned {resp.status}"
except (urllib.error.URLError, OSError) as exc:
return False, f"web unreachable: {exc}"
def _this_node_ok() -> tuple[bool, str]:
"""Ping THIS container's celery node, by name.
Pinned to this node deliberately. A bare `ping()` is answered by any
worker on the broker, so in a stack with several replicas a dead one
would go on reporting healthy for as long as a sibling was alive — the
healthcheck would be measuring the cluster, not the container it is in.
"""
from ..celery_app import celery as celery_app
node = f"{os.environ.get('CELERY_NODENAME', 'celery')}@{socket.gethostname()}"
try:
replies = celery_app.control.ping(destination=[node], timeout=PING_TIMEOUT)
except Exception as exc: # noqa: BLE001 — a probe reports, never raises
return False, f"could not reach the broker: {exc}"
if not replies:
return False, f"{node} did not answer a ping"
return True, ""
def _lanes_ok() -> tuple[bool, str]:
"""Every lane in the table is answering.
Deliberately ignores whether a lane is ON: a lane at cap 0 still runs its
process with its consumers cancelled, so it answers `inspect` and is
healthy. Health is "is the process alive"; whether it should be consuming
is a settings question the sizing pass owns, and conflating them would
make turning a lane off mark the container unhealthy.
That was not merely a risk — it was happening. Until 2026-09-23 a worker
was attributed to its lane by the queues it was CONSUMING, and a lane with
its consumers cancelled reports none, so it read as absent and this check
failed. ML ships at cap 0, so a fresh install was permanently unhealthy
and Swarm restarts an unhealthy task forever. The docstring above said the
right thing while the code did the opposite; `worker_lanes.lane_for_node`
is what makes it true.
"""
from ..services.worker_control import inspect_lanes_sync
from ..services.worker_lanes import LANES
live = inspect_lanes_sync()
missing = sorted(lane.name for lane in LANES if not live[lane.name].present)
if missing:
return False, "lanes not answering: " + ", ".join(missing)
return True, ""
def main(argv: list[str] | None = None) -> int:
role = current_role()
if role is None:
# Not a failure. See the module docstring: the entrypoint did not run,
# so there is no role to check against and no basis for a verdict.
print("no role recorded; nothing to check")
return 0
if role in NO_CHECK_ROLES:
print(f"{role}: nothing to check")
return 0
checks = []
if role == "all":
checks = [_web_ok, _lanes_ok]
elif role == "web":
checks = [_web_ok]
elif role in CELERY_ROLES:
checks = [_this_node_ok]
else:
print(f"unknown role {role!r}; nothing to check")
return 0
for check in checks:
ok, detail = check()
if not ok:
print(detail, file=sys.stderr)
return 1
return 0
if __name__ == "__main__":
raise SystemExit(main())
-119
View File
@@ -1,119 +0,0 @@
"""Restore FabledCurator from its off-site bucket, from a shell in the
container. The Backup screen does the same; this is for an install whose page
you can't reach yet, or for scripting a rebuild.
python -m backend.app.scripts.restore list
python -m backend.app.scripts.restore database --dump latest --yes
python -m backend.app.scripts.restore files
On a brand-new install with no destination saved, name it on the command
line: --endpoint, --bucket and --access-key, with the secret key in the
FC_BACKUP_SECRET_KEY environment variable (or typed at the prompt). It is
checked by listing the bucket, then saved as this install's destination,
sealed, exactly as the settings page saves it.
`database` replaces the database with the chosen dump here, then hands the
files to the worker; their progress shows on the Backup screen. `files`
fetches only what is missing from disk and leaves the database alone.
"""
from __future__ import annotations
import argparse
import getpass
import os
import sys
from ..models import ImportSettings
from ..services import backup_destination, backup_restore
from ..services.backup_s3 import S3Error
from ..tasks._sync_engine import sync_session_factory
def _human(n: int) -> str:
for unit in ("B", "KB", "MB", "GB", "TB"):
if n < 1000 or unit == "TB":
return f"{n:.0f} {unit}" if unit == "B" else f"{n:.1f} {unit}"
n /= 1000
return str(n)
def _destination(args) -> object:
"""The target to restore from: the one named on the command line, saved
first, or the one already saved."""
session_factory = sync_session_factory()
if args.endpoint or args.bucket or args.access_key:
fields, why = backup_destination.clean_fields({
"endpoint": args.endpoint, "bucket": args.bucket,
"access_key": args.access_key, "region": args.region,
})
if fields is None:
raise SystemExit(f"Destination not usable: {why}.")
secret = os.environ.get("FC_BACKUP_SECRET_KEY") or getpass.getpass("Secret key: ")
target = backup_destination.target_for(fields, secret.strip() or None)
if target is None:
raise SystemExit("Name the address, bucket, access key and secret key.")
check = backup_destination.check_target(target)
print(check["message"])
if not check["ok"]:
raise SystemExit(1)
with session_factory() as session:
settings = ImportSettings.load_sync(session)
limit = settings.backup_upload_limit_mbps
backup_restore.keep_destination(session, target, limit)
print("Saved as this install's off-site destination.")
return target
with session_factory() as session:
target = backup_destination.saved_target(ImportSettings.load_sync(session))
if target is None:
raise SystemExit("No off-site destination is saved; name one with "
"--endpoint, --bucket and --access-key.")
return target
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(prog="python -m backend.app.scripts.restore",
description=__doc__.split("\n\n")[0])
parser.add_argument("action", choices=("list", "database", "files"))
parser.add_argument("--dump", default="latest",
help="the dump's key from `list`, or latest (the default)")
parser.add_argument("--yes", action="store_true",
help="confirm replacing this install's database")
parser.add_argument("--endpoint")
parser.add_argument("--bucket")
parser.add_argument("--access-key")
parser.add_argument("--region", default=None)
args = parser.parse_args(argv)
try:
target = _destination(args)
if args.action == "list":
for d in backup_restore.list_dumps(target):
print(f"{d.key} {_human(d.size_bytes)}")
return 0
from ..tasks.backup import restore_offsite_task
if args.action == "files":
restore_offsite_task.delay(files_only=True)
print("Queued: the worker fetches every missing file; watch the Backup screen.")
return 0
dump = backup_restore.find_dump(target, args.dump)
if not args.yes:
print(f"This replaces this install's database with {dump.key} "
f"({_human(dump.size_bytes)}). Run again with --yes to go ahead.")
return 2
print(f"Restoring {dump.key}; this takes a while for a large library…")
result = restore_offsite_task(dump_key=dump.key)
if result.get("skipped"):
print(f"Not started: {result['skipped']}.")
return 1
print("The database is restored. The worker is fetching the library's files; "
"watch the Backup screen.")
return 0
except S3Error as exc:
print(f"Couldn't restore: {exc}", file=sys.stderr)
return 1
if __name__ == "__main__":
sys.exit(main())
-146
View File
@@ -1,146 +0,0 @@
"""Block until Postgres and Redis accept connections. Exit 0 ready, 1 timed out.
## Why the container has to do this itself
Compose has `depends_on: {condition: service_healthy}`, and **Swarm ignores
it**. `docker stack deploy` has no ordering primitive at all: every service in
the stack starts at once, so FabledCurator races Postgres on every cold
deploy and always has.
The multi-service stack hid how sharp that is. `web` ran `alembic upgrade
head`, failed against a Postgres that was still doing `initdb`, and the task
died — but Swarm restarts a failed task forever, so the service came up a few
seconds later and nobody saw a problem worth naming.
Consolidation removes that safety net. supervisord gives each program
`startretries=3`, so a web program that fails three times in the first
seconds goes FATAL and **stays** FATAL: supervisord keeps running, the
container keeps running, and the application never starts. The healthcheck
catches it — but as a container that is permanently unhealthy for a reason
that has nothing to do with the image, on a stack whose database simply took
twenty seconds to initialise.
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.
## A TCP connect, not a query
The same probe `build.yml`'s integration lane and the build smoke already use.
It answers the question that is actually being asked — is something listening
— and it cannot fail for a reason that retrying will never fix.
A real query would be a stronger readiness signal and a worse gate: a wrong
password or a missing database is not a transient condition, and a loop that
waits 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.
The Postgres image is well behaved here: during `initdb` it serves on a unix
socket only and opens TCP when it is ready for clients, so the connect is a
good proxy for "ready" rather than merely "process exists".
"""
from __future__ import annotations
import argparse
import os
import socket
import sys
import time
from urllib.parse import urlparse
# Long enough for a first-ever `initdb` on a slow disk, which is the worst
# case this exists for and is measured in tens of seconds, not minutes. A
# deploy that is genuinely misconfigured should fail while someone is still
# watching it rather than hold the container open for a quarter of an hour.
DEFAULT_TIMEOUT = 120.0
CONNECT_TIMEOUT = 2.0
RETRY_DELAY = 1.0
# Progress every N attempts. `docker logs` on a container that is waiting must
# say what it is waiting for — silence is indistinguishable from a hang.
REPORT_EVERY = 5
def _target(url: str | None, default_port: int) -> tuple[str, int] | None:
"""(host, port) from a connection URL, or None if there is nothing to wait for."""
if not url:
return None
parsed = urlparse(url)
if not parsed.hostname:
return None
return parsed.hostname, parsed.port or default_port
def targets() -> list[tuple[str, tuple[str, int]]]:
"""What this container must reach, read from the same env the app reads.
Derived rather than passed in, so the wait cannot drift from what the
application will actually connect to — a gate that checks a different
host than the app uses is worse than no gate.
"""
out: list[tuple[str, tuple[str, int]]] = []
host = os.environ.get("DB_HOST")
if host:
out.append(("postgres", (host, int(os.environ.get("DB_PORT") or 5432))))
broker = _target(os.environ.get("CELERY_BROKER_URL"), 6379)
if broker:
out.append(("redis", broker))
return out
def _accepts(host: str, port: int) -> bool:
try:
with socket.create_connection((host, port), timeout=CONNECT_TIMEOUT):
return True
except OSError:
return False
def wait(
name: str, host: str, port: int, deadline: float, now=time.monotonic,
) -> bool:
attempt = 0
while True:
if _accepts(host, port):
print(f"[wait] {name} at {host}:{port} is accepting connections")
return True
attempt += 1
if now() >= deadline:
print(
f"[wait] TIMEOUT: {name} at {host}:{port} never accepted a "
f"connection ({attempt} attempts)",
file=sys.stderr,
)
return False
if attempt % REPORT_EVERY == 0:
left = int(deadline - now())
print(f"[wait] {name} at {host}:{port} not ready yet, {left}s left")
time.sleep(RETRY_DELAY)
def main(argv: list[str] | None = None) -> int:
ap = argparse.ArgumentParser(description="Wait for Postgres and Redis.")
ap.add_argument("--timeout", type=float, default=DEFAULT_TIMEOUT)
args = ap.parse_args(argv)
wanted = targets()
if not wanted:
# Nothing configured to wait for. Not an error: `shell` and one-off
# runs are legitimate, and refusing to start would make this gate the
# reason a debugging container will not boot.
print("[wait] no database or broker configured; nothing to wait for")
return 0
deadline = time.monotonic() + args.timeout
for name, (host, port) in wanted:
if not wait(name, host, port, deadline):
return 1
return 0
if __name__ == "__main__":
raise SystemExit(main())
-44
View File
@@ -57,50 +57,6 @@ def is_archive(path: Path) -> bool:
return detect_archive_format(path) is not None
# Files that only a program ships: executables and libraries, and the packed
# data and compiled scripts of the common game engines (Ren'Py, Unity, Godot,
# RPG Maker, Python builds). An archive holding any of them is a game or app
# build. Its images are sprites, UI and CGs bundled for the program, not art
# for the library, so the archive is kept whole as an attachment.
SOFTWARE_EXTS = frozenset({
".exe", ".msi", ".dll", ".so", ".dylib", ".apk",
".rpa", ".rpyc", ".rpy", ".pyc", ".pyo",
".pak", ".pck", ".unity3d", ".assets", ".ress",
".rgssad", ".rgss2a", ".rgss3a", ".rxdata", ".rvdata", ".rvdata2",
})
def member_names(path: Path) -> list[str]:
"""Every member name in the archive, read from its directory without
decompressing anything. Empty when the archive can't be listed."""
try:
fmt = detect_archive_format(path)
if fmt == "zip":
with zipfile.ZipFile(path) as zf:
return zf.namelist()
if fmt == "rar":
import rarfile
with rarfile.RarFile(path) as rf:
return rf.namelist()
if fmt == "7z":
import py7zr
with py7zr.SevenZipFile(path, "r") as zf:
return zf.getnames()
except Exception as exc:
log.warning("archive listing failed for %s: %s", path, exc)
return []
def software_marker(path: Path) -> str | None:
"""The first member that marks the archive as a software build, or None."""
for name in member_names(path):
if Path(name).suffix.lower() in SOFTWARE_EXTS:
return name
return None
@contextmanager
def extract_archive(path: Path):
"""Context manager yielding a list of (member_name, extracted_path)
@@ -1,337 +0,0 @@
"""Proposing that a creator FC tracks and a membership it found are the same.
Milestone 388, step E4. An instance of the confirm-only matcher shape
(snippet #3842), and a sibling of `post_association_service`.
## What E4 turned out NOT to need
The step's own first instruction was to verify before building, and the
verification said: not the schema, not the flows. `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; `SourceService.reassign` already moves a source
between artists WITH post and image re-attribution; and a sweep for
one-source-per-artist assumptions found only `func.count()` calls, which are
the opposite of assuming one.
So the association a Discord source and a Patreon source share is already
expressible today. What was missing is FC OFFERING it.
## Accept adds a SOURCE — it never merges artists
The asymmetry that sets the whole posture: adding a source is trivially undone.
A wrong artist merge silently mixes two creators' work and corrupts tagging,
series and provenance downstream, with nothing left to tell the two apart by.
So the accepted action is "add the missing channel to this artist", and merging
is not offered at all.
## The signals
1. **Name.** The roster's `display_name` and `vanity`, slugified, against the
artist's `slug`. Graded rather than boolean — an exact match is strong
evidence, a containment match is a hint.
2. **Declared.** A post already under this artist whose body links to
`patreon.com/<vanity>` for this exact membership. A creator pointing at
their own Patreon from their own Discord is close to a statement.
Signal 2 is NOT read from `ExternalLink`, and that correction is worth keeping:
`link_extract.SUPPORTED_HOSTS` is file hosts only (mega/gdrive/mediafire/
dropbox/pixeldrain) and `host_for()` returns None for patreon.com, so no
`ExternalLink` row is ever written for one. The same trap already caught E5 for
Discord invites.
## Weights, and what they make impossible
name 0.65 · declared 0.35, cut at 0.60
Chosen so the arithmetic encodes the judgement rather than a code path doing it:
* an EXACT name match alone (0.65) proposes — same slug on both sides is
strong, and requiring corroboration would mean proposing almost nothing;
* a CONTAINMENT name match alone (0.6 * 0.65 = 0.39) does not — "art" inside
"artgirl" is a coincidence generator, and it needs the declaration;
* the declaration ALONE (0.35) never proposes, at any setting at or above
0.60 — 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, so they survive a
refactor of the scorer.
"""
from __future__ import annotations
import logging
import re
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from ..models import (
Artist,
ArtistMembershipSuggestion,
PlatformMembership,
Post,
Source,
)
from ..utils.slug import slugify
from ..utils.text import html_to_plain
from .membership_roster import source_for_membership
log = logging.getLogger(__name__)
WEIGHTS = {"name": 0.65, "declared": 0.35}
DEFAULT_THRESHOLD = 0.60
NAME_EXACT = 1.0
# Containment is a hint, not a match: "art" sits inside "artgirl", and slugs
# are short enough that coincidental containment is common.
NAME_CONTAINS = 0.6
# Below this many characters, containment is noise rather than signal — a
# 3-character slug is inside a great many longer ones.
_MIN_CONTAINMENT_LEN = 5
MAX_CANDIDATES = 25
def name_signal(membership: PlatformMembership, artist: Artist) -> float:
"""Graded slug agreement between a membership and an artist.
Both the display name and the vanity are tried, because creators routinely
differ between the two ("Team Melon Collie" vs "MelonCollieStudios") and
either may be the one the operator typed when they created the artist.
"""
artist_slug = slugify(artist.name or "") if artist.name else ""
if not artist_slug or artist_slug == "untitled":
return 0.0
candidates = {
slugify(v) for v in (membership.display_name, membership.vanity_or_none())
if v
}
candidates.discard("untitled")
if not candidates:
return 0.0
if artist_slug in candidates:
return NAME_EXACT
for c in candidates:
if len(c) < _MIN_CONTAINMENT_LEN or len(artist_slug) < _MIN_CONTAINMENT_LEN:
continue
if c in artist_slug or artist_slug in c:
return NAME_CONTAINS
return 0.0
def declared_signal(body: str | None, vanity: str | None) -> float:
"""Does this post body point at THIS membership's Patreon page?
Matched against the RAW body, not the stripped text: these links live in an
anchor's `href`, and `html_to_plain` discards attributes — the same trap
that caught E5's invite detection. The stripped text is checked too, for
bodies that paste the URL as plain text.
"""
if not body or not vanity:
return 0.0
pattern = re.compile(
r"patreon\.com/(?:c/|cw/|checkout/)?" + re.escape(vanity) + r"\b", re.I
)
if pattern.search(body):
return 1.0
return 1.0 if pattern.search(html_to_plain(body) or "") else 0.0
def weighted_score(signals: dict) -> float:
return round(sum(WEIGHTS[k] * signals.get(k, 0.0) for k in WEIGHTS), 4)
class ArtistMembershipService:
def __init__(self, session: AsyncSession):
self.session = session
async def _decided(self, membership_id: int) -> set[int]:
"""Artists already proposed for this membership, in ANY status.
Dismissed included: the row is what remembers the rejection, and
re-proposing a rejected pair every scan is what makes a queue ignored.
"""
rows = (await self.session.execute(
select(ArtistMembershipSuggestion.artist_id).where(
ArtistMembershipSuggestion.platform_membership_id == membership_id
)
)).scalars().all()
return set(rows)
async def _candidate_artists(self, membership: PlatformMembership) -> list[Artist]:
"""Artists that have SOME source but none for this membership's platform.
A hard filter, not a scored signal. An artist FC already tracks on this
platform needs no suggestion — the link exists — and an artist with no
sources at all is not a creator FC is following through another channel,
which is the whole case this step is about.
"""
# `select(...).exists()` rather than a bare `exists().where(...)`: the
# latter has no FROM to correlate against and does not reliably render.
has_any = select(Source.id).where(Source.artist_id == Artist.id).exists()
has_this = (
select(Source.id)
.where(
Source.artist_id == Artist.id,
Source.platform == membership.platform,
)
.exists()
)
return (await self.session.execute(
select(Artist).where(has_any, ~has_this).limit(MAX_CANDIDATES)
)).scalars().all()
async def _declared_for(self, artist_id: int, vanity: str | None) -> float:
if not vanity:
return 0.0
# Bounded scan: the newest posts are where a creator's current links
# live, and an unbounded body scan per (artist, membership) pair would
# be the expensive part of this sweep.
bodies = (await self.session.execute(
select(Post.description)
.where(Post.artist_id == artist_id, Post.description.is_not(None))
.order_by(func.coalesce(Post.post_date, Post.downloaded_at).desc())
.limit(50)
)).scalars().all()
for body in bodies:
if declared_signal(body, vanity) > 0:
return 1.0
return 0.0
async def match_membership(
self, membership_id: int, *, threshold: float = DEFAULT_THRESHOLD,
) -> int:
membership = await self.session.get(PlatformMembership, membership_id)
if membership is None:
return 0
# The shared identity join (C4), used here as the NEGATIVE check. A
# membership FC already has a source for is tracked — whoever it happens
# to be filed under — and proposing it to some OTHER artist would be
# exactly the wrong link this service exists to avoid making.
# `_candidate_artists` only knows whether a GIVEN artist has a source on
# the platform, which cannot see a source sitting under someone else.
if await source_for_membership(self.session, membership) is not None:
return 0
already = await self._decided(membership_id)
made = 0
for artist in await self._candidate_artists(membership):
if artist.id in already:
continue
signals = {
"name": name_signal(membership, artist),
"declared": await self._declared_for(
artist.id, membership.vanity_or_none()
),
}
score = weighted_score(signals)
if score < threshold:
continue
self.session.add(ArtistMembershipSuggestion(
platform_membership_id=membership.id,
artist_id=artist.id,
score=score,
signals=signals,
status="pending",
))
made += 1
return made
async def list_pending(self) -> list[dict]:
rows = (await self.session.execute(
select(ArtistMembershipSuggestion, PlatformMembership, Artist)
.join(
PlatformMembership,
PlatformMembership.id
== ArtistMembershipSuggestion.platform_membership_id,
)
.join(Artist, Artist.id == ArtistMembershipSuggestion.artist_id)
.where(ArtistMembershipSuggestion.status == "pending")
.order_by(
ArtistMembershipSuggestion.score.desc(),
ArtistMembershipSuggestion.id.desc(),
)
)).all()
return [
{
"id": s.id,
"score": s.score,
"signals": s.signals,
"artist": {"id": a.id, "name": a.name, "slug": a.slug},
"membership": {
"id": m.id,
"platform": m.platform,
"display_name": m.display_name,
"url": m.url,
},
}
for s, m, a in rows
]
async def accept(self, suggestion_id: int) -> dict | None:
"""Add the missing channel to the artist. NEVER merges two artists.
Returns the created source's id, or `already_linked` when a source for
that platform appeared between the proposal and the click — which is
not an error, it is the operator having done it by hand.
"""
s = await self.session.get(ArtistMembershipSuggestion, suggestion_id)
if s is None:
return None
membership = await self.session.get(PlatformMembership, s.platform_membership_id)
if membership is None or not membership.url:
return None
existing = (await self.session.execute(
select(Source.id).where(
Source.artist_id == s.artist_id,
Source.platform == membership.platform,
)
)).scalars().first()
if existing is not None:
s.status = "linked"
return {"id": s.id, "status": s.status, "already_linked": existing}
# Through SourceService, NOT a bare Source() insert. It carries the
# platform/URL validation, the duplicate check and the #693
# backfill-arming that a hand-added source gets — building a second,
# quieter way to create a source is how the two drift until one of them
# is subtly broken (rule 28: repurpose the existing surface).
from .source_service import DuplicateSourceError, SourceService
try:
record = await SourceService(self.session).create(
artist_id=s.artist_id,
platform=membership.platform,
url=membership.url,
)
except DuplicateSourceError as exc:
# The same URL already exists for this artist — the operator got
# there first by a different route. Not an error.
s.status = "linked"
return {"id": s.id, "status": s.status, "already_linked": exc.existing_id}
s.status = "linked"
return {"id": s.id, "status": s.status, "source_id": record.id}
async def dismiss(self, suggestion_id: int) -> dict | None:
s = await self.session.get(ArtistMembershipSuggestion, suggestion_id)
if s is None:
return None
# Kept, not deleted — the row is what remembers the rejection.
s.status = "dismissed"
return {"id": s.id, "status": s.status}
async def rescan(session: AsyncSession, *, threshold: float = DEFAULT_THRESHOLD) -> dict:
"""Offer every known membership to the artists FC already tracks."""
ids = (await session.execute(select(PlatformMembership.id))).scalars().all()
svc = ArtistMembershipService(session)
proposed = 0
for mid in ids:
proposed += await svc.match_membership(mid, threshold=threshold)
log.info(
"artist/membership matcher: scanned %d membership(s), proposed %d pair(s)",
len(ids), proposed,
)
return {"scanned": len(ids), "proposed": proposed}
+7 -34
View File
@@ -300,48 +300,21 @@ class ArtistService:
await self.session.commit()
return artist
async def all_names(self) -> list[tuple[int, str, str]]:
"""Every artist as (id, name, slug), alphabetical.
For pickers that should show a full list before anything is typed (the
Latest feed's artist filter). Three columns and no joins, so it stays
cheap on a library of thousands of artists.
"""
rows = (await self.session.execute(
select(Artist.id, Artist.name, Artist.slug).order_by(func.lower(Artist.name))
)).all()
return [(r.id, r.name, r.slug) for r in rows]
async def autocomplete(self, prefix: str, limit: int = 20) -> list[Artist]:
cleaned = (prefix or "").strip()
if not cleaned:
return []
low = cleaned.lower()
like = f"%{low}%"
prefix_like = f"{low}%"
# Spacing- and punctuation-insensitive too, so "Tamada Heijun" finds
# "TamadaHeijun" and "sabu_art" finds "Sabu Art" — the same creator is
# spelled differently on every platform, and the browser extension's
# Add panel matches a Discord server name against these (milestone 429).
# [[:alnum:]] keeps non-Latin letters; a query with none (all
# punctuation) skips this arm rather than matching every artist.
squashed = "".join(ch for ch in low if ch.isalnum())
name_squashed = func.regexp_replace(func.lower(Artist.name), "[^[:alnum:]]", "", "g")
matches = [func.lower(Artist.name).like(like)]
if squashed:
matches.append(name_squashed.like(f"%{squashed}%"))
# Rank: exact (0) < exact ignoring spacing (1) < prefix (2) <
# substring (3) < substring ignoring spacing (4).
like = f"%{cleaned.lower()}%"
prefix_like = f"{cleaned.lower()}%"
# Rank: exact (0) < prefix (1) < substring (2).
rank = case(
(func.lower(Artist.name) == low, 0),
(name_squashed == squashed, 1),
(func.lower(Artist.name).like(prefix_like), 2),
(func.lower(Artist.name).like(like), 3),
else_=4,
(func.lower(Artist.name) == cleaned.lower(), 0),
(func.lower(Artist.name).like(prefix_like), 1),
else_=2,
).label("rank")
rows = (await self.session.execute(
select(Artist, rank)
.where(or_(*matches))
.where(func.lower(Artist.name).like(like))
.order_by(rank, Artist.name.asc())
.limit(limit)
)).all()
+3 -12
View File
@@ -4,19 +4,11 @@ predicate for BOTH surfaces: FC-Cleanup's retroactive audit and — since
2026-07-02 — the import-side filter (Importer._single_color_hit /
SkipReason.single_color), so what the audit flags and what the import
skips can never disagree.
It is meant to catch the blank: a placeholder, an error tile, a solid fill.
Line art is the case it must not catch (#4483): a pencil doodle on white can be
well over 95% white even at full size, and a smoothing downsample blends its strokes
into the paper until it measures as blank. So the sample is taken by NEAREST
(each sampled pixel is a real pixel, strokes keep their contrast), at 256px
so thin strokes are still hit, and the default threshold is near-total
(0.995) — a few percent of ink is a drawing, not an empty image.
"""
from PIL import Image
_THUMB_SIZE = (256, 256)
_THUMB_SIZE = (64, 64)
def evaluate(
@@ -28,8 +20,7 @@ def evaluate(
"""True iff the fraction of pixels within `tolerance` (Euclidean RGB
distance) of the dominant color exceeds `threshold`.
Samples 256x256 by NEAREST (see the module docstring for why not a
smoothing resample).
Downsamples to 64x64 for speed (~4ms regardless of source size).
Alpha channels are stripped; only RGB is considered. Animated images
use frame 0 (PIL's default after Image.open without seek).
"""
@@ -39,7 +30,7 @@ def evaluate(
elif im.mode not in ("RGB", "L"):
im = im.convert("RGB")
if im.size != _THUMB_SIZE:
im = im.resize(_THUMB_SIZE, Image.Resampling.NEAREST)
im = im.resize(_THUMB_SIZE, Image.Resampling.BILINEAR)
pixels = list(im.getdata())
if not pixels:
return False
-138
View File
@@ -1,138 +0,0 @@
"""The off-site backup destination: an S3 bucket (Fabled Archives, or any S3
service) that the database dumps and the media blobs are sent to.
The address, bucket, access key and region are plain settings. The secret key
is sealed at rest with the credential key (`/images/secrets`), the same key
that seals account logins, and no API ever returns it: the settings answer
only whether one is set. Because the credential key is kept out of every
backup, a database dump carries the secret only as ciphertext nobody can open
without the original server.
"""
from __future__ import annotations
from dataclasses import dataclass
from pathlib import Path
from urllib.parse import urlsplit
from .backup_s3 import S3Error, S3Target, list_objects
from .credential_crypto import CredentialCrypto, InvalidCredentialBlob
KEY_PATH = Path("/images/secrets/credential_key.b64")
DEFAULT_REGION = "us-east-1"
_TEST_LISTING = 5
def _crypto() -> CredentialCrypto:
return CredentialCrypto(KEY_PATH)
def seal(secret: str) -> str:
return _crypto().encrypt(secret).decode("ascii")
def unseal(sealed: str) -> str:
return _crypto().decrypt(sealed.encode("ascii"))
@dataclass(frozen=True)
class DestinationFields:
endpoint: str | None
bucket: str | None
access_key: str | None
region: str
def clean_fields(body: dict) -> tuple[DestinationFields | None, str | None]:
"""The destination fields from a request body, trimmed, with empty ones as
None; or (None, why) when the address can't be an S3 endpoint."""
def text(name: str) -> str | None:
v = body.get(name)
if v is None:
return None
if not isinstance(v, str):
raise TypeError(name)
return v.strip() or None
try:
endpoint = text("endpoint")
bucket = text("bucket")
access_key = text("access_key")
region = text("region") or DEFAULT_REGION
except TypeError as exc:
return None, f"{exc.args[0]} must be text"
if endpoint is not None:
u = urlsplit(endpoint)
if u.scheme not in ("http", "https") or not u.hostname:
return None, ("the address must start with http:// or https:// and name "
"a host, e.g. http://archives.example:8081")
if u.query or u.fragment:
return None, "the address can't carry a query or a fragment"
endpoint = endpoint.rstrip("/")
if bucket is not None and "/" in bucket:
return None, "the bucket is a name, not a path: put any path in the address"
return DestinationFields(endpoint, bucket, access_key, region), None
def target_for(fields: DestinationFields, secret: str | None) -> S3Target | None:
"""The S3 target, or None while any part of it is missing."""
if not (fields.endpoint and fields.bucket and fields.access_key and secret):
return None
return S3Target(fields.endpoint, fields.bucket, fields.access_key, secret,
region=fields.region)
def saved_target(settings) -> S3Target | None:
"""The destination saved in ImportSettings, or None when none is set up.
Raises S3Error when the secret can't be opened (the credential key
changed since it was saved)."""
sealed = settings.backup_s3_secret_sealed
if not sealed:
return None
try:
secret = unseal(sealed)
except InvalidCredentialBlob:
raise S3Error(
"The saved secret key can't be opened on this server (the credential "
"key changed since it was saved). Enter the secret key again."
) from None
return target_for(fields_of(settings), secret)
def fields_of(settings) -> DestinationFields:
return DestinationFields(
settings.backup_s3_endpoint, settings.backup_s3_bucket,
settings.backup_s3_access_key, settings.backup_s3_region or DEFAULT_REGION,
)
def describe(settings) -> dict:
"""The destination as the settings screen shows it: never the secret."""
f = fields_of(settings)
return {
"endpoint": f.endpoint or "",
"bucket": f.bucket or "",
"access_key": f.access_key or "",
"region": f.region,
"secret_set": bool(settings.backup_s3_secret_sealed),
"upload_limit_mbps": settings.backup_upload_limit_mbps,
"configured": bool(
f.endpoint and f.bucket and f.access_key and settings.backup_s3_secret_sealed
),
}
def check_target(target: S3Target) -> dict:
"""Reach the bucket by listing it. A listing proves the address, the
bucket and both keys at once, and writes nothing, because an Archives key
is allowed to add objects but a test object could never be removed."""
try:
page = list_objects(target, limit=_TEST_LISTING)
except S3Error as exc:
return {"ok": False, "message": str(exc)}
if not page.objects:
said = f"Reached {target.bucket} at {target.endpoint}. The bucket is empty."
else:
more = "+" if page.next_token else ""
said = (f"Reached {target.bucket} at {target.endpoint}. It holds "
f"{len(page.objects)}{more} object(s), e.g. {page.objects[0].key}.")
return {"ok": True, "message": said}
-120
View File
@@ -1,120 +0,0 @@
"""Are the backups keeping up? One row on the System view, and the nav dot.
The backups run on their own at night, so the only way to notice one has
stopped is to be told. The System view's health endpoint asks `backup_part`,
which answers with a part like any other: `ok`, or `stale` with the sentence
that says what is wrong. It warns when
- the newest database backup is more than a day old, or there is none;
- the newest database backup failed;
- the weekly check of the off-site bucket found files missing from it, and no
media run has finished since to send them again.
No part at all when backups aren't set up (no off-site destination and the
nightly backup off): someone who chose not to back up is not told so every
fifteen seconds.
"""
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime
from sqlalchemy import select
from ..models import BackupRun, ImportSettings
from . import backup_destination
# A nightly backup is due every 24 hours. The hour of slack covers the dump
# itself and a beat tick that lands a little late.
DUMP_DUE_SECONDS = 25 * 3600
@dataclass(frozen=True)
class BackupFacts:
expected: bool
offsite: bool
last_dump_at: datetime | None = None
last_dump_error: str | None = None
missing: int = 0
def _age(seconds: float) -> str:
hours = int(seconds // 3600)
if hours >= 48:
return f"{hours // 24} days"
if hours >= 1:
return f"{hours} h"
return f"{max(1, int(seconds // 60))} min"
def verdict(facts: BackupFacts, now: datetime) -> dict | None:
"""The part for these facts, or None when backups aren't set up."""
if not facts.expected:
return None
where = "to the off-site bucket" if facts.offsite else "on this server"
problems = []
if facts.last_dump_error:
problems.append(f"The last database backup failed: {facts.last_dump_error}")
if facts.last_dump_at is None:
if not facts.last_dump_error:
problems.append(f"No database backup has been taken {where} yet")
else:
age = (now - facts.last_dump_at).total_seconds()
if age >= DUMP_DUE_SECONDS:
problems.append(f"The newest database backup {where} is {_age(age)} old")
if facts.missing:
problems.append(
f"The bucket check found {facts.missing:,} file(s) missing from the bucket; "
"the next media run sends them again"
)
part = {"key": "backup", "kind": "backup", "name": "Backups"}
if problems:
return {**part, "state": "stale", "detail": ". ".join(problems)}
age = (now - facts.last_dump_at).total_seconds()
return {**part, "state": "ok",
"detail": f"The newest database backup {where} is {_age(age)} old"}
def gather(session) -> BackupFacts:
"""Read the facts `verdict` judges, from a sync session."""
settings = ImportSettings.load_sync(session)
offsite = backup_destination.describe(settings)["configured"]
expected = offsite or bool(settings.backup_db_nightly_enabled)
if not expected:
return BackupFacts(expected=False, offsite=False)
def newest(kind: str, statuses: tuple[str, ...]):
return session.execute(
select(BackupRun).where(BackupRun.kind == kind)
.where(BackupRun.status.in_(statuses))
.order_by(BackupRun.started_at.desc()).limit(1)
).scalar_one_or_none()
last_ok = newest("db", ("ok",))
last_done = newest("db", ("ok", "error"))
failed = None
if last_done is not None and last_done.status == "error":
failed = last_done.error or "no reason recorded"
missing = 0
if offsite:
check = newest("reconcile", ("ok",))
dropped = int((check.manifest or {}).get("dropped") or 0) if check else 0
if dropped:
sent_since = session.execute(
select(BackupRun.id).where(BackupRun.kind == "media")
.where(BackupRun.status == "ok")
.where(BackupRun.finished_at > check.finished_at).limit(1)
).scalar_one_or_none()
missing = 0 if sent_since is not None else dropped
return BackupFacts(
expected=True, offsite=offsite,
last_dump_at=(last_ok.finished_at or last_ok.started_at) if last_ok else None,
last_dump_error=failed, missing=missing,
)
def backup_part(session, now: datetime) -> dict | None:
return verdict(gather(session), now)
-202
View File
@@ -1,202 +0,0 @@
"""The off-site copy of the library's files: every image and attachment, sent
once each to the backup bucket as a content-addressed object.
Layout in the bucket, beside the database dumps under `database/`:
media/ab/cd/<sha256><ext>
`ab` and `cd` are the first four hex digits of the file's sha256, which keeps
any one prefix small, and the extension keeps the files readable in the bucket
without FabledCurator. The sha256 is the one recorded at import, so a file
uploads exactly once however many rows or posts point at it, and a run is
incremental: its work list is every sha with no `backup_blob` row for the
current destination. A file removed from the library stays in the bucket until
the bucket's own retention removes it; FabledCurator never deletes there.
The bucket checks every object against that recorded sha256, so a file whose
bytes changed on disk since import is refused and reported, never stored under
the wrong name.
"""
from __future__ import annotations
import re
import time
from dataclasses import dataclass
from pathlib import Path
from sqlalchemy import delete, select, text
from sqlalchemy.dialects.postgresql import insert
from ..models import BackupBlob
from .backup_s3 import (
Pacer,
S3Error,
S3Target,
list_objects,
object_present,
put_file,
put_file_in_parts,
)
PREFIX = "media/"
# Files above this size go in parts (S3 refuses a single PUT over 5 GiB, and a
# part that fails is resent alone instead of the whole file).
MULTIPART_OVER = 1 << 30
# The bucket is listed this many keys at a time during a reconcile, with a
# short rest between pages.
LIST_PAGE = 1000
LIST_REST_SECONDS = 0.2
_EXT = re.compile(r"^\.[a-z0-9]{1,10}$")
@dataclass(frozen=True)
class Blob:
sha256: str
path: str
size_bytes: int
mime: str | None
def destination_id(endpoint: str, bucket: str) -> str:
"""How backup_blob names a destination: one copy per endpoint and bucket."""
return f"{endpoint.rstrip('/')}/{bucket}"
def target_id(target: S3Target) -> str:
return destination_id(target.endpoint, target.bucket)
def blob_key(sha256: str, path: str) -> str:
"""The bucket key for a file: `media/ab/cd/<sha256><ext>`. An extension
that isn't short and plain is left off, so a key is always safe."""
ext = Path(path).suffix.lower()
return f"{PREFIX}{sha256[:2]}/{sha256[2:4]}/{sha256}{ext if _EXT.match(ext) else ''}"
# Every library file once per sha256: images and attachments together. An
# attachment's sha can repeat across posts, and an image extracted from an
# archive is its own file with its own sha.
_LIBRARY = """
SELECT sha256, path, size_bytes, mime FROM image_record
UNION ALL
SELECT sha256, path, size_bytes, mime FROM post_attachment
"""
def pending(session, tid: str, *, after: str = "", limit: int = 500) -> list[Blob]:
"""The next `limit` files not yet in the bucket, in sha order after
`after`, so a run walks the library once even when some files fail."""
rows = session.execute(text(f"""
SELECT DISTINCT ON (lib.sha256) lib.sha256, lib.path, lib.size_bytes, lib.mime
FROM ({_LIBRARY}) AS lib
WHERE lib.sha256 > :after
AND NOT EXISTS (
SELECT 1 FROM backup_blob b WHERE b.target = :tid AND b.sha256 = lib.sha256
)
ORDER BY lib.sha256, lib.path
LIMIT :limit
"""), {"after": after, "tid": tid, "limit": limit}).all()
return [Blob(r.sha256, r.path, r.size_bytes, r.mime) for r in rows]
def coverage(session, tid: str | None) -> dict:
"""How much of the library is in the bucket: files and bytes, sent and
total, each sha counted once."""
row = session.execute(text(f"""
WITH lib AS (
SELECT DISTINCT ON (sha256) sha256, size_bytes FROM ({_LIBRARY}) AS u
ORDER BY sha256
)
SELECT count(*) AS files_total,
coalesce(sum(lib.size_bytes), 0) AS bytes_total,
count(b.sha256) AS files_sent,
coalesce(sum(lib.size_bytes) FILTER (WHERE b.sha256 IS NOT NULL), 0)
AS bytes_sent
FROM lib
LEFT JOIN backup_blob b ON b.sha256 = lib.sha256 AND b.target = :tid
"""), {"tid": tid or ""}).one()
return {
"files_total": row.files_total, "bytes_total": int(row.bytes_total),
"files_sent": row.files_sent, "bytes_sent": int(row.bytes_sent),
}
def send(target: S3Target, blob: Blob, images_root: Path, pacer: Pacer | None) -> str:
"""Upload one file to its key and return the key. Raises S3Error (or
OSError) with the reason when it can't be sent."""
path = Path(blob.path)
if not path.is_absolute():
path = images_root / path
if not path.is_file():
raise S3Error(f"{path} is not on disk.")
size = path.stat().st_size
if size != blob.size_bytes:
raise S3Error(f"{path} is {size} bytes on disk but {blob.size_bytes} when imported.")
key = blob_key(blob.sha256, blob.path)
content_type = blob.mime or "application/octet-stream"
try:
if size > MULTIPART_OVER:
put_file_in_parts(target, key, path, sha256=blob.sha256,
content_type=content_type, pacer=pacer)
else:
put_file(target, key, path, content_type=content_type,
payload_sha256=blob.sha256, pacer=pacer)
except S3Error:
if object_present(target, key, size):
return key # already there from an earlier run
raise
return key
def record(session, tid: str, blobs: list[tuple[Blob, str]]) -> None:
"""Note files as sent: (blob, key) pairs. A row already there is kept."""
if blobs:
session.execute(insert(BackupBlob).values([
{"target": tid, "sha256": b.sha256, "key": key, "size_bytes": b.size_bytes}
for b, key in blobs
]).on_conflict_do_nothing())
def reconcile(session, target: S3Target) -> dict:
"""Check backup_blob against what the bucket actually holds under
`media/`. A row whose object is missing or the wrong size is dropped, so
the next run sends that file again; an object the bucket holds for a
library file with no row (a restored database, a lost commit) is recorded,
so it isn't sent twice. Returns the counts."""
tid = target_id(target)
held: dict[str, int] = {}
token = None
while True:
page = list_objects(target, PREFIX, LIST_PAGE, continuation=token)
held.update((o.key, o.size) for o in page.objects)
token = page.next_token
if not token:
break
time.sleep(LIST_REST_SECONDS)
rows = session.execute(
select(BackupBlob.sha256, BackupBlob.key, BackupBlob.size_bytes)
.where(BackupBlob.target == tid)
).all()
gone = [r.sha256 for r in rows if held.get(r.key) != r.size_bytes]
for at in range(0, len(gone), 1000):
session.execute(delete(BackupBlob).where(
BackupBlob.target == tid, BackupBlob.sha256.in_(gone[at:at + 1000]),
))
kept = {r.sha256 for r in rows} - set(gone)
found = []
for r in session.execute(text(f"""
SELECT DISTINCT ON (sha256) sha256, path, size_bytes, mime
FROM ({_LIBRARY}) AS lib ORDER BY sha256, path
""")).all():
if r.sha256 in kept:
continue
blob = Blob(r.sha256, r.path, r.size_bytes, r.mime)
key = blob_key(blob.sha256, blob.path)
if held.get(key) == blob.size_bytes:
found.append((blob, key))
for at in range(0, len(found), 1000):
record(session, tid, found[at:at + 1000])
session.commit()
return {"listed": len(held), "dropped": len(gone), "adopted": len(found)}
-198
View File
@@ -1,198 +0,0 @@
"""Restoring from the off-site bucket: the database from one of its dated
dumps, then every library file the restored rows name, fetched back to the
path each row records.
The database comes first because it is the map: each image and attachment row
holds its file's path and sha256, which is also the file's name in the bucket
(`media/ab/cd/<sha256><ext>`). A file is checked against that sha256 on
arrival and only then moved into place, so a restore never leaves a wrong or
half-written file in the library. A file already on disk at the right size is
left alone, which makes a restore safe to run again and lets it bring back
only what a damaged library is missing.
"""
from __future__ import annotations
import os
import re
import shutil
import subprocess
import sys
import tempfile
from dataclasses import dataclass
from pathlib import Path
from sqlalchemy import select, text
from ..models import BackupBlob, ImportSettings
from . import backup_destination
from .backup_media import _LIBRARY, blob_key
from .backup_s3 import Pacer, S3Error, S3Target, get_file, list_objects
DUMP_PREFIX = "database/"
_DUMP_NAME = re.compile(r"^database/fabledcurator-(\d{8}T\d{6}Z)\.dump$")
_ALEMBIC_INI = Path(__file__).resolve().parents[3] / "alembic.ini"
_MIGRATE_TIMEOUT_S = 1800
@dataclass(frozen=True)
class Dump:
key: str
ts: str # 20261107T134258Z, UTC
size_bytes: int
counted: bool = False # its row counts sit beside it, for a test restore
def list_dumps(target: S3Target) -> list[Dump]:
"""Every database dump in the bucket, newest first."""
found, keys, token = [], set(), None
while True:
page = list_objects(target, DUMP_PREFIX, 1000, continuation=token)
for o in page.objects:
keys.add(o.key)
if m := _DUMP_NAME.match(o.key):
found.append((o.key, m.group(1), o.size))
token = page.next_token
if not token:
break
dumps = [Dump(k, ts, size, counted=k.removesuffix(".dump") + ".counts.json" in keys)
for k, ts, size in found]
return sorted(dumps, key=lambda d: d.ts, reverse=True)
def find_dump(target: S3Target, key: str | None) -> Dump:
"""The dump at `key`, or the newest when `key` is None or "latest"."""
dumps = list_dumps(target)
if not dumps:
raise S3Error(f"The {target.bucket} bucket holds no database dumps.")
if key in (None, "", "latest"):
return dumps[0]
for d in dumps:
if d.key == key:
return d
raise S3Error(f"{key} is not among the dumps in the {target.bucket} bucket.")
def fetch_dump(target: S3Target, dump: Dump, into: Path, pacer: Pacer | None) -> Path:
"""Download a dump into the folder `into`, checked against its listed
size. pg_restore reads it from there."""
into.mkdir(parents=True, exist_ok=True)
path = into / Path(dump.key).name
get_file(target, dump.key, path, size=dump.size_bytes, pacer=pacer)
return path
def migrate(db_name: str | None = None) -> None:
"""Bring a restored database up to this install's schema. A dump taken by
an older FabledCurator gains the migrations it predates; one from a newer
FabledCurator fails here, with alembic's reason, because this install
doesn't know its schema. `db_name` names another database on the same
server to bring up instead of this install's own."""
env = {**os.environ, "DB_NAME": db_name} if db_name else None
with tempfile.TemporaryFile() as err:
proc = subprocess.run(
[sys.executable, "-m", "alembic", "-c", str(_ALEMBIC_INI), "upgrade", "head"],
cwd=_ALEMBIC_INI.parent, stdout=subprocess.DEVNULL, stderr=err,
timeout=_MIGRATE_TIMEOUT_S, env=env,
)
if proc.returncode != 0:
err.seek(0)
lines = err.read().decode(errors="replace").strip().splitlines()
raise S3Error("The restored database couldn't be brought up to this "
f"version: {lines[-1] if lines else 'alembic failed'}")
def keep_destination(session, target: S3Target, limit_mbps: int | None) -> None:
"""Write this install's destination into the restored settings. The
dump's own copy of the secret is sealed with the old server's credential
key, which never leaves that server, so it can't be opened here."""
settings = ImportSettings.load_sync(session)
settings.backup_s3_endpoint = target.endpoint
settings.backup_s3_bucket = target.bucket
settings.backup_s3_access_key = target.access_key
settings.backup_s3_region = target.region
settings.backup_s3_secret_sealed = backup_destination.seal(target.secret_key)
settings.backup_upload_limit_mbps = limit_mbps
session.commit()
# ── files ───────────────────────────────────────────────────────────────────
@dataclass(frozen=True)
class LibraryFile:
sha256: str
paths: tuple[str, ...] # every row's path for this sha, sorted
size_bytes: int
def library_files(session, *, after: str = "", limit: int = 200) -> list[LibraryFile]:
"""The next `limit` distinct files the library names, in sha order after
`after`, each with every path a row gives it."""
rows = session.execute(text(f"""
SELECT lib.sha256, array_agg(DISTINCT lib.path ORDER BY lib.path) AS paths,
max(lib.size_bytes) AS size_bytes
FROM ({_LIBRARY}) AS lib
WHERE lib.sha256 > :after
GROUP BY lib.sha256
ORDER BY lib.sha256
LIMIT :limit
"""), {"after": after, "limit": limit}).all()
return [LibraryFile(r.sha256, tuple(r.paths), r.size_bytes) for r in rows]
def sent_keys(session, tid: str, shas: list[str]) -> dict[str, str]:
"""The bucket key recorded for each sha that was sent to this destination."""
if not shas:
return {}
return dict(session.execute(
select(BackupBlob.sha256, BackupBlob.key)
.where(BackupBlob.target == tid, BackupBlob.sha256.in_(shas))
).all())
def _resolve(path: str, images_root: Path) -> Path:
"""Where a row's path lands on this server. A path outside the library
folder is refused: a restore writes only inside it."""
p = Path(path)
if not p.is_absolute():
p = images_root / p
root = Path(*images_root.parts)
if ".." in p.parts or not (p == root or root in p.parents):
raise S3Error(f"{path} is outside {images_root}, so it isn't restored there.")
return p
def _present(p: Path, size: int) -> bool:
try:
return p.is_file() and p.stat().st_size == size
except OSError:
return False
def restore_file(target: S3Target, f: LibraryFile, key: str | None,
images_root: Path, pacer: Pacer | None) -> str:
"""Put one file back at every path the library gives it. Returns
"present" (nothing was missing), "copied" (a path already on disk
supplied the others) or "restored" (fetched from the bucket). Raises
ObjectMissing when the bucket doesn't hold it, S3Error or OSError
otherwise."""
places = [_resolve(p, images_root) for p in f.paths]
have = [p for p in places if _present(p, f.size_bytes)]
need = [p for p in places if p not in have]
if not need:
return "present"
outcome = "copied"
if not have:
first = need.pop(0)
first.parent.mkdir(parents=True, exist_ok=True)
get_file(target, key or blob_key(f.sha256, f.paths[0]), first,
sha256=f.sha256, size=f.size_bytes, pacer=pacer)
have.append(first)
outcome = "restored"
for p in need:
p.parent.mkdir(parents=True, exist_ok=True)
partial = p.with_name(p.name + ".partial")
shutil.copyfile(have[0], partial)
partial.replace(p)
return outcome
-533
View File
@@ -1,533 +0,0 @@
"""A small S3 client for sending backups off the box: AWS Signature Version 4,
path-style addressing (endpoint/bucket/key), and the calls a backup needs —
put an object (whole, or in parts when its size isn't known up front), read
one back, list a prefix.
FabledCurator puts, lists and reads; it never deletes. The bucket keeps the
history and thins it on its own policy, so a key that may only add and read
objects is enough, and is the safer key to hand a service: a stolen one cannot
wipe the history it writes into.
Built on the standard library's http.client rather than boto3: three calls
don't justify a large dependency, and signing is a pure function the tests
hold to AWS's own published examples. Every call is synchronous, because the
uploads run in Celery workers; the API runs the Test destination call in a
thread.
"""
from __future__ import annotations
import hashlib
import hmac
import http.client
import os
import socket
import ssl
import time
import xml.etree.ElementTree as ET
from collections.abc import Iterator
from dataclasses import dataclass
from datetime import UTC, datetime
from pathlib import Path
from urllib.parse import parse_qsl, quote, unquote, urlencode, urlsplit
EMPTY_SHA256 = hashlib.sha256(b"").hexdigest()
TIMEOUT_SECONDS = 30.0
# A single object can be a multi-gigabyte video or archive; a slow link still
# gets this long between bytes before the call gives up.
TRANSFER_TIMEOUT_SECONDS = 15 * 60
CHUNK = 1 << 20
class S3Error(Exception):
"""Why a call to the bucket failed, said to the person."""
@dataclass(frozen=True)
class S3Target:
endpoint: str # http(s)://host[:port][/prefix], no bucket
bucket: str
access_key: str
secret_key: str
region: str = "us-east-1"
verify_tls: bool = True
def url(self, key: str = "") -> str:
base = f"{self.endpoint.rstrip('/')}/{quote(self.bucket, safe='')}"
return f"{base}/{quote(key, safe='/-_.~')}" if key else f"{base}/"
@dataclass(frozen=True)
class StoredObject:
key: str
size: int
last_modified: str
@dataclass(frozen=True)
class ListPage:
objects: list[StoredObject]
next_token: str | None # pass back as `continuation` for the next page
# ── Signing ──────────────────────────────────────────────────────────────────
def _encode(text: str, keep_slash: bool = False) -> str:
return quote(text, safe="-_.~" + ("/" if keep_slash else ""))
def _hmac(key: bytes, msg: str) -> bytes:
return hmac.new(key, msg.encode(), hashlib.sha256).digest()
def sign(method: str, url: str, headers: dict[str, str], payload_sha256: str,
access_key: str, secret_key: str, region: str, at: datetime,
service: str = "s3") -> dict[str, str]:
"""The request's headers with Host, x-amz-date, x-amz-content-sha256 and
Authorization added. Every header given is signed, Host always."""
u = urlsplit(url)
stamp = at.astimezone(UTC).strftime("%Y%m%dT%H%M%SZ")
day = stamp[:8]
out = dict(headers)
out["Host"] = u.netloc
out["x-amz-date"] = stamp
out["x-amz-content-sha256"] = payload_sha256
signed = {k.lower(): " ".join(str(v).split()) for k, v in out.items()}
names = sorted(signed)
query = sorted(
(_encode(k), _encode(v)) for k, v in parse_qsl(u.query, keep_blank_values=True)
)
canonical = "\n".join([
method.upper(),
_encode(unquote(u.path) or "/", keep_slash=True),
"&".join(f"{k}={v}" for k, v in query),
"".join(f"{n}:{signed[n]}\n" for n in names),
";".join(names),
payload_sha256,
])
scope = f"{day}/{region}/{service}/aws4_request"
to_sign = "\n".join(["AWS4-HMAC-SHA256", stamp, scope,
hashlib.sha256(canonical.encode()).hexdigest()])
key = _hmac(_hmac(_hmac(_hmac(f"AWS4{secret_key}".encode(), day), region),
service), "aws4_request")
signature = hmac.new(key, to_sign.encode(), hashlib.sha256).hexdigest()
out["Authorization"] = (f"AWS4-HMAC-SHA256 Credential={access_key}/{scope}, "
f"SignedHeaders={';'.join(names)}, Signature={signature}")
return out
# ── Transport ────────────────────────────────────────────────────────────────
def _connect(target: S3Target, url: str, timeout: float) -> http.client.HTTPConnection:
u = urlsplit(url)
if u.scheme == "https":
ctx = ssl.create_default_context()
if not target.verify_tls:
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
return http.client.HTTPSConnection(
u.hostname, u.port, timeout=timeout, context=ctx, blocksize=CHUNK,
)
if u.scheme != "http":
raise S3Error(f"{target.endpoint} isn't an http:// or https:// address.")
return http.client.HTTPConnection(u.hostname, u.port, timeout=timeout, blocksize=CHUNK)
def _request_target(url: str) -> str:
u = urlsplit(url)
return (u.path or "/") + (f"?{u.query}" if u.query else "")
def _reach_error(target: S3Target, exc: Exception) -> S3Error:
if isinstance(exc, TimeoutError):
return S3Error(f"{target.endpoint} didn't answer in time.")
if isinstance(exc, socket.gaierror):
return S3Error(f"Couldn't reach {target.endpoint}: the host name doesn't resolve.")
if isinstance(exc, ConnectionRefusedError):
return S3Error(
f"Couldn't reach {target.endpoint}: nothing is listening there "
"(check the port)."
)
if isinstance(exc, ssl.SSLError):
return S3Error(f"Couldn't reach {target.endpoint}: TLS failed ({exc.reason}).")
return S3Error(f"Couldn't reach {target.endpoint}: {exc}.")
def _says(status: int, reason: str, content: bytes) -> str:
"""An S3 error response in words: its Code and Message when it has them."""
try:
root = ET.fromstring(content) if content else None
code = (root.findtext("Code") or "") if root is not None else ""
message = (root.findtext("Message") or "") if root is not None else ""
except ET.ParseError:
code, message = "", ""
hints = {
"SignatureDoesNotMatch": "the secret key is wrong",
"InvalidAccessKeyId": "the access key isn't known there",
"NoSuchBucket": "there is no such bucket",
"AccessDenied": "the key isn't allowed to do that",
"XAmzContentSHA256Mismatch": "the file's bytes don't match its recorded checksum",
}
said = hints.get(code) or message or reason
if not code and 400 <= status < 500:
# S3 refuses with an <Error><Code> body. A 4xx without one came from
# something else at that address — most often a web server on the
# default port, when the S3 service listens on a port of its own.
said = ("the address answered, but not as S3 — check the scheme and "
"the port (an S3 service usually listens on its own port)")
return f"HTTP {status}{f' {code}' if code else ''}: {said}."
# ── Calls ────────────────────────────────────────────────────────────────────
def file_sha256(path: Path) -> str:
h = hashlib.sha256()
with open(path, "rb") as f:
while chunk := f.read(CHUNK):
h.update(chunk)
return h.hexdigest()
def put_file(target: S3Target, key: str, path: Path, *,
content_type: str = "application/octet-stream",
payload_sha256: str | None = None,
pacer: Pacer | None = None,
now: datetime | None = None) -> None:
"""Upload one file as one object, streamed from disk. `payload_sha256` is
the file's sha256 when the caller already knows it (the library stores
every file's), which saves reading the file twice; the bucket checks the
bytes against it, so a file that changed on disk is refused rather than
stored under the wrong name."""
digest = payload_sha256 or file_sha256(path)
size = path.stat().st_size
url = target.url(key)
headers = sign("PUT", url, {"Content-Length": str(size), "Content-Type": content_type},
digest, target.access_key, target.secret_key, target.region,
now or datetime.now(UTC))
conn = _connect(target, url, TRANSFER_TIMEOUT_SECONDS)
try:
with open(path, "rb") as f:
conn.request("PUT", _request_target(url),
body=pacer.file_chunks(f) if pacer else f, headers=headers)
response = conn.getresponse()
content = response.read()
except (OSError, http.client.HTTPException) as exc:
raise _reach_error(target, exc) from None
finally:
conn.close()
if response.status // 100 != 2:
raise S3Error(f"Upload of {key} refused: "
f"{_says(response.status, response.reason, content)}")
class ObjectMissing(S3Error):
"""The bucket holds nothing at the key asked for."""
def get_file(target: S3Target, key: str, path: Path, *,
sha256: str | None = None, size: int | None = None,
pacer: Pacer | None = None,
now: datetime | None = None) -> None:
"""Download one object to `path`, streamed to disk. The bytes land under a
`.partial` name and are renamed into place when complete, so a broken
transfer never leaves a truncated file at `path`. With `sha256` (and
`size`) given, the bytes are checked on arrival and a copy that doesn't
match is thrown away and reported. Raises ObjectMissing when the bucket
has no such object."""
url = target.url(key)
headers = sign("GET", url, {}, EMPTY_SHA256, target.access_key, target.secret_key,
target.region, now or datetime.now(UTC))
partial = path.with_name(path.name + ".partial")
digest = hashlib.sha256()
got = 0
conn = _connect(target, url, TRANSFER_TIMEOUT_SECONDS)
try:
conn.request("GET", _request_target(url), headers=headers)
response = conn.getresponse()
if response.status == 404:
response.read()
raise ObjectMissing(f"{key} is not in the {target.bucket} bucket.")
if response.status != 200:
raise S3Error(f"Reading {key} refused: "
f"{_says(response.status, response.reason, response.read())}")
with open(partial, "wb") as f:
while chunk := response.read(CHUNK):
if pacer:
pacer.take(len(chunk))
digest.update(chunk)
got += len(chunk)
f.write(chunk)
if size is not None and got != size:
raise S3Error(f"{key} arrived as {got} bytes, not the {size} expected.")
if sha256 and digest.hexdigest() != sha256:
raise S3Error(f"{key} arrived with a different fingerprint "
f"({digest.hexdigest()[:12]}…), so it wasn't kept.")
os.replace(partial, path)
except (OSError, http.client.HTTPException) as exc:
partial.unlink(missing_ok=True)
raise _reach_error(target, exc) from None
except S3Error:
partial.unlink(missing_ok=True)
raise
finally:
conn.close()
def list_objects(target: S3Target, prefix: str = "", limit: int = 1000, *,
continuation: str | None = None,
now: datetime | None = None) -> ListPage:
"""One page (up to `limit`) of the objects under `prefix`, ListObjectsV2."""
params = {"list-type": "2", "max-keys": str(limit)}
if prefix:
params["prefix"] = prefix
if continuation:
params["continuation-token"] = continuation
url = f"{target.url()}?{urlencode(params, quote_via=quote)}"
headers = sign("GET", url, {}, EMPTY_SHA256, target.access_key, target.secret_key,
target.region, now or datetime.now(UTC))
conn = _connect(target, url, TIMEOUT_SECONDS)
try:
conn.request("GET", _request_target(url), headers=headers)
response = conn.getresponse()
content = response.read()
except (OSError, http.client.HTTPException) as exc:
raise _reach_error(target, exc) from None
finally:
conn.close()
if response.status != 200:
raise S3Error(f"Listing {target.bucket} refused: "
f"{_says(response.status, response.reason, content)}")
try:
root = ET.fromstring(content)
except ET.ParseError:
raise S3Error(
f"{target.endpoint} answered a listing with something that isn't S3's XML."
) from None
objects, next_token = [], None
for item in root.iter():
tag = item.tag.rsplit("}", 1)[-1]
if tag == "NextContinuationToken":
next_token = item.text or None
if tag != "Contents":
continue
fields = {child.tag.rsplit("}", 1)[-1]: (child.text or "") for child in item}
objects.append(StoredObject(fields.get("Key", ""), int(fields.get("Size") or 0),
fields.get("LastModified", "")))
return ListPage(objects, next_token)
# ── Multipart: one object sent in parts ─────────────────────────────────────
# Each part is held in memory while it is sent, so this bounds the memory a
# streamed upload uses. S3 allows 10,000 parts, which at this size is 640 GiB
# for one object, and every part but the last must be at least 5 MiB.
PART_SIZE = 64 * CHUNK
# A part the bucket couldn't take (it didn't answer, or said it was busy) is
# sent again after a rest that grows each time: 15 s, then 60 s.
PART_RETRY_WAITS = (15, 60)
_BUSY = frozenset({429, 500, 502, 503, 504})
_MAX_RETRY_AFTER = 300
class Pacer:
"""Holds the bytes a job moves to an average rate, across every request
it makes, so a backup or a restore leaves room on the link for everything
else."""
def __init__(self, mbps: int | None):
self.rate = mbps * 1_000_000 / 8 if mbps else None # bytes per second
self.sent = 0
self.started = time.monotonic()
def take(self, n: int) -> None:
"""Count `n` bytes moved, pausing while the job is ahead of its rate."""
if self.rate:
self.sent += n
ahead = self.sent / self.rate - (time.monotonic() - self.started)
if ahead > 0:
time.sleep(ahead)
def chunks(self, data: bytes) -> Iterator[memoryview]:
view = memoryview(data)
for at in range(0, len(view), CHUNK):
piece = view[at:at + CHUNK]
self.take(len(piece))
yield piece
def file_chunks(self, f) -> Iterator[bytes]:
while piece := f.read(CHUNK):
self.take(len(piece))
yield piece
def _send(target: S3Target, method: str, url: str, body: bytes = b"",
headers: dict[str, str] | None = None, *, timeout: float = TIMEOUT_SECONDS,
pacer: Pacer | None = None,
now: datetime | None = None) -> tuple[int, str, http.client.HTTPMessage, bytes]:
"""Sign and send one request with an in-memory body, paced when a pacer
is given. Returns (status, reason, headers, content)."""
digest = hashlib.sha256(body).hexdigest()
signed = sign(method, url, {"Content-Length": str(len(body)), **(headers or {})},
digest, target.access_key, target.secret_key, target.region,
now or datetime.now(UTC))
conn = _connect(target, url, timeout)
try:
conn.request(method, _request_target(url),
body=pacer.chunks(body) if pacer and body else body, headers=signed)
response = conn.getresponse()
content = response.read()
except (OSError, http.client.HTTPException) as exc:
raise _reach_error(target, exc) from None
finally:
conn.close()
return response.status, response.reason, response.headers, content
def _xml_field(content: bytes, name: str) -> str | None:
try:
root = ET.fromstring(content)
except ET.ParseError:
return None
for item in root.iter():
if item.tag.rsplit("}", 1)[-1] == name:
return item.text
return None
def _is_error_body(content: bytes) -> bool:
try:
return bool(content) and ET.fromstring(content).tag.rsplit("}", 1)[-1] == "Error"
except ET.ParseError:
return False
def _retry_after(headers: http.client.HTTPMessage | None, default: int) -> int:
try:
asked = int((headers or {}).get("Retry-After") or default)
except ValueError:
asked = default
return max(0, min(asked, _MAX_RETRY_AFTER))
class Upload:
"""One object written as a series of parts, for data whose size isn't known
when it starts, such as a database dump streamed from pg_dump.
The object appears in the bucket only when `complete()` succeeds. Until
then the parts are invisible, so a stream that fails partway leaves
nothing behind that looks like a finished backup. `abort()` asks the
bucket to drop the parts at once; if the abort is refused or never
arrives, S3 services expire an unfinished upload on their own (Fabled
Archives after a day)."""
def __init__(self, target: S3Target, key: str, *,
content_type: str = "application/octet-stream",
pacer: Pacer | None = None):
self.target = target
self.key = key
self.size = 0
self.pacer = pacer
self._etags: list[str] = []
status, reason, _, content = _send(
target, "POST", f"{target.url(key)}?uploads", headers={"Content-Type": content_type},
)
upload_id = _xml_field(content, "UploadId") if status == 200 else None
if not upload_id:
raise S3Error(f"Starting the upload of {key} refused: "
f"{_says(status, reason, content)}")
self.upload_id = upload_id
def _url(self, **params: str) -> str:
query = urlencode({**params, "uploadId": self.upload_id}, quote_via=quote)
return f"{self.target.url(self.key)}?{query}"
@property
def parts(self) -> int:
return len(self._etags)
def add(self, data: bytes) -> None:
"""Send the next part, resting and sending it again when the bucket
doesn't answer or says it is busy."""
n = len(self._etags) + 1
for wait in (*PART_RETRY_WAITS, None):
headers = None
try:
status, reason, headers, content = _send(
self.target, "PUT", self._url(partNumber=str(n)), data,
timeout=TRANSFER_TIMEOUT_SECONDS, pacer=self.pacer,
)
except S3Error as exc:
if wait is None:
raise S3Error(f"Part {n} of {self.key}: {exc}") from None
time.sleep(wait)
continue
etag = headers.get("ETag") if status == 200 else None
if etag:
self._etags.append(etag)
self.size += len(data)
return
if status not in _BUSY or wait is None:
raise S3Error(f"Part {n} of {self.key} refused: "
f"{_says(status, reason, content)}")
time.sleep(_retry_after(headers, wait))
def complete(self) -> None:
"""Join the parts into the object. S3 can refuse a completion with an
error body under a 200 status, so the body is read too."""
if not self._etags:
raise S3Error(f"Nothing was sent for {self.key}.")
body = "<CompleteMultipartUpload>" + "".join(
f"<Part><PartNumber>{n}</PartNumber><ETag>{etag}</ETag></Part>"
for n, etag in enumerate(self._etags, start=1)
) + "</CompleteMultipartUpload>"
status, reason, _, content = _send(
self.target, "POST", self._url(), body.encode(),
headers={"Content-Type": "application/xml"}, timeout=TRANSFER_TIMEOUT_SECONDS,
)
if status != 200 or _is_error_body(content):
raise S3Error(f"Finishing the upload of {self.key} refused: "
f"{_says(status, reason, content)}")
def abort(self) -> None:
"""Drop the parts sent so far. Never raises: it runs while another
failure is already being reported, and the bucket expires the parts on
its own if this doesn't reach it."""
try:
_send(self.target, "DELETE", self._url())
except Exception: # noqa: BLE001 — best effort, see above
pass
def put_file_in_parts(target: S3Target, key: str, path: Path, *, sha256: str,
content_type: str = "application/octet-stream",
pacer: Pacer | None = None) -> int:
"""Upload a large file as a multipart object, read from disk a part at a
time. The whole file's sha256 is computed on the way and must equal
`sha256` before the upload is completed; otherwise it is aborted, so a file
that changed on disk is never stored under its old name. Returns the
number of bytes sent."""
upload = Upload(target, key, content_type=content_type, pacer=pacer)
digest = hashlib.sha256()
try:
with open(path, "rb") as f:
while part := f.read(PART_SIZE):
digest.update(part)
upload.add(part)
if digest.hexdigest() != sha256:
raise S3Error(f"{path} no longer matches its recorded checksum; not stored.")
upload.complete()
except BaseException:
upload.abort()
raise
return upload.size
def object_present(target: S3Target, key: str, size: int) -> bool:
"""Whether `key` is already in the bucket at `size` bytes. Used when a put
is refused: an append-only bucket refuses to replace an object that is
already there, which for a content-addressed key means the work is done."""
try:
page = list_objects(target, prefix=key, limit=1)
except S3Error:
return False
return any(o.key == key and o.size == size for o in page.objects)
+79 -274
View File
@@ -1,11 +1,12 @@
"""FC-3h: first-class backup/restore service for FC.
The database dump (pg_dump), nightly via Beat when the setting is on. With an
off-site destination set up, the dump streams straight to that bucket
(backup_db_offsite), and images and attachments go there too (backup_media).
Without one, the dump is written under <images_root>/_backups/:
fc_db_<ts>.dump — the dump (custom format)
fc_db_<ts>.json — manifest (tag, trigger, row counts)
Two independent backup kinds:
- 'db' — pg_dump only; fast; nightly via Beat (settings-gated)
- 'images' — tar+zstd of /images; slow; manual trigger only
Files live under <images_root>/_backups/. Each backup writes:
fc_<kind>_<ts>.{sql|tar.zst} — the artifact
fc_<kind>_<ts>.json — manifest (kind/tag/triggered_by)
Service functions are sync (subprocess-bound). Celery tasks in
backend.app.tasks.backup wrap each one with task_run-tracked
@@ -14,18 +15,13 @@ lifecycle + soft/hard time limits + retention bookkeeping.
from __future__ import annotations
import json
import logging
import os
import shutil
import subprocess
import tempfile
import time
from contextlib import contextmanager
from datetime import UTC, datetime
from pathlib import Path
log = logging.getLogger(__name__)
_BACKUPS_DIRNAME = "_backups"
# Subprocess-level guardrails BEYOND the Celery soft_time_limit. The Celery
@@ -36,7 +32,9 @@ _BACKUPS_DIRNAME = "_backups"
# matters because an NFS D-state hang defeats even Celery's SIGKILL (the failure
# that wedged the maintenance lane for hours, #739).
# backup_db_task: soft=1800s / hard=2100s → 1700s
# backup_images_task: soft=21600s / hard=23400s → 21000s
_DB_SUBPROCESS_TIMEOUT_S = 1700 # ~28 min, under the 30-min DB soft limit
_IMAGES_SUBPROCESS_TIMEOUT_S = 21000 # ~5.8 hr, under the 6-hr images soft limit
# Grace after SIGKILL to reap the child. If it can't be reaped in this window
# (an uninterruptible NFS D-state — the failure mode that wedged the
# concurrency-1 maintenance lane for hours, operator-flagged 2026-06-07), we
@@ -45,85 +43,6 @@ _DB_SUBPROCESS_TIMEOUT_S = 1700 # ~28 min, under the 30-min DB soft l
_KILL_REAP_GRACE_S = 10
# Tables whose rows stay out of every dump. `credential` holds account logins,
# sealed with the key in /images/secrets: a restored install keeps the table,
# empty, and you sign in to each account again.
DATA_LEFT_OUT = ("credential",)
# Every database dump, local or off-site: custom format (-Fc, compressed and
# restored with pg_restore), without ownership or grants so it loads into any
# install.
_PG_DUMP = ["pg_dump", "--no-owner", "--no-acl", "-Fc",
*(f"--exclude-table-data={t}" for t in DATA_LEFT_OUT)]
def table_counts(conn) -> dict[str, int]:
"""How many rows each table in the public schema holds, read on a psycopg
connection, in the order of the tables' names."""
from psycopg import sql
names = [r[0] for r in conn.execute(
"SELECT tablename FROM pg_tables WHERE schemaname = 'public' ORDER BY tablename")]
return {
n: conn.execute(sql.SQL("SELECT count(*) FROM {}").format(sql.Identifier(n))).fetchone()[0]
for n in names
}
def database_counts(conn) -> dict:
"""{"alembic": the schema's revision, "tables": {name: rows}}."""
has_version = conn.execute("SELECT to_regclass('public.alembic_version')").fetchone()[0]
alembic = (conn.execute("SELECT version_num FROM alembic_version").fetchone()
if has_version else None)
return {"alembic": alembic[0] if alembic else None, "tables": table_counts(conn)}
def read_counts(db_url: str) -> dict:
"""database_counts for the database at `db_url`, as it is now."""
import psycopg
with psycopg.connect(_libpq_url(db_url)) as conn:
return database_counts(conn)
class DumpSnapshot:
"""A read-only transaction whose snapshot pg_dump dumps from (it is handed
over with --snapshot), so rows counted here are exactly the rows the dump
holds. A test restore checks every table against these counts."""
def __init__(self, conn):
self.conn = conn
self.id = conn.execute("SELECT pg_export_snapshot()").fetchone()[0]
def counts(self) -> dict | None:
"""The counts, or None when they can't be read: the dump is good
without them, and a test restore then says none were recorded."""
try:
return self._counts()
except Exception: # noqa: BLE001 — see above
log.exception("backup: the dump's row counts couldn't be read")
return None
def _counts(self) -> dict:
counts = database_counts(self.conn)
for t in DATA_LEFT_OUT:
if t in counts["tables"]:
counts["tables"][t] = 0 # the dump holds the table, not its rows
return counts
@contextmanager
def dump_snapshot(db_url: str):
"""Open the snapshot a dump is taken from. It stays open until the dump
is finished: pg_dump can only take it up while it is."""
import psycopg
with psycopg.connect(_libpq_url(db_url)) as conn:
conn.isolation_level = psycopg.IsolationLevel.REPEATABLE_READ
conn.read_only = True
yield DumpSnapshot(conn)
def _run_bounded(cmd: list[str], timeout: int) -> None:
"""subprocess.run(check=True, timeout) whose reaper can't itself hang.
@@ -179,7 +98,7 @@ def _file_size_or_none(path: Path) -> int | None:
def _write_manifest(
out_dir: Path, *, kind: str, ts: str,
tag: str | None, triggered_by: str,
artifact_path: Path, counts: dict | None = None,
artifact_path: Path,
) -> Path:
manifest = {
"kind": kind,
@@ -189,8 +108,6 @@ def _write_manifest(
"created_at": datetime.now(UTC).isoformat(),
"artifact_path": str(artifact_path),
}
if counts is not None:
manifest["counts"] = counts
mf = out_dir / f"fc_{kind}_{ts}.json"
mf.write_text(json.dumps(manifest, indent=2))
return mf
@@ -216,215 +133,102 @@ def backup_db(
os.close(fd)
tmp_path = Path(tmp_name)
try:
with dump_snapshot(db_url) as snap:
_run_bounded(
_gently([*_PG_DUMP, f"--snapshot={snap.id}", "-f", str(tmp_path),
_libpq_url(db_url)]),
_DB_SUBPROCESS_TIMEOUT_S,
)
counts = snap.counts()
_run_bounded(
[
"pg_dump", "--no-owner", "--no-acl", "-Fc",
"-f", str(tmp_path), _libpq_url(db_url),
],
_DB_SUBPROCESS_TIMEOUT_S,
)
shutil.move(str(tmp_path), str(sql_path))
finally:
if tmp_path.exists():
tmp_path.unlink(missing_ok=True)
manifest_path = _write_manifest(
out_dir, kind="db", ts=ts, tag=tag, triggered_by=triggered_by,
artifact_path=sql_path, counts=counts,
artifact_path=sql_path,
)
return {
"kind": "db",
"ts": ts,
"sql_path": str(sql_path),
"tar_path": None,
"manifest_path": str(manifest_path),
"size_bytes": _file_size_or_none(sql_path),
"counts": counts,
}
class DumpFailed(Exception):
"""pg_dump exited with an error; the message is its last line of output."""
def offsite_dump_key(ts: str) -> str:
"""Where a database dump lands in the off-site bucket. Dated names sit
side by side under one prefix, which is what lets the bucket's retention
thin them by age."""
return f"database/fabledcurator-{ts}.dump"
def _gently(cmd: list[str]) -> list[str]:
"""`cmd` at low CPU priority, and low disk priority where `ionice` is
installed, so a long dump leaves the server responsive."""
io = ["ionice", "-c", "2", "-n", "7"] if shutil.which("ionice") else []
return ["nice", "-n", "10", *io, *cmd]
def _last_line(f) -> str | None:
f.seek(0)
lines = f.read().decode(errors="replace").strip().splitlines()
return lines[-1].strip() if lines else None
def backup_db_offsite(*, db_url: str, target, limit_mbps: int | None = None) -> dict:
"""Stream `pg_dump` straight into the off-site bucket, in parts, with no
copy staged on this server. The object appears only once pg_dump has
exited cleanly and every part has arrived; a dump that fails partway
leaves nothing in the bucket, and raises DumpFailed with pg_dump's own
last line. Returns a dict for the caller to record in BackupRun, with
`counts`: each table's rows in the dump, for the caller to store beside
it."""
def backup_images(
*, images_root: Path,
tag: str | None = None, triggered_by: str = "manual",
) -> dict:
"""Run tar --zstd over images_root; write .tar.zst + manifest."""
ts = _now_ts()
with dump_snapshot(db_url) as snap:
result = _stream_dump(db_url, target, ts, limit_mbps, snap.id)
result["counts"] = snap.counts()
return result
def _stream_dump(db_url: str, target, ts: str, limit_mbps: int | None, snapshot: str) -> dict:
from .backup_s3 import PART_SIZE, Pacer, Upload
key = offsite_dump_key(ts)
deadline = time.monotonic() + _DB_SUBPROCESS_TIMEOUT_S
with tempfile.TemporaryFile() as err:
proc = subprocess.Popen(
_gently([*_PG_DUMP, f"--snapshot={snapshot}", _libpq_url(db_url)]),
stdout=subprocess.PIPE, stderr=err,
)
upload = None
try:
upload = Upload(target, key, pacer=Pacer(limit_mbps))
while part := proc.stdout.read(PART_SIZE):
if time.monotonic() > deadline:
raise subprocess.TimeoutExpired(proc.args, _DB_SUBPROCESS_TIMEOUT_S)
upload.add(part)
proc.stdout.close()
proc.wait(timeout=_KILL_REAP_GRACE_S * 6)
if proc.returncode != 0:
raise DumpFailed(_last_line(err) or f"pg_dump exited with {proc.returncode}")
upload.complete()
except BaseException:
if proc.poll() is None:
proc.kill()
try:
proc.wait(timeout=_KILL_REAP_GRACE_S)
except subprocess.TimeoutExpired:
pass # unkillable (D-state): abandon the reap, fail fast
if upload is not None:
upload.abort()
raise
return {
"kind": "db",
"ts": ts,
"key": key,
"bucket": target.bucket,
"endpoint": target.endpoint,
"parts": upload.parts,
"size_bytes": upload.size,
}
class RestoreFailed(Exception):
"""psql or pg_restore exited with an error; the message is its last line."""
# A restore waits this long for other connections to let go of the tables it
# replaces, then gives up with Postgres' own reason instead of queueing every
# other query behind it.
_RESTORE_LOCK_TIMEOUT = "60s"
# pg_restore of a large library (vector tables, rebuilt indexes) runs well past
# a dump's length; it is bounded on its own.
_RESTORE_SUBPROCESS_TIMEOUT_S = 3 * 3600
def _run_reporting(cmd: list[str], timeout: int, env: dict | None = None) -> None:
"""Run `cmd`; raise RestoreFailed with the last line it printed when it
exits non-zero."""
with tempfile.TemporaryFile() as err:
proc = subprocess.run(cmd, stdout=subprocess.DEVNULL, stderr=err, timeout=timeout,
env=env)
if proc.returncode != 0:
raise RestoreFailed(_last_line(err) or f"{cmd[0]} exited with {proc.returncode}")
# Every other connection to this database: the web server's, the other
# workers'. Ended before the tables are dropped, so none holds a lock the drop
# waits on, and again once the restore is in, so none keeps statements it
# prepared against the old tables. Each reconnects on its next query.
_END_OTHER_CONNECTIONS = (
"SELECT pg_terminate_backend(pid) FROM pg_stat_activity "
"WHERE datname = current_database() AND pid <> pg_backend_pid();"
)
def end_other_connections(*, db_url: str) -> None:
_run_reporting(["psql", _libpq_url(db_url), "-v", "ON_ERROR_STOP=1",
"-c", _END_OTHER_CONNECTIONS], 60)
def wipe_db(*, db_url: str) -> None:
"""Drop and recreate the public schema: an empty database, ready for a
dump or for `alembic upgrade head`."""
_run_reporting(
out_dir = _backups_dir(images_root)
tar_path = out_dir / f"fc_images_{ts}.tar.zst"
# No local-temp here (the archive is hundreds of GB — it can't stage in
# /tmp), but bounded-kill still applies so a tar wedged on NFS fails fast
# rather than holding the lane for hours.
_run_bounded(
[
"psql", _libpq_url(db_url), "-v", "ON_ERROR_STOP=1", "-c",
f"{_END_OTHER_CONNECTIONS} DROP SCHEMA IF EXISTS public CASCADE; "
"CREATE SCHEMA public;",
"tar", "--zstd", "-cf", str(tar_path),
"-C", str(images_root.parent), images_root.name,
f"--exclude={images_root.name}/_backups",
f"--exclude={images_root.name}/_quarantine",
],
120,
# Set for the session, not as a SET inside the same -c string.
env={**os.environ, "PGOPTIONS": f"-c lock_timeout={_RESTORE_LOCK_TIMEOUT}"},
_IMAGES_SUBPROCESS_TIMEOUT_S,
)
# A pg_restore newer than the server sets parameters the server doesn't have
# (pg_restore 17 against Postgres 16: `SET transaction_timeout = 0`). The
# statement fails and nothing depends on it, so its error is not a failed
# restore.
_HARMLESS_RESTORE_ERRORS = (
'unrecognized configuration parameter "transaction_timeout"',
)
def _restore_errors(stderr: str) -> list[str]:
"""pg_restore's error lines, less the harmless ones."""
return [
line.strip() for line in stderr.splitlines()
if "error:" in line and not any(h in line for h in _HARMLESS_RESTORE_ERRORS)
]
manifest_path = _write_manifest(
out_dir, kind="images", ts=ts, tag=tag, triggered_by=triggered_by,
artifact_path=tar_path,
)
return {
"kind": "images",
"ts": ts,
"sql_path": None,
"tar_path": str(tar_path),
"manifest_path": str(manifest_path),
"size_bytes": _file_size_or_none(tar_path),
}
def restore_db(*, db_url: str, sql_path: Path) -> None:
"""Wipe the public schema, then load the custom-format dump. Any error
pg_restore reports, other than a harmless one, raises RestoreFailed with
the first; the database is then partly loaded, and the caller decides what
to put back."""
wipe_db(db_url=db_url)
# Custom-format (-Fc) dumps are restored with pg_restore, not psql. It runs
# on past errors (it can't skip the harmless one and stop at the rest), so
# its errors are judged once it is done.
cmd = _gently(["pg_restore", "--no-owner", "--no-acl",
"-d", _libpq_url(db_url), str(sql_path)])
with tempfile.TemporaryFile() as err:
proc = subprocess.run(cmd, stdout=subprocess.DEVNULL, stderr=err,
timeout=_RESTORE_SUBPROCESS_TIMEOUT_S)
if proc.returncode == 0:
return
err.seek(0)
said = err.read().decode(errors="replace")
errors = _restore_errors(said)
if errors:
raise RestoreFailed(errors[0] if len(errors) == 1
else f"{errors[0]} ({len(errors)} errors in all)")
if not any(h in said for h in _HARMLESS_RESTORE_ERRORS):
# A failure with no error line to show for it.
lines = said.strip().splitlines()
raise RestoreFailed(lines[-1].strip() if lines
else f"pg_restore exited with {proc.returncode}")
"""Wipe public schema, then load from the custom-format dump. Raises on
subprocess failure; partial-restore state is the caller's concern."""
libpq = _libpq_url(db_url)
subprocess.run(
[
"psql", libpq, "-c",
"DROP SCHEMA IF EXISTS public CASCADE; CREATE SCHEMA public;",
],
capture_output=True, check=True, timeout=120,
)
# Custom-format (-Fc) dumps are restored with pg_restore, not psql.
subprocess.run(
["pg_restore", "--no-owner", "--no-acl", "-d", libpq, str(sql_path)],
capture_output=True, check=True,
timeout=_DB_SUBPROCESS_TIMEOUT_S,
)
def restore_images(*, images_root: Path, tar_path: Path) -> None:
"""Untar over images_root.parent. Additive — files NOT in the
tarball are NOT removed. Caller wipes first if a clean restore
is needed."""
subprocess.run(
[
"tar", "--zstd", "-xf", str(tar_path),
"-C", str(images_root.parent),
],
capture_output=True, check=True,
timeout=_IMAGES_SUBPROCESS_TIMEOUT_S,
)
def unlink_artifact_files(
*,
sql_path: str | None,
tar_path: str | None,
manifest_path: str | None,
) -> dict:
"""Best-effort unlink of all on-disk files for a BackupRun row.
@@ -433,6 +237,7 @@ def unlink_artifact_files(
deleted: dict = {}
for label, p in (
("sql", sql_path),
("tar", tar_path),
("manifest", manifest_path),
):
if not p:
-162
View File
@@ -1,162 +0,0 @@
"""Testing a database dump without touching this install.
The dump is loaded into a separate, temporary database on the same Postgres
server, using the same steps a real restore takes. Every table is counted
there and compared, the copy is brought up to this version, and the copy is
then dropped. The live database is only read, to show its counts alongside.
Every dump records how many rows each table held, counted in the same
snapshot pg_dump dumped from, so the comparison is exact. For a dump in the
bucket the counts sit beside it as `<dump name>.counts.json`; for a dump on
this server they are in its manifest file. A dump taken before counts were
recorded can still be tested: it is then compared with the live database,
where a difference is what changed after the dump was taken, not a fault.
The temporary database needs about as much free space on the database's disk
as the live one takes, and only for the length of the test.
"""
from __future__ import annotations
import json
import tempfile
from collections.abc import Callable
from pathlib import Path
from sqlalchemy.engine import make_url
from . import backup_restore, backup_service
from .backup_s3 import ObjectMissing, S3Target, get_file, put_file
SCRATCH_SUFFIX = "_restore_test"
def counts_key(dump_key: str) -> str:
"""Where a dump's row counts sit in the bucket, beside the dump."""
return dump_key.removesuffix(".dump") + ".counts.json"
def store_counts(target: S3Target, dump_key: str, counts: dict) -> None:
with tempfile.TemporaryDirectory() as d:
path = Path(d) / "counts.json"
path.write_text(json.dumps(counts))
put_file(target, counts_key(dump_key), path, content_type="application/json")
def fetch_counts(target: S3Target, dump_key: str) -> dict | None:
"""The counts recorded with a dump in the bucket, or None for a dump that
has none."""
with tempfile.TemporaryDirectory() as d:
path = Path(d) / "counts.json"
try:
get_file(target, counts_key(dump_key), path)
except ObjectMissing:
return None
return json.loads(path.read_text())
def local_counts(manifest_path: str | None) -> dict | None:
"""The counts in a dump's manifest file on this server, or None."""
try:
return json.loads(Path(manifest_path).read_text()).get("counts")
except (TypeError, OSError, ValueError):
return None
def scratch_name(db_url: str) -> str:
"""The temporary database's name: the live one's, plus SCRATCH_SUFFIX."""
return f"{make_url(db_url).database}{SCRATCH_SUFFIX}"
def scratch_url(db_url: str) -> str:
return make_url(db_url).set(database=scratch_name(db_url)).render_as_string(
hide_password=False)
def _on_server(db_url: str, statement) -> None:
"""Run a CREATE or DROP DATABASE, which can't run inside a transaction."""
import psycopg
with psycopg.connect(backup_service._libpq_url(db_url), autocommit=True) as conn:
conn.execute(statement)
def drop_scratch(db_url: str) -> None:
"""Drop the temporary database if it is there, ending any connection to it."""
from psycopg import sql
_on_server(db_url, sql.SQL("DROP DATABASE IF EXISTS {} WITH (FORCE)").format(
sql.Identifier(scratch_name(db_url))))
def _create_scratch(db_url: str) -> None:
from psycopg import sql
_on_server(db_url, sql.SQL("CREATE DATABASE {} TEMPLATE template0").format(
sql.Identifier(scratch_name(db_url))))
def trial_restore(*, db_url: str, dump_path: Path, recorded: dict | None,
note: Callable[[str], None] = lambda phase: None) -> dict:
"""Load the dump at `dump_path` into the temporary database, compare it,
bring it up to this version, and drop it. `note` is told each phase as it
starts: "loading", "comparing", "upgrading". Raises RestoreFailed (or
S3Error from the upgrade) when the dump doesn't load or can't be brought
up; the temporary database is dropped either way."""
scratch = scratch_url(db_url)
if make_url(scratch).database == make_url(db_url).database:
raise ValueError("The test database would be the live one.")
drop_scratch(db_url) # one left by a test cut off by a restart
_create_scratch(db_url)
try:
note("loading")
backup_service.restore_db(db_url=scratch, sql_path=dump_path)
note("comparing")
restored = backup_service.read_counts(scratch)
live = backup_service.read_counts(db_url)
note("upgrading")
backup_restore.migrate(db_name=scratch_name(db_url))
upgraded = backup_service.read_counts(scratch)["alembic"]
finally:
drop_scratch(db_url)
return {**compare(recorded, restored, live),
"alembic_dump": restored["alembic"], "alembic_now": upgraded}
def compare(recorded: dict | None, restored: dict, live: dict) -> dict:
"""Each table's rows in the dump (as recorded), in the restored copy, and
in the live database. With counts recorded, every table must come back
with exactly its count; `mismatched` names those that didn't."""
rec = (recorded or {}).get("tables")
names = sorted(set(restored["tables"]) | set(rec or {}) | set(live["tables"]))
tables = [
{"table": n, "dump": rec.get(n) if rec is not None else None,
"restored": restored["tables"].get(n), "live": live["tables"].get(n)}
for n in names
]
mismatched = [t["table"] for t in tables
if rec is not None and t["table"] in rec and t["restored"] != t["dump"]]
return {
"counted": rec is not None,
"tables": tables,
"mismatched": mismatched,
"rows_restored": sum(v for v in restored["tables"].values()),
}
def summary(result: dict) -> str:
"""One sentence on how a test went."""
n = len([t for t in result["tables"] if t["restored"] is not None])
rows = f"{result['rows_restored']:,}"
if result["mismatched"]:
shown = ", ".join(
f"{t['table']} ({t['dump']:,} in the dump, "
+ ("table missing)" if t["restored"] is None else f"{t['restored']:,} restored)")
for t in result["tables"] if t["table"] in result["mismatched"][:5]
)
more = len(result["mismatched"]) - 5
return (f"{len(result['mismatched'])} table(s) came back different: {shown}"
+ (f", and {more} more" if more > 0 else "") + ".")
if result["counted"]:
return f"All {n} tables came back with the {rows} rows the dump recorded."
return (f"The dump loaded cleanly: {n} tables, {rows} rows. It was taken before "
"dumps recorded their counts, so it is shown beside the live database instead.")

Some files were not shown because too many files have changed in this diff Show More