84 Commits

Author SHA1 Message Date
Zachary Lowden e92cf5fe4a test(geometry): a browser tier that loads the real cascade at a phone viewport (#4601)
* test(geometry): a browser tier that loads the real cascade at a phone viewport

Adds a fourth Vitest project, `geometry`, and demonstrates it catching a defect
whose own source comment records that nothing rendered can see it.

WHAT THE GAP IS, AND WHAT IT IS NOT. The `component` project is NOT jsdom — it is
real headless Chromium via @vitest/browser-playwright, `page.viewport()` moves
`window.innerWidth`, and `getBoundingClientRect()` returns real boxes. What it is
missing is the STYLESHEET and the VIEWPORT. `test/component-setup.tsx` injects
only the `:root` custom properties parsed out of globals.css, so the document
holds 24 CSS rules: Mantine classes are styleless, Tailwind utilities are inert,
and any `getComputedStyle` assertion whose expected value is the CSS initial
value passes against a broken component. And nothing sets a viewport, so files
inherit the runner's silent 414x896.

THE SAME FIXTURE, THE SAME CORRECT SOURCE, IN BOTH TIERS (PageBlockHost in its
production shell chain):

                            `component`      `geometry`
  viewport                    414 x 896       390 x 844   (default vs set)
  CSS rules in the document          24           3,677
  box-sizing on a bare div  content-box      border-box
  `className="flex"`              block            flex
  chrome bar height                 200              31
  host frame height                 350             844
  APP COLUMN HEIGHT                 150             813

That last row is the argument. 150 is ALSO what the app column measures once the
recorded `flex: 1` defect is planted, so a threshold written in the `component`
tier would have to expect the number the DEFECT produces.

THE HARNESS. `test/geometry-setup.tsx` loads the production cascade in production
order — the `@layer tailwind-preflight, theme, mantine, modules;` statement first
(as _document.tsx emits it), then globals.css, then every `@mantine/*` layer
stylesheet _app.tsx imports. It defaults to a 390x844 phone and THROWS unless the
window reports back the size it asked for; tests assert `observed` against their
own literal on top of that. It exports measurement helpers: `box`,
`childrenUnionBox` (the union of child rects — `scrollHeight` is clamped to the
padding box and cannot see a parent taller than its content), `flexAxis`,
`flexLonghands` (longhands, because `getComputedStyle(el).flex` serialises
`1 1 220px` and `1 1 0%` identically), and `cascadeEvidence`.

WHY A PROJECT AND NOT A CHANGE TO THE SHARED SETUP. Loading the cascade in
`component-setup.tsx` moves existing numbers — measured, the same chrome bar is
200px there and 31px with the cascade, a 169px move on one element, under 212
files / 2,362 tests of which 14 read getBoundingClientRect and 20 read
getComputedStyle. The per-file import pattern (15 files do it today, 3 also take
globals.css) stays available and is not deprecated; what it cannot give is a
guarantee — those files each picked their own subset, none declares the @layer
order, none sets a viewport, and the "did my stylesheet load" guard is
re-hand-rolled per file. The `geometry` glob (`src/**/*.geometry.test.tsx`) is
disjoint from every other project's, so nothing that runs today changes project
and no file is collected twice.

DEMONSTRATED RED. `PageBlockHostFillHeight.geometry.test.tsx` asserts that the
app column reaches the bottom of the host frame at 390x844 and at 390x640.
Dropping `flex: 1` from `app-page-content` (the mutation PageBlockHost.tsx's own
comment records as invisible to every rendered tier) fails it:

  the app column ends at y=181 inside a frame that ends at y=844 — 663px of the
  phone is blank below a running App Block. The column measured 150px of the
  frame's 844px, with flex longhands {"grow":"0","shrink":"1","basis":"auto"}.

Restoring the property returns it to 10/10. A second mutant — dropping `flex: 1`
from the frame's `fit === 'fill'` branch — collapses the column to 269px and is
caught by the literal floor rather than by the frame/content comparison, which
both mutations keep satisfied.

BOTH MUTANTS ARE ALSO CAUGHT IN THE NODE TIER TODAY, by verbatim source pins in
pageBlockHostMaxWidth.test.ts and pageRunScrollContract.test.ts. Stated plainly
so this is not read as claiming otherwise. The difference is what each guard can
SEE: a source pin is a claim about the text of one file, blind to a collapse
arriving from the cascade, from an ancestor or from a viewport, and it has to be
rewritten every time the block is legitimately reformatted.

WHAT RUNS THIS TODAY: NOTHING. lint.yml selects `--project 'unit*'`,
`'@civitai/*'` and `'app:*'`; no pattern matches `component` or `geometry`,
because the Actions runners install no Chromium. `component` has the preview
pipeline's report-only status; `geometry` has no CI home at all. Wiring one is a
pipeline change and deliberately not in this PR — but a harness nothing runs
rots, so it is said out loud in the setup file rather than left to be discovered.

Verification: typecheck 0 errors · geometry 2 files / 10 tests passed · component
212 files / 2,362 tests passed (unchanged) · scripts unit tier 24 files / 521
tests passed · eslint clean on both new test files.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* ci(geometry): run the geometry tier, and refuse a green that collected nothing

Adds a `Geometry tests` job to lint.yml. Without it this harness runs in no gate
at all — the workflow selects projects by name (`unit*`, `@civitai/*`, `app:*`)
and none of those patterns matches `geometry`.

ALIGNED WITH THE `unit` JOB'S COMMENT, NOT AN OVERRIDE OF IT. That comment gives
exactly one reason for keeping browser tests out — "they need Chromium, which
this job does not install. That is the whole reason." — which is a statement
about what that job provides, not a ban on providing it. The same comment then
retracts the only other objection on the record ("Don't cite a cold-cache flake
as a reason to keep this job Chromium-free") and names vitest.config.mts's
dedupe + optimizeDeps pre-bundling as the canonical fix. A job that DOES install
Chromium satisfies the stated condition.

GEOMETRY ONLY. `component` stays ungated and that is now visible rather than
fixed. Measured on a 16-core box: `geometry` is 2 files / 10 tests in 9.07s;
`component` is 212 files / 2,362 tests in 112.92s wall, of which 334s is test
time spread across workers — so a 2-core runner (browser pool `min(12, cpus-1)`
= 1 instance) does not divide it. That is an order of magnitude more expensive,
with 212 files of pass/fail history this workflow has never seen. It belongs in
its own PR.

REPORT-ONLY, mirroring `unit` and for its stated reason: blocking a brand-new
tier from day one would red unrelated PRs and the job would be switched off
within a week. `main` has no required_status_checks, so nothing here blocks a
merge either way; `continue-on-error` only decides whether a red renders as red
or as red-but-ignored. The FLIP TO BLOCKING note says concretely what would make
that safe. The `unit` job's selectors and its `continue-on-error` are untouched.

CHROMIUM comes from `pnpm exec playwright install --with-deps chromium` — the
workspace-local playwright, so the revision follows this repo's own pin rather
than a second version written into the workflow. Desynchronising those two is
the documented failure mode (CLAUDE.md records 59 preview specs dying on a
revision mismatch with zero specs run). A local NixOS bundle mismatch is a
property of that host and is handled by PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH; the
pin is not moved to accommodate it.

A GREEN VITEST RUN IS A CLAIM, NOT EVIDENCE — the hazard the `packages` job's
ledger exists for, one project over. `--project` matching nothing exits 0, and
this tier's glob is deliberately narrow, which is exactly the kind of pattern
that can quietly stop matching. The new step asserts floors of 2 files and 10
tests from the JSON report, with `if: always()` so it also fires when the tests
fail or the runner aborts without writing a report.

Two things measured rather than assumed while writing it: the file count comes
from `testResults.length`, NOT `numTotalTestSuites` — against a real report this
run is 2 files while that field reads 4, because it counts `describe` blocks.
And the gate script was extracted back out of the parsed YAML and executed on
all three arms before commit: real report -> exit 0 (2 files, 10 tests); a
report with `testResults: []` -> exit 1; a missing report -> exit 1 with its own
message.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(ci): the geometry job must be report-only on PRs ONLY, not on pushes to main

Caught by this repo's own gate on the previous commit's run:
`scripts/__tests__/main-branch-ci-coverage.test.ts` > "has no unconditionally
report-only job on the push path" went red (Unit tests shard 2, 1 failed / 5,624
passed).

The job was written with a literal `continue-on-error: true`. That is report-only
on EVERY event, including a push to `main`, where it produces a run reporting
`success` while the step underneath failed — the stale-TRUE shape that guard
exists to forbid, and the exact hazard the `unit` job's own comment spells out:
"A green that has to be disbelieved is worse than no run at all."

Now `${{ github.event_name == 'pull_request' }}`, byte-identical to `unit`'s.
Report-only on PRs (a new tier should not red unrelated work while it settles),
honest verdict on `main` (where the merge has already happened and there is no
unrelated work to protect). The guard accepts a conditional precisely because a
conditional can differ between a PR and a push; a literal cannot.

The comment now records this so the next reader does not "simplify" it back.

Red at c2359ba847 (CI), green at HEAD: the guard file is 1 file / 6 tests passed
locally, and the whole `scripts/` unit tier is 24 files / 521 tests passed. That
tier was run BEFORE the workflow existed on the previous commit, which is why it
did not catch this locally — a suite is only evidence about the tree it ran on.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-03 16:40:40 -05:00
Zachary Lowden afe84cf7c8 ci: shard the unit suite across 4 runners, 10.4m -> 3.8m (#4392)
Measured from the Actions API (n=12): the job was 555.5s of test work plus
66.5s of fixed overhead = 622s, the 10.4m median, and 3.4x the next-slowest
job. T(N) = 66.5 + 555.5/N puts N=4 at ~3.4m.

Measured after: 10.3m -> 3.8m on the critical path (2.7x) at +36%
runner-minutes, reproduced across two runs with a 1.12-1.14x shard spread.
N=4 rather than more because `App unit tests + typecheck` is 2.9m — past N=5
sharding optimises something that is no longer the bottleneck.

Ships with a positive control, `scripts/ci/assert-shard-ran.mjs`. Sharding
adds a failure this job did not have: a `--shard=i/N` selecting no files exits
0 and reports green, and four green checks look identical whether they ran
22,000 tests or none. The guard asserts per shard that work happened, counting
EXECUTED assertions rather than `numTotalTests` (which includes skipped, so an
all-self-skipping shard would pass a total-based floor), and its bounds scale
with `total` so a change to the matrix does not trip them.

It earned its keep immediately: on the first run it failed all four shards
while `Unit tests` reported SUCCESS. `pnpm run … -- --shard=…` forwards the
`--` literally, vitest DISCARDS everything after it, and so `--shard` and
`--outputFile` were both silently dropped — each runner executed the entire
suite (7m30s-9m54s per step) and wrote no report. Without the control this
would have merged as four green checks that tested nothing verifiable.

A subsequent adversarial audit found three further claims that measurement did
not support, all fixed here: bounds hardcoded off a ten-day-stale suite size
that would have tripped within weeks and blamed the wrong cause; a comment
asserting post-`--` args become filename filters when they are discarded; and
a "mutation-verified" claim that five of twelve mutants walked through. Four
are now covered by named cases; the fifth was unreachable dead code and is
removed rather than papered over with a test that could not kill it.

Final sweep, re-run after the Prettier pass: 10 mutants, 10 killed, each by its
own named test, with a no-op negative control that correctly survived. Suites
20/20. Verified live across two runs; the typecheck-scripts gate was confirmed
to have executed rather than assumed green.

Not verified: behaviour on a push to `main`, where `continue-on-error` is false
and a red shard renders as a genuine failure. That path only exercises on merge.
2026-08-25 15:59:41 -05:00
Manuel Emilio Urena 63d4a1efa7 feat(text-moderation): measure a text policy per surface, and let one surface run it (#4358)
* chore(gitignore): document what local/ is protecting

The XGuard tuning harness lives there alongside the ticket agent, and it carries
scanner policy prose and corpora pulled from production. This repo is public, so
the entry is load-bearing rather than housekeeping — say so where someone might
otherwise tidy it away.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(xguard-lab): decide SSL from the URL's sslmode, not the hostname

`/xguard` returned a 500 with SELF_SIGNED_CERT_IN_CHAIN for anyone reaching the
lab database through a local port-forward, which is the documented dev path for
the cluster instance. SSL was decided from `isLocalHost(url)`, but a tunnel and a
local docker Postgres are both `localhost` and need opposite answers — the tunnel
needs SSL on with verification off, plain docker rejects SSL outright. The
connection string already declared its sslmode; the code was not reading it.

`labelConnectionString()` had the same bug by another route: eval-core and
rate-core each open a bare `pg.Client` from it, bypassing the Kysely path, so the
policy editor's Evaluate action failed the same way. Fixed at the shared helper.

`moderator-db.ts` already hardcodes `sslNoVerify: true` against the same
database, so two files pointing at one instance disagreed about its SSL and only
one of them was right.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(xguard-lab): evaluate against the scanner the content is actually scanned by

`scan()` hardcoded `mode: 'prompt'`, so a batch of model listings was scored
against the prompt registry. Prompt and text are separate registries with
separate label sets and separate thresholds, so the wrong mode does not fail — it
returns a confident number for a scanner the content never passes through.

Mode is now derived from `sample.source` rather than defaulted, because a default
is silent and an operator clicking Evaluate has no reason to know the two exist.
An unknown source throws naming the source; a batch mixing both throws rather
than averaging two scanners into one precision. The resolved mode is stamped on
the run note, since `eval_run` has no column for it and a run whose mode cannot
be recovered cannot be compared against any other run.

The Evaluate action in the policy editor passed no mode at all.

Live baselines now require an explicit threshold instead of defaulting to one. A
baseline exists to say what production does today, and production's threshold
lives in the orchestrator registry — a default silently measures a different
operating point and records it as "live".

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* feat(xguard-lab): make a label a judgement and a domain what it is read against

Every rubric restated what kind of text it judges — the prompt ones that they
judge prompts, the listing ones that they judge listings. A new surface meant
another copy of that paragraph in every label.

"Does this text assert a minor" is the same question whether the text is a
prompt, a model listing, an article or a bounty. What differs is the shape of the
input and the conventions of the people writing it. `DOMAINS` holds that,
`rubric` holds the judgement, `domainNotes` holds a convention that applies to
one surface only, and `rubricFor` composes them. A new surface is one `DOMAINS`
entry rather than an edit to every rubric.

The rater no longer carries a hardcoded idea of what it is reading — the domain
comes from `sample.source`, for the same reason the scanner mode does. An unknown
source throws.

`domains` records where a label is meaningful, and asking for a pair that is not
listed throws rather than quietly rating against the wrong preamble. The
listing labels claim `modelListing` only: their bodies still name conventions
specific to that surface, so claiming prompt reuse would assert something that
has not been measured. Splitting those bodies into a neutral core plus
`domainNotes` is the follow-up that makes the claim true.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* feat(xguard-lab): sample and review corpora for any text-mode entity

The lab could only sample generation prompts from ClickHouse, so preparing a
batch for anything scanned in text mode meant hand-seeding rows.

`sample-entities.ts` stratifies an entity's content across a label's score bands
using scores `EntityModeration.result` already holds, so building a batch costs
no model calls. Stratifying is the point: a label's scores are rarely spread
evenly across live content and different labels skew in opposite directions, so a
uniform sample of a skewed label says nothing about where its boundary sits.
Listings with little or no description are kept as a bounded minority rather than
excluded, since dropping them entirely would tune a label against a population it
does not meet in production.

Everything entity-shaped is in `ENTITY_TEXT`, which maps an entity to the table
and columns its scanned string is built from. Each entry must match that entity's
`resolveContent` in its production adapter, or reviewers judge text the scanner
never sees. Challenge and WildcardSetCategory are absent deliberately — neither
is a title+body pair, and inventing a resolver that merely looks right is the
drift that comment warns about.

`seed-from-predictions.ts` feeds the rows a policy fired on back into the review
queue. Precision is TP/(TP+FP) and every term is a fired row, so when positives
are a small minority, reviewing those measures it exactly while a random sample
spends most of the effort on rows that cannot change the answer. It is documented
as unusable for recall, since the rows it drops are the false negatives.

Harness paths in the README and `import-policy.ts` follow the move to `local/`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* feat(text-moderation): let one surface scan under its own label policy

A tuned text policy had only one route to production: writing the global text
registry, which Model, Article, Challenge and WildcardSetCategory all read. So
raising a threshold measured on model listings also moved it for every other
text consumer, none of which it was measured against.

`createXGuardModerationRequest` already accepted and forwarded `labelOverrides`
for both modes — only `submitTextModeration`, the entity-facing wrapper every
adapter goes through, dropped it on the floor. It now takes and forwards them,
and the override shape is an exported type rather than a third inline copy.

Forwarding alone would have shipped a footgun. The dedup answers a request from
an earlier workflow whenever the content hash matches, and that workflow was
scored under whatever policy was in force at the time — so the first request
under a new policy would have been answered with the old policy's verdict, for
content that was never scanned under the new one. That is the same failure this
branch has been fixing elsewhere: a confident number for a scan that did not
run. The overrides now form part of the dedup key.

Canonicalised rather than serialised as given, because a per-surface policy is
meant to be the steady state: an equivalent policy in a different order has to
keep deduping, or every save of an unchanged entity burns a scan. The suffix is
empty when there are no overrides, so every hash written so far still matches
and no entity is rescanned by this change.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-25 13:48:17 -04:00
Justin Maier 5bf9ab1429 fix(stickers): wrap the placement tray into two scrollable rows (#4289)
* fix(stickers): let CSS own the placement tray's overflow (#868kut2bf)

The tray's sticker row scrolled through `ScrollArea.Autosize`, which wraps its
child in a `display:flex; overflow:auto` box whose `flex:1` inner box keeps the
default `min-width:auto`. With a `wrap="nowrap"` row that inner box refuses to
shrink to the panel, so it becomes a second scroll container and Mantine's own
viewport measures a width larger than anything the user can see.

Measured on a 500px viewport with 12 stickers owned: the panel is 474px wide and
the scroll viewport came out 555px, so 81px of the track — and the last sticker —
sat outside the panel's clip, and the thumb was sized 322px against a 954px row
instead of 235px. That is the wrong-proportions half of the report.

The missing-bar half is the same wrapper: `type="auto"` shows the bar only when
its ResizeObserver produces a measurement, and on first mount it does not. Same
geometry both times (555 < 954), `data-state=hidden` on first open and `visible`
after a close and reopen — which is what the third reporter described. It is not
image-load timing; the images were fully cached in both runs.

A plain `overflow-x-auto` div has one scroll container, gets its width from the
panel by construction, and needs no measurement, so both shapes go away. Verified
after the change: scroller width 474 = panel width 474, bar present on first
open, and the last sticker fully inside the panel at max scroll.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* test(stickers): guard the tray's single plain overflow container

Structural, not layout: the browser project loads no stylesheet, so widths
measured there are meaningless. Asserts the shape that broke — one
`overflow-x-auto` ancestor, nothing scrolling between it and the clipping
panel, and no Mantine ScrollArea anywhere in the tray.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* test(stickers): gate the tray's overflow shape in a suite CI actually runs

The browser project runs in no CI job and loads no stylesheet, so the sibling
browser test can neither gate nor measure. Same three structural assertions,
in the unit project (happy-dom + createRoot), which does run on a PR.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* chore: ignore vitest browser-mode failure artifacts

A failing `*.browser.test.tsx` writes screenshots to .vitest-attachments/, which
was untracked rather than ignored and got staged by a `git add -A`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* feat(stickers): wrap the placement tray into two scrollable rows

One long horizontal row is the wrong shape for a collection. Measured signed in
as an account owning 83 stickers, it ran 6452px wide inside a 1254px panel, so
almost everything owned was reachable only by dragging a scrollbar sideways.

The tiles now wrap and the tray scrolls vertically, capped at two rows. The cap
is derived from the tile height and gap rather than written as a pixel, so a
taller tile cannot silently leave the second row half-shown.

Also drops the tray's second note. "One a day, shared between stickers and remix
galleries" sat under a line that already says whether this placement is free, and
explained a limit nobody had asked about; it still appears on the remix-gallery
modal and in the spent-allowance reason, where the reader is asking that
question. And the price line no longer offers a choice that does not exist: the
free placement is taken automatically by the first draft that can use it, so it
now reads "Free · one use" or "<price> Buzz + one use", never both.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* feat(stickers): say what the drafts line is actually for

"Closing this panel leaves them on the image" answered a question nobody had
asked yet, in the line that is supposed to say what to do next. And "buy the ones
you want" describes a purchase; what the button does is pay to place one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* feat(stickers): name both things a placement costs

"Free · one use" and "50 Buzz + one use" both left "use" doing two jobs. A
placement costs a placement AND a sticker use, so the line now says so: "Free
placement + one sticker use" or "50 Buzz + one sticker use".

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 10:53:05 -06:00
Zachary Lowden 481582d969 flake: own the dev toolchain, guard the pins, one command to a running app (#4107)
* feat(flake): make the Nix flake own the dev toolchain, and add one command to start

The flake shipped nodejs_22 while package.json declares engines.node
">=24.0.0 <25", .nvmrc pins 24.19.0 and the Dockerfile builds production on
node:24.19.0-alpine3.24. A NixOS developer was running a major the repo does
not support, and nothing said so.

Toolchain:
- node and pnpm are now DERIVED from .nvmrc and package.json's packageManager
  rather than named twice. .nvmrc is treated as the authority because it is what
  every workflow's actions/setup-node reads and what the Dockerfile tracks.
- flake.lock moved 2026-04-23 -> 2026-08-18 (117 days). At that rev nodejs_24 is
  exactly 24.19.0, which is what made agreeing with .nvmrc possible at all.
- pnpm now comes from `pnpm_10`, not the unversioned `pkgs.pnpm`. At the new
  rev the unversioned attribute resolves to 11.21.0 -- a major bump that
  rewrites pnpm-lock.yaml -- so this bump would otherwise have shipped pnpm 11
  to every dev shell silently.
- postgresql_16 -> postgresql_17, matching the primary `db` container. The
  postgres/redis/clickhouse entries are CLIENTS for the compose-hosted servers;
  that is now stated in the file instead of left to be guessed.
- npm_config_manage_package_manager_versions=false. Measured: without it, pnpm
  downloads and re-execs the exact version from the packageManager field, so the
  flake's pnpm pin was being defeated at runtime (`pnpm --version` returns
  10.28.1 with the var unset, 10.34.5 with it set).

Guards (`nix flake check`, 4 checks):
- toolchain-pins: the flake's node must satisfy engines.node and equal .nvmrc,
  and its pnpm must share a major with packageManager. Deliberately does NOT
  re-check the .nvmrc/Dockerfile/engines triangle -- node-version-consistency.test.ts
  already owns that, and a predicate open-coded twice starts disagreeing.
- prisma-pin: re-derives the resolved @prisma/client AND its engine commit from
  pnpm-lock.yaml and compares them to the values flake.nix hardcodes. These were
  correct but unguarded: package.json declares `^6.3.0`, a caret range, so a
  routine lockfile refresh moves the client while the flake's engines stay put,
  and the failure surfaces at runtime in every dev shell.
- pin-guards-selftest: breaks each pin on purpose and requires the guard that
  owns it to fire while the others stay silent.
- dev-scripts: builds the shell entrypoints, which is what runs their shellcheck.
  (`nix flake check` builds checks.* but only EVALUATES packages.*, measured.)

Entrypoints:
- `nix run .#dev` - docker preflight, submodule, .env.development, compose up,
  wait for postgres, pnpm install, then `next dev`. Every step idempotent and
  non-destructive; migrations and seeding stay opt-in.
- `nix run .#dev-server` - runs the dev-server CLI on the flake's node. The
  daemon re-execs itself with process.execPath, so whichever node starts the CLI
  is the node it runs on until it is restarted.
- `nix run .#doctor` - the same pin checks against the working tree.

Compose project is pinned to `civitai` so every worktree shares the one local
stack instead of each spawning a duplicate that fails on the port binds.

* fix(flake): give `nix run` the same env as the dev shell, not just the shell

Found by running the bootstrap on a genuinely clean worktree rather than
reasoning about it. `mkShell`'s `env` applies to `nix develop` only, so both
values it carried were absent from `nix run .#dev`:

- `pnpm install`'s postinstall runs `prisma generate`. Without
  PRISMA_QUERY_ENGINE_LIBRARY et al, prisma tried to fetch an engine for
  platform `linux-nixos` and the bootstrap died on
  `404 ... /linux-nixos/libquery_engine.so.node.sha256`.
- pnpm re-execed itself as 10.28.1 from the packageManager field even though
  PATH pointed at the flake's 10.34.5, so the app reported a pnpm the flake had
  not pinned.

The env is now one attrset (`devEnv`) rendered two ways: `env` for the shell and
an `export` preamble for the apps, so they cannot drift. `nix run .#dev-server`
gets it too -- the daemon runs `pnpm install` / `db:generate` on its own when it
sees the lockfile move, which would have hit the identical 404.

* docs: describe the toolchain the repo actually has, not the one it used to

Every claim below was checked against the code before rewriting, and the
measurements are quoted where they are load-bearing.

README.md
- "Node.js (version 20 or later)" -> 24.19.0, with .nvmrc named as the authority.
- `make init` was DEAD, not merely awkward: it ran `npm i`, and package.json's
  `preinstall` runs `only-allow pnpm`, which exits 1 under an npm user agent
  (measured, with the pnpm-user-agent control exiting 0). Both bootstrap paths
  the README offered went through it.
- MinIO console is on :9001, not :9000 (:9000 is the S3 API). The instructions
  sent people to the wrong port to mint the keys the next step needs.
- `git submodule update --recursive` -> `--init`; without `--init` it is a no-op
  on a fresh clone, which is precisely when it is being run.
- Data Migrations step 1 pointed at `schema.prisma`, which is gitignored and
  regenerated from `schema.full.prisma` on every `db:generate`, so edits to it
  were silently discarded.
- Adds the Nix path (`nix run .#dev`) and a real non-Nix sequence.
- engines.node is ADVISORY, stated plainly: pnpm 10.34.5 under node 26.7.0
  against ">=24.0.0 <25" prints `WARN Unsupported engine` and exits 0. An
  earlier draft of this very README claimed it refuses. It does not, and that is
  the reason the drift survived so long.

Makefile
- `npm i` -> `pnpm install` (see above). `npm-install` kept as an alias.
- `gen-prisma` ran a bare `prisma generate`, which reads the gitignored slim
  schema that does not exist yet on a fresh clone; now `pnpm run db:generate`,
  which generates it first.
- `dev` ran bare `cross-env`/`next`, requiring the caller to put
  node_modules/.bin on PATH by hand; now via `pnpm exec`.
- `docker-compose` (EOL v1) -> `docker compose`.
- COMPOSE_PROJECT_NAME pinned to `civitai`. Reproduced first: `make start` in a
  worktree died with `Bind for :::15434 failed: port is already allocated`
  because compose named the project after the directory.

.envrc.example (new, tracked) + .gitignore
- `.env*` matched `.envrc` too, so nothing tracked in the repo mentioned the
  flake at all -- the only reference was a line in CLAUDE.md filed under
  worktree hygiene. Placeholders only; the real .envrc stays ignored.

.claude/skills/dev-server/SKILL.md
- The skill said nothing about node. The daemon is spawned with
  `process.execPath` (cli.mjs:66, console.mjs:87) and hands its env to every
  `next dev` it supervises, so the first shell to run a CLI verb decides the
  node for everything, indefinitely. Measured on this box: daemon on 26.7.0,
  with no pnpm on PATH at all. Documents `nix run .#dev-server` and how to check.
- `npm run dev:daemon` -> `pnpm run dev:daemon`, in a repo that bans npm.

src/__tests__/node-version-consistency.test.ts
- Comment-only. It said flake.nix "is on a different major" and could not be
  aligned because the pinned nixpkgs had no Node 24 this new. Both halves are
  now false, and a comment a maintainer might act on is worth correcting.

Also: docs/pnpm-migration.md's "Node.js 18.x or later"; the generated-header
line in scripts/generate-slim-schema.js telling readers to run `npm run
db:generate`; CLAUDE.md's local-dev section (no node version, no services) and
its stale "flake's 22.22.2" figure.

NOT changed, because it could not be exercised here: the devcontainer pins
typescript-node:1-22 (Node 22, outside engines.node). Flagged in README with the
tag to use -- there is no `1-24`, the template major moved on, so `3-24`.

* docs(flake): the four postgres containers are not all one version

prisma-pit and db are postgres 17; notification-db and logical-db are 15. The
comment justifying postgresql_17 read as though they were uniform, which would
have made the next person's version decision from the wrong premise.

* docs: keep the non-Nix path the default, demote the flake to optional

The flake is used by one maintainer. Everyone else uses Docker + nvm, and that
has to stay the path a contributor lands on. The previous revision inverted
that: README's Installation section led with "With Nix (recommended...)" and
titled the standard path "Without Nix" — framing the majority workflow as the
fallback. CLAUDE.md opened "From nothing to a running app, one command:" with
`nix run .#dev`, and the dev-server skill led its fix with "Start it through the
flake and this cannot happen".

None of that made Nix *required* — verified: `.github/` is untouched by this
branch, no workflow references Nix (the apparent hits are substrings of
`eslint-unix.json` and `--format unix`), and `nix flake check` is not wired to
any CI gate. It was purely an ordering-and-emphasis problem, which is the kind
that costs a new contributor twenty minutes before they find the section that
applies to them.

Changes, all editorial:

- README: `#### Standard setup` now precedes `#### Optional: Nix flake`, and the
  Nix section opens with a blockquote saying it is not the supported default,
  that nothing requires it, and why it exists at all (NixOS has no published
  `linux-nixos` Prisma engine, so a flake is the practical way to work there).
  The signals/buzz instructions lead with `docker compose up -d` and mention
  `nix run .#dev -- --full` parenthetically.
- CLAUDE.md: the bootstrap block is now the nvm/docker sequence, labelled as the
  default path, with the flake shown after it as NixOS-only and explicitly
  flagged as something not to assume a contributor has. The dev-server step no
  longer instructs going through `nix run .#dev-server`; it states the
  requirement (a shell whose node matches `.nvmrc`) and notes the flake does that
  for you on NixOS.
- dev-server SKILL.md: the fix is now stated setup-agnostically — start the
  daemon from a shell whose node matches `.nvmrc` with pnpm on PATH, which
  `nvm use` gives you — with the flake wrapper presented as the optional NixOS
  convenience, and an explicit note that nothing in the document depends on Nix.

No behaviour, tooling or gate changes: the Makefile, flake, guards and their
tests are untouched by this commit.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-19 19:48:54 -05:00
Justin Maier 93ee73cd84 test(perf): instrument the unit suite and track the isolation migration (#3957)
* test(perf): instrument the unit suite and track the isolation migration

The unit suite spends 81% of its worker time importing, and nothing in the repo
could say which modules or which files. This adds the measurement that answers it,
plus a dashboard so the isolation migration has a burn-down nobody has to maintain.

The finding that motivated the shape of it: traced at 1 worker, the module BODIES
of two of the heaviest test files total ~0.4s against a 25.4s import phase. The
cost is vite-node's per-module fetch, which under the `forks` pool is a
child-process IPC round trip per module per file. So cost is linear in module
COUNT, not module weight, and the static import graph is the right thing to rank by.

- graph.mjs      static first-party import graph + vi.mock inventory
- reporter.mjs   per-file collect/setup/test timings from any run
- bench.mjs      fixed 90-file stratified yardstick, so two measurements taken
                 hours apart by different people are comparable
- sweep.mjs      pool x isolation x worker-count matrix, run back to back
- trace-*        module-execution tracer; counts what actually ran, which the
                 static graph cannot know (a vi.mock factory stops the real
                 module and its subtree from executing)
- why.mjs        shortest import path between two modules
- dashboard.mjs  builds .test-perf/dashboard.html from whatever is on disk

Output goes to .test-perf/, gitignored. Nothing here runs in CI or changes how
any suite executes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* test(perf): add the per-worker union report, and record three dead ends

Under isolate:false a worker keeps one module registry, so its cost is the UNION
of what its files import rather than the sum. order.mjs reports that union, which
is the number that bounds what removing isolation can deliver: measured at ~36
seconds per worker to build, near-constant at 8 and at 24 workers, and a wall-clock
floor more workers cannot shrink.

Three things measured today that do NOT help, written down so the next person does
not pay for them again:

- Affinity file ordering. A greedy graph-similarity sequencer gave a mean per-worker
  union of 1139 modules at 31 workers against alphabetical's 1084 - slightly worse.
  Alphabetical already groups by directory and directory already correlates with the
  graph. The sequencer is deleted; the measurement is kept.
- NODE_COMPILE_CACHE. Cold 26.8s, warm 51.4s, warm again 33.9s on the yardstick. The
  cache filled (5.3MB) so it was active; vite-node does not evaluate through the
  loader it covers.
- vmThreads. Two clean 90-file runs, then the five sharp-executing files crashed or
  passed on identical input at 2 and 3 workers. It is a race, and CI's 4 vCPU
  resolves to the width measured at 1-in-3 SIGSEGV.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs(test-perf): three measurement rules this project had to learn twice

- The 90-file yardstick understates isolate:false and cannot judge it. That flag
  amortises the registry build across the files a worker runs, so its win scales
  with files-per-worker: 90 files at 16 workers is ~6 each and measures 1.65x,
  while 1065 files at 8 workers is ~133 each and measures 16x on the same phase.
- No --no-isolate number is quotable without a per-file collected count. Its damage
  is not only failing assertions: files silently collect ZERO tests, and how many is
  width-dependent (9 of 90 at forks/4, 14 of 90 at threads/4, 0 at threads/16, same
  input). A summary line cannot show this.
- The noise floor on a shared box is +/-30%: one configuration measured 53.3s and
  76.6s in a single session. Below ~20%, quote phase numbers or nothing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* test(perf): show the guard allowlist as the migration burn-down

The dashboard was deriving migration status from the static vi.mock inventory,
which is an estimate. The authoritative number is the length of the guard's
allowlist: it ratchets in both directions, so a new direct mock fails and a
migrated file left on the list fails too, and it therefore cannot drift from what
the suite will actually accept.

Reads it from the working tree when present, otherwise from the branch carrying
it, so a dashboard built on main still shows the real number rather than nothing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs(test-perf): name the baseline, retract the IPC mechanism, fix the yardstick's promise

Three defects found by an adversarial review of this PR, all in the class of
asserting something nobody re-checked.

- The README quoted one baseline while shipping an artifact with different
  numbers for the same tree - 4565.5s against 5476.3s of import, 36% apart. The
  tree did not change; the box did. All three measurements of `3863adcbb0` are
  now tabulated with the rule that a main-relative figure must name the run it
  was measured against, because that spread is the same size as several of the
  effects being measured against it. The derived import share is 81% or 84.3%
  depending which row you pick, and both are now stated.
- The per-module IPC mechanism was retracted in mail hours ago and left standing
  here. The pool sweep refutes it: `threads` beat `vmThreads` while paying a cold
  fetch, which shipping module source cannot explain. Now labelled inferred, with
  the competing reading and the note that V8 compile time was never instrumented.
- The yardstick claimed it made measurements "hours apart" comparable, three
  bullets above a +/-30% noise floor measured inside one session. It fixes what is
  measured, not when. Also records that a null from a 90-of-1065 sample is not
  evidence of no effect - two changes measured flat there were later shown real.

And the gitignore claim is now scoped to "by this change", since it is false on
main until this merges.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(test-perf): count the modules a worker loads, not the ones a bundler compiles

The static graph over-counted a page-gate test by ~75x, which put four of the
cheapest files in the suite at the top of the closure ranking. Their measured
worker time ranks 202-572 of 1065.

Four independent causes, each removing modules the naive walk counted:

- lazy `import()` is not followed, EXCEPT from a test file itself. A
  `dynamic(() => import())` in a page never runs; an `await import()` in a
  test body is what the test exists to do. Collapsing the two makes those
  files either the top of the ranking or 1 module each.
- a `vi.mock` factory without `importOriginal` truncates the subtree behind
  it. The mocked module itself is still counted -- registering the mock is
  what causes the transform -- and is counted even when nothing imports it.
- `import { type X } from` erases the statement. Only `import type {` was
  handled, and the inline form is the common shape here.
- a line filter cannot strip a multi-line `import type`: it leaves
  `} from '...'` behind, and IMPORT_RE's lazy `[\s\S]*?` glues that orphan
  onto the previous import, inventing an edge. Stripped as statements now.

Plus `event-engine-common` in SRC_DIRS -- a submodule imported by relative
path from src/server/services, so its modules and the civitai-db-queries
files reachable only through it were invisible.

Validated by diffing the model against a transform-hook trace as SETS, not
counts: 3 of 5 files exact with empty diffs both ways, 16 modules of
symmetric error across 691 traced (2.32%). Counts alone agreed often enough
to hide two of the four causes -- a count agreeing is not the rule agreeing.

`graphModules` is now the honest count and stays the default field, so
dashboard.mjs is corrected without a change of its own; `graphModulesRaw`
keeps the bundler view. closures.json gains `mode: 'real'` and a note
describing the truncation rules, so a consumer can refuse a naive one.

Also flags the two order.mjs union figures as pre-honest levels needing a
re-run; the ordering conclusion is a ratio and survives.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(test-perf): stop the type-statement stripper eating real code, and record maxWorkers honestly

Three review findings, all mine.

1. graph.mjs -- the type-statement stripper swallowed spans of real code.
   `TYPE_STATEMENT_RE` also started matching on a bodyless `export type X = ...`
   (no `from`), and the lazy `[\s\S]*?from '...'` then scanned FORWARD across the
   file to the next `from '...'`-shaped text -- inside comments and strings
   included -- deleting every statement in between before IMPORT_RE saw them.
   src/server/common/enums.ts lost a real `@civitai/notifications/constants`
   edge across a 7,881-character span.

   The gap is now tempered: between `type` and `from` a real type-import
   statement holds only a binding clause, so it may not cross `;`, `=`, or
   another `import`/`export` keyword. Measured across 5,294 files, spans of
   more than 12 lines fall from 73 to 28, and every one of the 28 remaining is
   a genuinely long multi-line type-import list. Re-validated against the
   transform-hook traces: symmetric error 2.32% -> 2.03% over 691 traced
   modules, so the fix is strictly in the right direction.

   Direction of the bug was UNDER-count, and `graphModules` is the ranking key
   for the dashboard, order.mjs and bench.mjs --make-subset. The earlier
   validation did not cover it: 5 files of 1,065, none of the affected ones.

2. reporter.mjs -- the one unguarded filesystem call. It sits inside an awaited
   vitest lifecycle hook, so a throw (read-only cwd, `.test-perf` existing as a
   file, ENOSPC, an AV EPERM) would red a run whose tests all passed, and the
   failure would be attributed to the code under test. A measurement tool must
   never be able to fail a suite; losing the recording is the correct trade.

3. reporter.mjs -- `maxWorkers` is not on vitest 4's `ctx.config`. Verified by
   execution: the emitted config was `{isolate, pool, argv}`. Every run ever
   recorded therefore stored null, and dashboard.mjs rendered all of them as
   "(default) workers" -- claiming a fact nobody measured. Recovered from argv
   (`--max-workers=8`, `--max-workers 8`, `--maxWorkers=8`), falling back to
   VITEST_MAX_WORKERS, and reported as 'unknown' rather than null when neither
   is present. The env var and the flag are recorded separately because they do
   not behave the same -- the flag reaches a queued run and the env var does
   not. The dashboard now shows historical nulls as 'unrecorded', not
   'default'.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-15 14:15:17 -06:00
Manuel Emilio Urena c9b492e8b5 feat(skills): add feature-walkthrough, for demoing a feature you cannot demo live (#3823)
* feat(skills): add feature-walkthrough, for demoing a feature you cannot demo live

Turning a finished feature into screenshots a reviewer can read is repeatable
work, and most of the cost is rediscovering the same friction: the
browser-automation server discards a chunk's return value, so an inspection
script cannot report what it saw; a sticky announcement sits above the page and
swallows clicks, failing 30s later with "subtree intercepts pointer events",
which reads like a bad selector; artifacts run under a CSP that blocks every
external host, so screenshots have to be inlined as data URIs under a 16 MB
ceiling; and captures have a dependency order, because a pending state has to be
shot before it is accepted.

`capture.mjs` solves the first two in code — it keeps the return value and
injects `dismissOverlays`, `shot`, `shotOf`, `shotModal`, `mainText`,
`modalText`. `build-report.mjs` inlines the images and fails the build on a
missing one or an oversized page, rather than letting a rejected publish or a
silently absent screenshot be the first sign of trouble.

The skill leans on dev-server, browser-automation and postgres-query rather than
restating them, and deliberately does not carry a sign-in-as-any-user recipe.

Its `.env` is gitignored with an `.env.example` to copy, matching postgres-query.
It also states the rule that makes the whole thing safe: seeding and restoring
write, so this runs against a dev database only, and screenshots leave with
whatever is on screen.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(skills): keep walkthrough screenshots out of a public checkout

`SHOTS_DIR` defaulted to a relative `shots`, so running a capture from the repo
root wrote screenshots of dev accounts — usernames, avatars, their content —
into a public, permanent checkout, one `git add .` away from being published.
The skill said to use a scratch directory while shipping a default that did the
opposite.

The default is now a temp directory outside the repo, and `/shots/` plus
`**/walkthrough-shots/` are gitignored as a backstop for anyone who points
SHOTS_DIR somewhere convenient instead.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 16:39:13 -04:00
ZacxDev ee5e88f440 gitignore 2026-08-06 16:49:41 -05:00
Zachary Lowden 902d222ed1 ci: run the nine packages/* test suites, which nothing has ever executed (#3642)
* ci: run the nine packages/* test suites, which nothing has ever executed

The root `unit` Vitest project's `include` is root-relative (`src/**`,
`scripts/**`), and the `Unit tests` job runs `vitest run --project unit`. So
no CI job in this repo has ever invoked a workspace package's suite: 616 tests
across nine `packages/*` packages ran only for whoever remembered
`pnpm --filter <pkg> test` by hand.

That is not theoretical. The schema-drift detector (#3591) shipped 81 tests
into this gap, and two of them had been RED on `main` since #3592 landed —
with nothing anywhere to say so.

Root `vitest.config.mts` now globs each package's OWN config file, so every
package keeps the config it was written against. The new `Package unit tests`
job is BLOCKING: the reasoning that made `Unit tests` report-only does not
transfer, because this is 616 tests with no browser, no database and no Next
module graph, and the whole run is ~15s.

Three pre-existing failures the job surfaced, all fixed here rather than
skipped:

  * civitai-db-queries/src/infra/enum-array-parsers.explain.test.ts could
    never pass without a database. `describe.skipIf(!url)` skips the tests but
    Vitest still EXECUTES the describe callback during collection, so the
    eager `new Pool({ connectionString: noVerify(undefined) })` threw
    `TypeError: Invalid URL` and failed the whole FILE to import — which
    reports as "1 failed test file" with no test count, not as a failing test.
    The Pool moves into `beforeAll`, which genuinely does not run when skipped.

  * production-snapshot.test.ts pinned 235 nullability findings on seven
    `*Rank` tables. #3592 marked those columns optional to match the database
    and the findings correctly went away. The assertion now guards the
    REMEDIATION (Rank family at zero) with a positive control on that zero —
    the 11 findings that remain.

  * parse-prisma-schema.test.ts pinned Prisma's defaulted `onDelete` at one
    named site, `TagsOnImageNew.image`, and #3589 gave that relation an
    explicit `onDelete`. Re-anchored to the POPULATION of bare relations, which
    an ordinary schema edit cannot retire.

A green vitest run is a claim, not evidence: `--project` matching nothing exits
0, and so does a config whose globs stopped resolving. scripts/ci/assert-
package-suites-ran.mjs therefore asserts a ledger — every package with a vitest
config and a test file on disk must appear in the results — so the job fails
when the executed set SHRINKS, which the totals cannot see. Validated against
three known-bad reports (empty run, one package dropped, missing report file)
before being trusted.

Test count, measured in CI: 811 files / 12,031 tests before, all from `src/`;
61 files / 616 tests added by the new job, of which 81 are the drift detector's.

* fix(ci): correct the "blocking" claim, and close three ledger blind spots

Audit follow-ups on the packages/* CI job.

The job was described as BLOCKING. It is not, and the distinction is
load-bearing for whoever next decides whether to flip `unit`: `main` has branch
protection but no required_status_checks at all, so a red check here does not
prevent a merge. What it actually buys is rendering RED instead of
red-but-ignored. Corrected in the workflow comment, the README and the PR body,
along with what would make it a real interlock.

The Rank positive control was green for the wrong reason. Asserting the Rank
family is at zero nullability findings is satisfied just as well by a differ
that never visits a Rank table — measured: making compare.ts `continue` on every
model whose table ends in `Rank` left that test GREEN and reddened only two
neighbouring ones. The `nullability.length >= 11` control could not see it,
because it proves the KIND is still emitted, not that those seven tables are in
scope. Now pinned via findings of a different kind that the same seven models
must still produce; re-running the identical mutation kills the test itself.

Three ledger blind spots, each demonstrated with a probe before and after:

  - It only looked in `src/`. A package with its tests in a top-level
    `__tests__/` was invisible to the EXPECTED set, so it could never be
    noticed going missing. Now scans the whole package directory.
  - It only matched `.test.ts`/`.spec.tsx`. `.mts`, `.cts` and `.js` tests were
    invisible the same way.
  - It counted SKIPPED tests as having run, summing assertionResults.length. A
    package that self-skipped in its entirety still satisfied the ledger — and
    self-skipping on a missing DATABASE_URL is exactly the pattern these suites
    use, so that failure mode is live, not hypothetical. It now requires a
    non-zero EXECUTED count and reports skips beside it, so a package quietly
    turning itself off is visible rather than absent from the arithmetic.

Both directions verified by exit code, not by reading output: real report 0;
tests-outside-src probe 1; .mts probe 1; wholly-skipped package 1; empty run 1;
one package dropped 1; missing report 1.

Known remaining gap, deliberately not widened into this change: `apps/*` has
four more vitest configs and ~43 test files that no CI job runs. Same one-line
fix in the same projects array, plus teaching the ledger about `apps/` — it
hardcodes `packages/` today. Follow-up PR.

Also: the CI job's JSON report is gitignored (the documented command left an
untracked artifact at the repo root), and the workflow comment's "four EXPLAIN
tests" is corrected to 3 + 1 across two files.
2026-08-05 19:55:32 -05:00
Manuel Emilio Urena 0ddd82b64a fix(challenge): escalate NSFW-text challenges — cancel green, raise R on yellow (#3211)
* docs(challenge): spec for NSFW text-scan escalation + green→yellow flip

Design for escalating a user challenge to R and flipping green→yellow when its
text scans as sexual content, plus currency-scoping the initial-prize
externalTransactionId to prevent a silent-drop unfunded-pool bug on re-charge.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs(challenge): implementation plan for NSFW scan escalation + flip

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(challenge): currency-scope initial-prize externalTransactionId

Refunding a charge leaves its externalTransactionId occupied in the ledger, so a
later yellow re-charge on the shared green id would be silently dropped by the
createBuzzTransaction dedup, leaving an unfunded pool. Suffix the id with the
currency; the -creator prefix matchers still match both variants.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(challenge): scan text for suggestive+explicit, not nsfw only

The nsfw label (threshold 0.75) misses crude sexual themes; suggestive/explicit
(threshold 0.5) catch them with a large margin. Centralize the label set so the
adapter submit and scanUserChallenge stay in sync.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(challenge): add computeNsfwEscalation pure decision

Given a scanned challenge, decides the raised allowed/display nsfw levels and
whether a green user challenge flips to yellow + refunds its green initial prize.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(challenge): applyChallengeNsfwEscalation IO helper

Applies a scan verdict: marks Scanned, raises to R, flips green->yellow, refunds
the green initial prize (refund-before-update for crash safety), updates the
collection browsing level, and notifies the creator. Idempotent on retry.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(challenge): delegate scan applyResult to escalation helper

The adapter now keeps only the Blocked path; clean/NSFW verdicts route through
applyChallengeNsfwEscalation, treating any triggered label as NSFW.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(challenge): broaden flip refund prefix + harden collection update

- Flip refund uses the -creator prefix so it also matches pre-deploy charge
  ids (-creator, no currency suffix); the narrow -creator-green prefix missed
  them and stranded the creator's escrow while zeroing the pool.
- collection.updateMany instead of update so a deleted collection no-ops
  rather than poison-pilling webhook retries with P2025.
- Refresh the adapter's stale top-of-file comment for the delegated flip.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs(challenge): pivot design from green→yellow flip to cancel/void

Cancelling a green NSFW challenge (via the existing race-safe voidChallenge)
instead of flipping it to yellow reuses battle-tested code and eliminates the
currency-migration edge cases. Yellow challenges still raise to R.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(challenge): cancel green NSFW challenges instead of flipping to yellow

Green user challenges whose text scans NSFW are now voided (Cancelled +
collection closed + initial prize refunded via the race-safe voidChallenge)
and the creator is notified to recreate on civitai.red. Yellow challenges still
raise to R and stay live. Replaces the buzzType-flip + green-prize-refund path.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs+test(challenge): refresh adapter comment for cancel; add already-R escalation case

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs(challenge): correct cancel-path ordering to void-before-Scanned

Match the spec to the implemented (crash-safe) order: void first so a crash
leaves the challenge Cancelled/hidden, never Scanned-and-visible.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(challenge): guard blocked-path against deleted challenge + align stale docs

- adapter blocked path: early-return when the challenge was deleted between
  scan submit and the webhook, instead of a bare update that throws P2025 and
  fails the moderation callback (restores pre-refactor behavior).
- spec title + plan header: flag the flip→cancel pivot so the docs don't read
  as though the code is wrong.

Addresses Copilot PR review feedback.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(challenge): scan nsfw label only (suggestive/explicit not yet reliable)

Only the nsfw XGuard label is currently trustworthy, so scan for it alone.
Trade-off: nsfw's 0.75 threshold misses borderline sexual text (~0.68) — only
clearly-NSFW text escalates until suggestive/explicit are reliable. Escalation
logic is label-agnostic; this is a one-const change.

* docs(challenge): document text-scan NSFW handling in feature doc; drop session spec/plan

Add a Text moderation bullet to the challenge-platform Safety/gating section
(green NSFW → void+refund, yellow → raise to R, nsfw-label-only interim). Remove
the superpowers/ spec + plan — implementation scratch that doesn't belong in main.

* docs: remove superpowers/specs scratch (article-rating #2779, public-challenges #2965)

Implementation-detail specs that don't belong in main; removed per maintainer
request alongside this PR's own doc cleanup.

* chore: gitignore docs/superpowers/ (skill scratch, not shipped to main)

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 15:13:04 -04:00
Manuel Emilio Urena f07c11fc0d feat(challenges): open platform to public user-created challenges (dark) (#2965)
Opens challenge creation to regular users (was mod/system only). Behind userChallenges Flipt kill-switch (availability mod), platform behind challenge-platform-enabled flag.

Schema

- New Challenge cols: entryFee, maxParticipants, judgingCategories, ingestion (scan status), buzzType, operationSpent
- createdById → nullable, FK ON DELETE SET NULL (user challenge survives owner deletion)
- New: ChallengeIngestionStatus enum, ChallengeReport join, ReportEntity.Challenge, ChallengeCategory table (DB-backed judging-category library)
- Migrations manual-apply per env

Core systems

Eligibility — creator score ≥5000, good standing, tier active-challenge caps (free1/bronze2/silver3/gold5), daily create limit 5, can't-enter-own guard.

Funding (buzz) — creator escrows initial prize; entry fee split into non-refundable house cut (25) + refundable pool contribution; per-image idempotent charge; ledger-verified partial charges self-heal on retry. Winners paid from final pool in challenge's stored buzzType.

Judging — weighted category system (theme mandatory + gated, weights sum=100, max 4). Curated DB category library (no free-text → injection-safe). Rich rubrics live DB-only (ChallengeCategory.rubric), NOT in repo (public-repo leak fix). {{SCORING_RUBRICS}} sentinel injects at review time. Entry-fee challenges judge every entry; dailies keep sampling.

Moderation — async text scan (title/theme/invitation/desc) via XGuard; POI + cover-scan gates; visibility gate hides non-Scanned from non-owners; challenge Report → mod queue.

NSFW/domain isolation — green (.com) vs yellow (.red) buzz currency, domain-locked. Green forces SFW. Feed/detail/winners gate on REAL cover nsfwLevel (not declared). Soft-gate via <Gated> MatureContentRedirect.

Prod-readiness (Phase from PR #3107)

Job scalability (bounded limitConcurrency, batch sizes, review rotation by reviewedAt, no silent LIMIT 50 drop), concurrency-safe activation/void/delete (conditional writes, claim-before-refund), LLM spend tracking (operationSpent vs house cut → Axiom metric), off-resource fee-leak fix, <2-entrant winner-pick skip.

UI

Unified ChallengeUpsertForm (variant moderator|user), /challenges/create + eligibility requirements card, feed split (Daily row + Community masonry), owner Edit/Delete context menu, My Challenges view, Create-a-Challenge in global CreateMenu w/ NEW badge.

Notable review-driven fixes

Buzz-mint vectors closed (unfunded entry, edit-path escrow bypass), negative-input floors, winner mapping by creatorId (name-spoof hardening), refund-by-internal-txn-id, judge-prompt injection preamble.
2026-07-15 16:27:28 -04:00
Justin Maier bbf8c2b1e5 chore: gitignore nested SvelteKit .svelte-kit output (apps/*)
Creator-studio branch work leaves generated .svelte-kit/ behind when
switching back to main, showing as untracked noise for everyone.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 14:58:24 -06:00
Zachary Lowden 05c0bff7e0 chore: ignore nested coverage dirs + commit notifications coverage audit (#2935)
The root .gitignore had `/coverage` (root-anchored), so per-package coverage
output like apps/notifications/coverage/ (left by a vitest --coverage run) showed
as untracked and dirtied the tree. Broaden to `coverage/` so any workspace
package's coverage output is ignored (root coverage still ignored too).

Also commit the notifications test-coverage audit that drove the recent worker /
create.ts / markNotificationsRead behavioral-test PRs (#2925/#2926/#2927), into
claudedocs/ (already a tracked docs dir).

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-05 07:15:26 -05:00
briant 74e690ac8b fix(cls): reduce model page layout shift
- Carousel: reserve each slide's aspect-ratio box up front (EmblaSlide render-prop) so the container height is correct from first paint instead of snapping off Embla's initialHeight; SSR-prefetch the carousel images so it has real dimensions.
- Creator card: SSR-prefetch user.getCreator (hydrate real data, no zeroed-fallback growth) and render the default background as a base layer so it doesn't flash blank while a cosmetic loads.
- Donation goals: render nothing while loading/empty instead of a skeleton that collapses (the common case).
- Action buttons: explicit flex/flex-1 children instead of Mantine `grow` (no fill-then-shrink).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 13:31:46 -06:00
briant 3d5934b9b6 Merge main into monorepo-bootstrap (231 commits)
Reconcile main with the package-extraction branch. 8 conflicts resolved by
keeping the @civitai/* package shims and porting main's NEW logic into the
packages (not the app shims):

- prisma enums/models: re-export shims; regenerated from merged schema (Model3D*)
- package.json/pnpm-lock: union scripts; @civitai/client beta.71->73, +three
- @civitai/axiom: logToAxiom stderr-before-guards reordering (test relocated here)
- @civitai/telemetry: redisSelfHealReconnect + redisMetricWriteFailSoft counters
- clickhouse tracker.ts: Tracker.view() context fields + new blockRender()
- @civitai/redis: ported main's cluster self-heal + packed-compression subsystem
  (cluster-selfheal/inflight/deadline-hits/packed-compression moved into the
  package, deadline.ts + 8 REDIS_CLUSTER_SELFHEAL_* env vars wired, metric bridge)
- search services: dropped next-auth import -> ~/types/session SessionUser

Verified: typecheck 0 errors (app+packages); redis(53)/axiom(3)/app redis+logging(78)
tests pass; two independent review agents confirmed no lost main functionality and a
faithful (9/9 invariants) redis-resilience port.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 15:23:28 -06:00
Briant Diehl b22c0f1f96 chore: gitignore hub e2e playwright artifacts
apps/auth/e2e generates a playwright-report/, test-results/, and minted storage
states under .auth/ — generated/secret-ish, not source. Ignore them.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 17:18:07 -06:00
Luis Rojas f4d5e501ce Merge branch 'main' into hackaton/3d-model-support 2026-06-15 17:27:59 -04:00
Briant Diehl 552a53e27c Merge origin/main into monorepo-bootstrap
Brings in main's App Blocks feature (+ @civitai/app-sdk, @civitai/blocks-react).
Conflicts resolved:
- package.json: keep @civitai/auth (workspace) + main's @civitai/app-sdk + @civitai/blocks-react
- .github/workflows/pr-check.yml: accept main's deletion
- src/shared/utils/prisma/models.ts: keep our @civitai/db-schema re-export shim
- db-helpers.ts / redis/client.ts / prom/client.ts: keep our package-extracted shims (ours)
- oauth/authorize.ts: keep both imports (OIDC nonce + app-block client check)
- pnpm-lock.yaml: reconciled via pnpm install

Ported main's App Blocks infra additions INTO our extracted packages so the incoming
code compiles against the package architecture:
- @civitai/redis: REDIS_KEYS.BLOCKS + REDIS_SYS_KEYS.BLOCKS keys; withRedisCommandTimeout (app shim)
- @civitai/db: 'apps' pool instance + APPS_DATABASE_URL/appsUrl
- @civitai/telemetry: App Blocks metrics (blockBuzzAttribution, appStorageOps/QuotaExceeded/Latency)

Typecheck: 0 errors.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 12:59:41 -06:00
Zachary Lowden e29a606955 test(component): Vitest browser-mode component-testing scaffold (runs in Tekton, removes GH Actions pr-check) (#2547)
* test(component): add Vitest browser-mode component-testing scaffold + SeedInput

Adds a second Vitest project (`component`, browser mode / real Chromium via
Playwright) alongside the unchanged 857-test `unit` suite. Includes a
renderWithProviders scaffold (Mantine + QueryClient + next/router mock), a
process.env shim for browser mode, a report-only GH Actions `component-tests`
job, and the first test on the high-churn, e2e-impossible SeedInput generation
leaf (6 cases, mutation-proven).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(component): address audit findings (typecheck test/, seed-test teeth, optimizeDeps)

- tsconfig: add `test` to include so the load-bearing browser-process-shim +
  component-setup are actually typechecked (were only checked transitively/not
  at all). (audit H1)
- SeedInput test: stub Math.random for an exact-value assertion bounded by
  MAX_RANDOM_SEED (was a loose MAX_SEED range that survived a constant-seed
  mutation). (audit M1)
- vitest component project: optimizeDeps.include next/router to stop the
  "Vite unexpectedly reloaded a test" flake warning. (audit L4)
- drop the stale "857 tests" count from the config comment. (audit M2)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* ci: remove GitHub Actions pr-check.yml — consolidate PR checks onto Tekton

typecheck + unit-tests already run in the Tekton PR-preview pipeline (author-
gated on MEMBER/OWNER/COLLABORATOR), and component-tests now run there too
(talos-infra: report-only npm-component-tests task). Removing this workflow
stops paying for GitHub Actions runner time. Tradeoff: external-contributor PRs
(not author-authorized) no longer get automated checks — accepted per the
"pure Tekton" decision.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 14:55:14 -05:00
Zachary Lowden cca249e517 ci(lighthouse): report-only Lighthouse CI on PR previews (Tekton) (#2516)
* ci(lighthouse): report-only Lighthouse CI on PR previews (Tekton)

Adds the civitai-side config for a new report-only `lighthouse` task in the
datapacket-talos pr-preview pipeline. On each preview build it runs Lighthouse
(5 runs, desktop, median-aggregated) against the deployed preview at
pr-<N>.civitaic.com and posts a report-only PR comment with perf/a11y scores +
LCP/CLS/TBT/INP and a public report link.

- lighthouserc.json: 3 routes (/ /models /generate), numberOfRuns 5, desktop
  preset, --no-sandbox, temporary-public-storage upload. All assertions are
  `warn` (report-only) — promotion path documented (deterministic CLS/byte
  budgets to `error` first, noisy timing metrics last, after a soak).
- tests/lighthouse-mint-cookie.cjs: mints the gate-passing ci-smoke-gold
  NextAuth session cookie (same next-auth/jwt encode + NEXTAUTH_SECRET as
  tests/preview-auth.setup.ts) and injects it into collect.settings.extraHeaders
  -> lighthouserc.runtime.json, so headless Chrome clears the preview-auth
  middleware /login gate instead of measuring the login page.

Report-only first; structured to flip to gating later.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* ci: retrigger preview for lighthouse upload fix

* ci: retrigger preview for lighthouse manifest fix

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 17:09:46 -05:00
Briant Diehl ee89847977 Merge remote-tracking branch 'origin/main' into monorepo-bootstrap
# Conflicts:
#	.gitignore
#	src/server/prom/client.ts
2026-06-11 10:42:50 -06:00
Luis Rojas 513e64d348 Merge branch 'main' into hackaton/3d-model-support
# Conflicts:
#	.gitignore
#	package.json
#	src/components/HomeContentToggle/HomeContentToggle.tsx
2026-06-10 14:43:19 -04:00
Briant Diehl f63154e2f9 chore(monorepo): Turborepo + package dependency boundary enforcement
Add Turborepo for task caching/orchestration, and lock in the package
boundary discipline that keeps its cache invalidation trustworthy.

Turborepo:
- turbo.json with build/typecheck/lint/test/dev tasks
- "cacheDir": ".turbo/cache" pins each git worktree's cache to itself (an
  explicit relative cacheDir resolves to the worktree root, not the shared
  git common-dir) -- no per-worktree TURBO_CACHE_DIR setup needed
- .turbo gitignored

Package deps + lint:
- declare every @civitai/* package's real external dependencies; redis,
  clickhouse, axiom, and telemetry were relying on root hoisting -- a
  phantom-dep trap any second app would hit (as the moderator did with
  @civitai/db)
- packages/.eslintrc.cjs (root:true) enforces import/no-extraneous-dependencies
  (every import must be a declared dep, so Turborepo's graph-based cache
  invalidation stays correct) and bans ~/... app imports; run via
  `pnpm lint:packages`
- @civitai/db-schema keeps a documented inline exception for @prisma/client
  (declaring it breaks `prisma generate`; proper fix is a custom output)

CI:
- pr-check.yml runs lint:packages on every PR/push to main

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 17:08:28 -06:00
Briant Diehl c790726d44 chore: gitignore .turbo cache
Turborepo writes its local task cache to .turbo/; ignore it so the cache
never lands in a commit.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 16:00:16 -06:00
Briant Diehl 2e4e017280 feat(apps): scaffold moderator app — second-app factory consumption
A Next.js 16 app in apps/moderator that consumes @civitai/db as a workspace dep via createPrismaClients() — proving a fresh app can use the base-package factories. Welcome page is static (db wiring in lib/db.ts is dormant/ready).

Notes for future apps: each app needs its own empty instrumentation.ts + proxy.ts to shadow the main app's (Next 16 infers the repo root as workspace root); declared @civitai/db's real deps (pg, prom-client, zod) so they resolve outside root hoisting.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 15:39:20 -06:00
Briant Diehl d572773c31 Merge branch 'main' into monorepo-bootstrap
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 13:50:22 -06:00
Briant Diehl 6c421733a8 chore(next16): gitignore auto-generated next-env.d.ts
Next 16 rewrites next-env.d.ts's routes import between .next/dev/types (next dev) and .next/types (next build) depending on the last command run, so tracking it produces constant churn and merge conflicts. The file it imports lives under the already-ignored .next/, and Next regenerates next-env.d.ts on every dev/build, so untracking it is safe.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 16:39:33 -06:00
Briant Diehl 3530a85f30 Migrate to Next.js 16 + Turbopack; fix Turbopack-dev runtime regressions
Build/deps/config:
- next, eslint-config-next, @next/bundle-analyzer → ^16; Turbopack is the
  default build bundler. Added turbopack:{} config, dropped removed config keys.
- Removed all 151 typed-scss-modules *.module.scss.d.ts in favor of ambient
  declarations in src/types/css-modules.d.ts (Turbopack panics on the .d.ts).
- CSS modules: rewrote bare :global to function form for Turbopack.
- trpc/[trpc] handler typing; bundler-agnostic applySourceMaps.

Runtime fixes (Turbopack dev/prod):
- CAConsentManager: static-import instead of next/dynamic. A dynamic component
  wrapping the whole app async-loads its chunk under Turbopack dev, causing a
  whole-tree hydration mismatch that re-mounts the app and orphans the server
  DOM (the "double layout"; US:CA-gated). General rule: wrapper dynamics remount,
  leaf dynamics are fine.
- ReactQueryDevtools: load client-only (ssr:false) — not SSR-safe under Turbopack.
- SharedWorkers (signals + civitai-link): Turbopack doesn't compile .ts
  SharedWorker entries (vercel/next.js#74842), breaking dev AND the prod build.
  Pre-bundle each with esbuild to public/workers/*.js (scripts/build-workers.mjs,
  wired into predev/prebuild) and instantiate via static path.

See docs/next-16-migration.md for full details and remaining open items.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 16:46:11 -06:00
Briant Diehl 5cc887c97c refactor(packages): factory + per-package env schema for base packages
Convert the civitai-* base packages from eager singletons into app-agnostic factories with their own zod env schemas, and wire them into the main app via thin shims. Packages now import only external deps (+ @civitai/db-schema); the app injects behavior (loggers, Flipt resolver, slow-query sink) and owns HMR globals + the Next build guard.

- Phase 0: pnpm-workspace.yaml, per-package package.json + index barrels, @civitai/* tsconfig paths, transpilePackages
- axiom/redis/db/clickhouse: createX() factories reading package-owned env.ts (z.prettifyError); Partial<Config> overrides
- db: createPrismaClients + config-driven getClient pool factory; kv-helpers.ts breaks the client<->db-helpers cycle; limitConcurrency vendored; pg singletons -> app shims
- clickhouse: base client stays; Tracker (auth/session/schema-coupled) extracted to src/server/clickhouse/tracker.ts
- telemetry: prom helpers stay; DB pool-gauge block -> src/server/prom/client.ts
- prisma generate paths updated for the db-schema package; generated slim schema gitignored

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 13:39:06 -06:00
Luis Rojas 6404bc8b4a chore: gitignore Claude Code session lock
.claude/scheduled_tasks.lock is per-process state (PID + proc-start
tick + acquisition timestamp) written every time Claude Code starts a
session. Tracking it meant every contributor saw it as a perpetually
modified file. Removing from index + adding to .gitignore so it stays
local-only.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-03 12:03:03 -04:00
Zach Lowden 9192134523 update gitignore 2026-05-25 14:31:44 -05:00
Justin Maier bb7e9c7560 Model UI Overhaul - File Management & Download Experience (#1964)
* feat: [US-001] - Add quantType and componentType type definitions

- Add ModelFileQuantType type: 'Q8_0' | 'Q6_K' | 'Q5_K_M' | 'Q4_K_M' | 'Q4_K_S' | 'Q3_K_M' | 'Q2_K'
- Add ModelFileComponentType type: 'VAE' | 'TextEncoder' | 'UNet' | 'CLIPVision' | 'ControlNet' | 'Config' | 'Other'
- Add quantType and componentType to BasicFileMetadata interface
- Add quantType to UserFilePreferences interface
- Update preferenceWeight to use Partial<Record<...>> for type compatibility

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

* Restore model UI overhaul planning documents

Re-adds planning files from feature/model-ui-overhaul-plan branch:
- Main plan document with implementation details
- Proposal document with options analysis
- HTML mockups for sidebar and file upload UI

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

* feat: US-002 - Add quantTypes and componentTypes constants

Added modelFileQuantTypes and modelFileComponentTypes constant arrays
to src/server/common/constants.ts for use in UI selectors.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>

* feat: US-003 - Add quantType to file preference scoring

- Added quantType: 0.5 to preferenceWeight object for scoring
- Updated defaultFilePreferences to include quantType: 'Q4_K_M' as default
- FileMetaKey type already includes quantType from BasicFileMetadata
- Files with matching quantType now get 0.5 added to their score

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>

* feat: US-004 - Add quant type preference to user settings UI

- Added 'Preferred Quant Type' Select to SettingsCard.tsx
- Shows conditionally when format preference is GGUF
- Uses constants.modelFileQuantTypes for options
- Wired to user.filePreferences.quantType
- Added tooltip explaining quant types (Q8 = best quality, Q4 = balanced, Q2 = smallest)
- Default value: Q4_K_M (balanced quality/size)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>

* feat: US-005 - Update file service for quantType selection

Updated getFileForModelVersion to accept and handle quantType parameter.
Files with matching quantType will now be scored appropriately during file selection.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>

* feat: US-006 - Update download API for quantType parameter

- Added quantType to validation schema with Zod enum validation
- quantType is automatically passed through to file selection via spread operator
- Validated against constants.modelFileQuantTypes
- Misalignment check automatically handles quantType

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>

* feat: US-007 - Add quant type selector to file upload form

- Added quantType field to FileFromContextProps and SchemaError types
- Updated FilesProvider to initialize and handle quantType from metadata
- Added quantType to metadata upload payload in handleUpload
- Added quantType to modelFileMetadataSchema in server schema
- Added validation refine to require quantType for GGUF files
- Added conditional quantType Select in FileEditForm for .gguf files
- Added tooltip explaining quant types (Q8 = best quality, Q4/Q2 = smaller)
- Updated handleSave and handleReset to include quantType
- quantType is marked as required (withAsterisk) for GGUF files

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>

* feat: US-008 - Add component type selector to file upload form

Added componentType field to file upload form for non-Model file types.
Component type selector appears for VAE, Text Encoder, Config, and Archive files.
Auto-suggests componentType based on file type (VAE -> VAE, Text Encoder -> TextEncoder).

Changes:
- Added componentType to FileFromContextProps and SchemaError types
- Added componentType to modelFileMetadataSchema
- Added componentType Select to FileEditForm with conditional display
- Updated handleSave, handleReset, and handleUpload to include componentType
- Added auto-suggestion logic when file type changes

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>

* feat: US-009 - Restructure file upload UI into sections

- Restructured Files.tsx into three sections: Model Files, Required Components, Optional Files
- Added section headers with appropriate icons (IconFile3d, IconPuzzle, IconFileSettings)
- Model Files section: files with type 'Model' or 'Pruned Model'
- Required Components section: files with type 'VAE', 'Text Encoder' - yellow warning styling
- Optional Files section: files with type 'Config', 'Archive', 'Workflow', etc.
- Files auto-categorize based on type into appropriate section
- Yellow Card styling for Required Components section to indicate importance

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>

* feat: US-010 - Create Link Component Modal

Implemented LinkComponentModal for linking to existing models on Civitai as
required components. Users can now:
- Select component type (VAE, TextEncoder, UNet, CLIPVision, ControlNet)
- Search for models using QuickSearchDropdown
- Select version from model
- Select file from version
- Link component is saved and displayed in Files.tsx

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

* feat: [US-011] - Update FilesProvider validation for new fields

- Add component-only model validation: models without Model type files
  must have at least 2 required components (VAE, Text Encoder, UNet, etc.)
- Update conflict checking to include quantType in duplicate detection,
  preventing duplicate [size, type, fp, format, quantType] combinations

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

* feat: [US-012] - Create file variant grouping utility

Add groupFilesByVariant() utility function that groups model files by:
- Format (SafeTensor, GGUF, other) for model files
- Component type (VAE, TextEncoder, etc.) for component files

Within each group, files are sorted by quality (best first):
- SafeTensor: fp32 > fp16 > bf16 > fp8 > nf4, full > pruned
- GGUF: Q8_0 > Q6_K > Q5_K_M > Q4_K_M > Q4_K_S > Q3_K_M > Q2_K

Also exports GroupedFileVariants type for consuming components.

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

* feat: [US-013] - Update download button with variant dropdown

- Created DownloadVariantDropdown component for grouped file variant selection
- Groups files by format (SafeTensor, GGUF, Other)
- Shows 'Best match' badge on user's preferred file based on preferences
- Dropdown shows file size, precision/quant type for each variant
- Default selected file is user's preference match
- Integrated into ModelVersionDetails sidebar when multiple model variants exist

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

* feat: [US-014] - Add Required Components accordion section to sidebar

- Create RequiredComponentsSection.tsx with yellow warning styling
- Group component variants (e.g., Text Encoder fp16/fp8) with expandable lists
- Single-variant components show without dropdown
- Multi-variant components show expandable list with best match auto-selected
- Add Download button for each component
- Add "Download All Components" button that downloads preferred variants
- Update detailAccordions default to include required-components

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

* feat: [US-015] Handle component-only models in sidebar

- Detect component-only models (no model files, only components)
- Hide main download button for component-only models
- Show informational message: 'This is a modular model - download components below'
- Make 'Download All Components' the primary action (filled button, larger size)
- Keep Generate button visible for component-only models

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

* feat: [US-016] - Update FileInfo display for new metadata

- Show quantType for GGUF files in file info popover
- Show componentType for component files (VAE, TextEncoder, etc.)
- Added friendly display names for component types
- Handle missing metadata gracefully (only show if set)

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

* fix: Fix prettier formatting in FileInfo.tsx

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

* chore: Fix prettier formatting in feature files

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

* chore: Remove unused imports from RequiredComponentsSection

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

* feat: [BUG-001] - Persist linkedComponents to FilesProvider context

Move linkedComponents state from local useState in Files.tsx to
FilesProvider context to ensure state persists across component
remounts and is available for form submission.

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

* feat: [BUG-002] - Add componentType to duplicate file checking

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

* feat: [BUG-003] - Fix race condition in LinkComponentModal auto-selection

- Move auto-selection logic from render to useEffect hook
- Add versionsLoading check to prevent accessing files before data loads
- Add hasAutoSelectedFileRef to prevent multiple auto-selections
- Reset ref in handleBack to allow re-selection when navigating back

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

* feat: [BUG-005] - Agent review validation with null safety fix

Ran agent-review to validate BUG-001 through BUG-004.
Fixed issue identified in review: added missing toLowerCase()
for .zip extension check in FileInfo.tsx for consistency with
.gguf check.

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

* feat: [PAT-003] - Verify form validation patterns

Audit of Zod validation patterns in FilesProvider.tsx - no code changes needed.
Pattern compliance verified: Zod schema extension, refine usage, showErrorNotification.

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

* feat: [PAT-006] - Verify download button patterns

Audit completed for DownloadVariantDropdown.tsx patterns.
All patterns match established codebase conventions:
- createModelFileDownloadUrl usage matches other components
- formatKBytes usage consistent across codebase
- DownloadButton polymorphic pattern properly implemented

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

* feat: [PAT-008] - Run agent-review on pattern compliance

External agent review completed for all new/modified components:
- LinkComponentModal.tsx: PASS - modal, search, select patterns correct
- FilesProvider.tsx: MOSTLY PASS - found silent error catch issue
- Files.tsx: PASS - well-structured
- RequiredComponentsSection.tsx: PASS - with code duplication note
- DownloadVariantDropdown.tsx: PASS - with code duplication note
- SettingsCard.tsx: PASS - optimistic updates correct

Key findings documented:
- getFileLabel/getFileDescription duplicated across 2 files (non-blocking)
- Silent catch block in FilesProvider line 391 (non-blocking)
- All core patterns (modal, search, select, accordion, tRPC) followed correctly

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

* feat: [QUA-001] - Extract shared file helper functions

- Create ~/utils/file-display-helpers.ts with getFileLabel and getFileDescription
- Update DownloadVariantDropdown.tsx to import from shared utility
- Update RequiredComponentsSection.tsx to import from shared utility
- Remove duplicated function definitions from both files

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

* feat: [QUA-002] - Fix silent error catch block

Add proper error handling to the empty catch block in FilesProvider.tsx.
Previously, errors from createFileMutation.mutateAsync were silently caught.
Now displays an error notification to the user matching established patterns.

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

* feat: [QUA-003] - Check for unused imports and dead code

- Remove unused import MasonryScroller from masonic in Files.tsx
- Remove unused variables offset and resizeObserver in Files.tsx
- Fix unescaped apostrophe (We'll -> We&apos;ll) in Files.tsx
- Remove unused index parameter in map callbacks in Files.tsx
- Remove unused componentType prop destructuring in RequiredComponentsSection.tsx
- Remove unused modelType prop destructuring in DownloadVariantDropdown.tsx

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

* feat: [QUA-004] - Verify accessibility attributes

Added comprehensive ARIA attributes and keyboard accessibility:
- DownloadVariantDropdown: Added role="button", aria-expanded, aria-haspopup,
  aria-label, tabIndex, keyboard handler to dropdown trigger; role="listbox"
  to dropdown content; role="option", aria-selected, aria-label to VariantItem
- RequiredComponentsSection: Added role="button", aria-expanded, aria-label,
  tabIndex, keyboard handler to expandable component headers; role="listbox"
  to variant list; role="option", tabIndex, aria-selected, aria-label,
  keyboard handler to file selection boxes
- LinkComponentModal: Added aria-label to QuickSearchDropdown and Select
  components for search, version, and file selection

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

* feat: [QUA-006] - Verify useEffect cleanup

- Added useMemo to the `files` variable in LinkComponentModal.tsx to maintain
  reference equality for useEffect dependencies
- Fixes ESLint react-hooks/exhaustive-deps warning about the files logical
  expression causing useEffect dependencies to change on every render
- Reviewed all useEffect hooks in LinkComponentModal.tsx, FilesProvider.tsx,
  and DownloadVariantDropdown.tsx for proper cleanup
- Verified no async operations/subscriptions/timers require cleanup in these files

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

* chore: Remove accidentally committed temp and ralph project files

- Remove ralph phase2-pattern-reuse prd.json and progress.txt (should be gitignored)
- Remove tmpclaude-* temp files
- Add tmpclaude-* pattern to .gitignore

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

* Fixes typecheck issues

* feat(model-ui): overhaul model sidebar with variant downloads, components, and actions

- Restructure primary actions card with consolidated icon row (generate, share, like, vault, civitai link, notifications, collect, bid, report)
- Add DownloadVariantDropdown with grouped file sections (SafeTensor/GGUF/Other) and best-match badge
- Add RequiredComponentsSection with component variant selection and Download All
- Persist linked components via RecommendedResource with isLinkedComponent flag
- Overhaul PermissionIndicator to show all permission badges (commercial, generation, credit, merges, license, NSFW)
- Handle early access/Buzz pricing in download and component sections
- Expand upload UI component filter to include UNet, CLIPVision, ControlNet
- Remove dead LinkComponentModal code (keep type export only)
- Fix: side effect in setState updater causing duplicate API calls
- Fix: memoize filesVisible to prevent groupFilesByVariant recomputing every render
- Fix: stagger multi-download to avoid browser popup blocking
- Fix: gate download hrefs on canDownload for early access models
- Fix: add staleTime to getFollowingUsers query to reduce unnecessary refetches

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

* Includes design file

* feat: model sidebar UI polish - verification status, button styling, reviews

- Add VerifiedText scan status to all file variants (download dropdown, required components, optional files)
- Remove redundant VAE alert banner from ModelFileAlert (now in RequiredComponentsSection)
- Hide Bid button for unpublished/deleted model versions
- Restyle Generate button as primary CTA with gradient background
- Make download button styling consistent (light blue secondary CTA) for single and multi-variant
- Update VerifiedText to match design (smaller text, colored icons, no ThemeIcon wrapper)
- Fix VerifiedText click propagation preventing variant toggle on popover click
- Fix light mode hover backgrounds on variant rows for readability
- Replace ResourceReviewThumbActions with ModelVersionReview in Details card

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

* feat: add isRequired to file metadata & merge upload UI sections

- Add `isRequired` field to BasicFileMetadata, modelFileMetadataSchema,
  and linkedComponentSettingsSchema
- Move LinkedComponent type from LinkComponentModal.tsx to model-file.schema.ts
- Replace hardcoded 3-way file split (Model/Required/Optional) with
  dynamic 2-way split (Model Files + Additional Components) driven by
  acceptedModelFiles per model type
- Add Switch toggle for required/optional on FileCard and LinkedComponentCard
- Smart defaults: component types (VAE, Text Encoder, etc.) default to required
- Update groupFilesByVariant() to use metadata.isRequired for sidebar grouping
- RequiredComponentsSection now data-driven (no hardcoded component type list)
- Update validation to use isRequired for component-only model checks

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* Updates design

* fix: restore buzz earned and generation popularity metrics to model version details

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: enrich linked components at read time & add backend link creation

- Batch-fetch ModelFile data (name, sizeKB, type, metadata) in both
  controllers to enrich linked components at read time, fixing stale
  data for older records
- Add addLinkedComponent mutation that resolves file details server-side,
  eliminating the extra modelVersion.getById fetch from the frontend
- Remove sizeKB from linkedComponentSettingsSchema (no longer persisted)
- Add fileType/fileMetadata to LinkedComponent for correct download URLs
- Update RequiredComponentsSection and ModelVersionDetails to use file
  metadata for download URLs instead of user preferences
- Display file size next to download button consistently across all
  component types
- Add UNet, CLIPVision, ControlNet to modelFileTypes and modelFileOrder
- Various UI polish: icon swap, border removal, no-wrap on file sizes

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: guard download buttons behind purchase check for optional/linked files

Optional files and linked components in both ModelVersionDetails and
RequiredComponentsSection were missing canDownload guards, allowing
direct downloads on early-access models that require purchase. Also
removes unused downloadPrice prop from ComponentGroup.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* Address PR review: type safety, null handling, and shared constants

- Add componentFileTypes constant to DRY up hardcoded arrays in 3 locations
- Tighten componentType schema validation from z.string() to z.enum()
- Prevent null quantType via allowDeselect={false} on SettingsCard Select
- Fix groupFilesByVariant to default isRequired to true (isRequired !== false)
- Normalize component-only validation to match linked component convention
- Remove unused RecommendedResourceSettings import

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* Correctly filters out incompatible resources when linking components

* Fix model UI feedback: share popover, download URLs, resource select, upload errors

- Add withinPortal to ShareButton Popover to fix clipping in sidebar
- Use primary download URL for linked components to avoid 404 misalignment
- Default resource select to 'all' tab for modelVersion linking and skip
  empty recommendedModels filter to prevent spinner
- Improve file upload rejection messages with supported types and max file
  limit notifications

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* Port resource select fixes to new ResourceSelectModal component

Apply the same fixes from the deleted ResourceSelectModal2.tsx:
- Default to 'all' tab when selectSource is 'modelVersion'
- Skip empty recommendedModels filter to prevent spinner

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* Replace useEffect with conditional state for modelVersion tab default

Use local useState instead of localStorage-backed state when
selectSource is 'modelVersion', avoiding the stale tab problem
without a useEffect override.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* Fixes bad download url for required and optional components

* Fix Download All Components to download multiple files via hidden iframes

The previous implementation used forEach + setTimeout which broke the
browser's user-gesture trust context, causing only the first file to
download. The model download endpoint uses res.redirect(), so programmatic
<a> tag clicks get blocked after the first navigation. Hidden iframes each
follow redirects independently in their own browsing context. Also adds
loading state to the download button.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* Migrate vaeId to linked components system

Replace the legacy ModelVersion.vaeId FK with the linkedComponents
system (RecommendedResource table). This unifies VAE linking under the
same mechanism used for all other component types (TextEncoder, UNet,
CLIPVision, etc.).

- Add SQL migration to create RecommendedResource records for existing vaeId refs
- Add getLinkedVaeIds helper for batch VAE resolution from linked components
- Update model controller, file service, generation service, and public API
- Replace raw SQL vaeId column refs with RecommendedResource subqueries in caches
- Generalize file download to support all linked component types, not just VAE
- Remove vaeId from selectors, schemas, and frontend form
- Remove duplicate VAE display in model detail (linkedComponents already shows it)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* Removes TextualInversion and Hypernetwork filters when selecting linkedComponents

* Retrigger pr preview deployment

* Address model upload wizard UI feedback

- Add Workflow and Upscaler to file types, component types, and file order
- Expand GGUF quant types from 7 to 25 (K-quants, legacy, I-quants)
- Allow .gguf files in Checkpoint additional components (for quantized text encoders)
- Fix .zip inference: no longer auto-assigns type, user must pick
- Fix .zip format mapping from 'Diffusers' to 'Other'
- Add .json default inference to 'Config'
- Add ComfyUI-friendly display labels (CLIP / Text Encoder, UNet / Diffusion Model, etc.)
- Add Upscaler and Workflow to linked component resource selector
- Improve validation error surfacing with per-file toast notifications
- Add red border highlight on file cards with validation errors
- Make quant type select searchable for the expanded list

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* Update quantQualityRank with expanded GGUF quant types

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: add Consolidate Versions feature for merging multi-version models

Allow creators to merge multiple model versions into a single version,
moving all files into the target version with configurable file type
mapping and aggregating stats (downloads, likes, etc.) from all source
versions. Triggered from the model detail page context menu.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: update import path for ReportEntity to use shared utils

* feat: enhance ConsolidateVersions UI and support file metadata updates

Overhaul the ConsolidateVersions modal with improved theme-aware design,
file metadata editing (fp, size, format, quantType, isRequired), and
updated schema/service to persist metadata changes during consolidation.
Also pass quantType and isRequired through getModelsWithVersions.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: GGUF validation, .bin file type options, and required toggle reset

- Skip fp (precision) validation for GGUF checkpoint files since they use
  quantType instead — the UI shows Quant, not Precision, for GGUF files
- Allow .bin files to be assigned component types (VAE, UNet, etc.) in the
  file type dropdown, not just Model/Negative
- Stop resetting the Required toggle when changing file type in Additional
  Components — preserve the user's manual selection

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: remove DownloadHistory reference from consolidateVersions

The DownloadHistory table no longer exists in production — downloads
are now tracked in ClickHouse. The raw SQL query was causing a 42P01
error when consolidating versions.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: expand file upload types, selective version merging, rename consolidate to merge

- Allow config-type files (.json, .yaml, .yml, .txt) as primary uploads for
  Workflows, Poses, Wildcards, and Other model types
- Add .safetensors to Detection primary extensions
- Ensure all model types accept archive + config in additional components
- Extract shared filterFileTypeByExtension to file-display-helpers.ts
- Make inferFileType model-type-aware for correct .zip default assignment
- Fix additional dropzone defaultType bug (was always using first type)
- Add selective source version picking to merge versions wizard
- Rename ConsolidateVersions to MergeVersions across frontend and backend

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: remove debug console.log referencing ctx.isGreen in trpc middleware

Stray debug log was accessing ctx.isGreen which doesn't exist on the
context type — isGreen is a feature flag on ctx.features, not ctx directly.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: skip type inference for additional component drops, add Diffusion Model file type

Files dropped in the Additional Components section no longer auto-infer as 'Model' type,
letting users pick the correct component type (VAE, UNet, etc.). Split grouped labels
"CLIP / Text Encoder" → "Text Encoder" and "UNet / Diffusion Model" → separate "UNet"
and "Diffusion Model" options.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* refactor: reorganize model sidebar cards for better UX flow

Move resource review card after the accordion so it no longer interrupts
between download and model details. Group download-related alerts near
the download card. Move donation goals below review as a secondary CTA.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat(model-upload): add UNet and Diffusion Model to Checkpoint primary file types

Move UNet and Diffusion Model from the additional components section to
the primary model files section for Checkpoint models, so users can
select these types directly from the model file type dropdown.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
Co-authored-by: manuelurenah <manuel.ureh@hotmail.com>
2026-04-22 13:35:05 -04:00
Justin Maier 2a12585c75 chore(gitignore): exclude docs/incidents and cloudflare skill local state 2026-04-17 11:14:09 -06:00
manuelurenah c57437acb1 Add Freshdesk support skill for Claude Code
Adds a Freshdesk API integration skill for querying tickets, replying to
customers, managing KB articles, and looking up contacts from Claude Code.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 16:54:19 -04:00
Justin Maier aaf7ec3620 feat: Add Dynamic Prize Pool card to challenge pages
Combined 3-section SpotlightCard for challenges with dynamic pricing:
- Green top: Growing prize pool with shimmer animation and progress bar
- Yellow middle: Your Entries with segmented review progress and guarantee button
- Gray bottom: Generate + Submit action buttons

Includes mod-only preview toggle for testing different entry states,
extracted shared constants/helpers (DRY refactor), and reusable
ProgressLegendDot and GlowDivider components.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 21:48:59 -07:00
Justin Maier 47910c946e feat: Add paid challenge judging (#2031)
* feat: Add paid challenge judging — pay Buzz to guarantee entry review

Users can pay Buzz to guarantee their challenge entries get reviewed by
the AI judge instead of waiting for random selection. Adds reviewCost
field to Challenge model, requestReview mutation for buzz payment + tag
assignment, getUserUnjudgedEntries query, guarantee checkbox in submit
modal, post-submission review UI on detail page, and removes the
per-user scoring cap for paid (reviewMeTagId) entries.

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

* docs: Add UI preview screenshots for paid challenge judging

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

* feat: Add flat-rate review option for paid challenge judging

Extends the paid review system to support both per-entry and flat-rate
pricing. Flat rate charges once and covers all current + future entries.
UI updates include a review cost type selector, segmented progress bar
with hover animation, spotlight card effect, and HoverCard→Popover swap.

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

* fix: Address PR review issues for paid challenge judging

- Use pendingCount instead of unreviewedCount for guarantee cost display
  to avoid inflating the Buzz price with already-queued entries
- Fix test assertions to check createBuzzTransactionMany (per-entry
  transactions) instead of createBuzzTransaction (single transaction)
- Invalidate getUserUnjudgedEntries after submit to prevent stale data

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

* fix: Revert guaranteeCost to use unreviewedCount (include queued entries)

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

* fix: Move stories file out of pages/ to fix build error

Next.js requires all files in pages/ to have a default React component
export. Moved EligibleModels.stories.tsx to src/components/Challenge/.

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

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: manuelurenah <manuel.ureh@hotmail.com>
2026-02-13 15:26:46 -04:00
Justin Maier 8456cadd62 Add Flipt feature flag management skill
- SKILL.md: Documentation for managing feature flags
- flipt.mjs: CLI script for listing, getting, and managing flags
- .env.example: Template for Flipt configuration
- Supports list, get, create, enable, disable, delete commands
- Graceful handling of read-only API tokens with GitOps guidance

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 11:37:51 -07:00
Justin Maier bc058e8387 Merge remote-tracking branch 'origin/test/animal-paragraph' 2026-01-16 21:47:32 -07:00
Justin Maier 04db54219c refactor: Consolidate Ralph daemon into skill and improve orchestration
- Move daemon code from ralph-daemon/ into ralph/daemon/
- Add `watch` command for efficient state change monitoring
- Add child session prefix (child-*) for clear hierarchy
- Add PRD type validation before session creation
- Fix abort cascade bug with defensive array handling
- Update orchestrator prompt to recommend watch over polling
- Add gitignore for daemon runtime files (pid, data, temp dirs)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-16 21:46:23 -07:00
Justin Maier b6b1dfc48f fix: Simplify .browser gitignore to ignore entire profiles folder
Each developer has different browser profiles, so there's no need
to track the metadata file.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-16 21:33:40 -07:00
Justin Maier 8c83826368 refactor: Consolidate browser automation skill into HTTP server
- Remove redundant files (runner.mjs, login-helper.mjs, lib/)
- Add multi-session support with named sessions
- Add profile management with metadata
- Add flow listing and execution endpoints
- Add full-page screenshot support (?fullPage=true)
- Fix project root path calculation (was going 4 dirs up instead of 3)
- Update .gitignore for profile auth state files
- Simplify SKILL.md documentation

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-16 10:10:33 -07:00
Justin Maier 5a57ba1183 Add browser automation skill with REPL exploration and flow saving
- Interactive REPL mode for exploring pages via Playwright code chunks
- Chunk recording system for capturing successful interactions
- Flow saving with curation (select which chunks to keep)
- Flow chaining (run saved flows as chunks in exploration)
- Session folder structure for organizing screenshots
- One-shot page inspection command

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-15 23:26:20 -07:00
Zach Lowden d2e116a0f1 Fix Redis Cluster failover handling with proactive topology refresh
After migrating to Redis Cluster, the app would hold stale connections
after a failover because node-redis only refreshes topology on MOVED/ASK
errors. If the old master goes down before responding, the client never
discovers the new topology.

Changes:
- Add cluster-specific event handlers (node-error, node-disconnect) that
  trigger topology rediscovery when nodes fail
- Add periodic topology refresh (default 30s) to catch missed failovers
- Support multiple root nodes via REDIS_CLUSTER_NODES env var for
  redundant discovery during startup
- Increase maxCommandRedirections from 16 to 32 for longer failovers

New environment variables:
- REDIS_CLUSTER_NODES: Comma-separated list of cluster node URLs
- REDIS_CLUSTER_REFRESH_INTERVAL: Topology refresh interval in ms (default 30000)

Addresses: https://github.com/redis/node-redis/issues/2806

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

Gate enhanced Redis cluster failover behind Flipt feature flag

Adds REDIS_CLUSTER_ENHANCED_FAILOVER flag to Flipt enum and gates
the enhanced failover handling (topology rediscovery on node events
and periodic refresh) behind this flag.

When flag is disabled (default):
- Basic cluster operation continues normally
- Node events are logged but don't trigger rediscovery
- No periodic topology refresh

When flag is enabled:
- Node errors/disconnects trigger topology rediscovery
- Periodic topology refresh runs every REDIS_CLUSTER_REFRESH_INTERVAL ms

This allows safe rollout of the new failover handling behavior.

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

Pass hostname context to Flipt for is-next segment matching

The REDIS_CLUSTER_ENHANCED_FAILOVER flag uses the "is-next" segment
(hostname == next.civitai.com) to enable enhanced failover by default
on next.civitai.com while keeping it disabled elsewhere.

This commit:
- Extracts hostname from NEXTAUTH_URL for Flipt context
- Passes hostname context to isFlipt() calls for segment matching
- Logs the hostname being used for debugging

Flipt flag configuration required:
- Flag: redis-cluster-enhanced-failover
- Default: disabled (false)
- Rollout: segment "is-next" -> enabled (true)

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

gitignore
2026-01-15 14:11:15 -06:00
Justin Maier e08ab05e1c Add mod-actions Claude Code skill for moderator actions
Adds a new skill for taking moderator actions via tRPC API:
- user lookup by ID or username
- ban/unban users with reason codes and messages
- mute/unmute users
- set leaderboard eligibility
- remove all user content

Uses API key authentication with configurable endpoint (production or local).

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-15 10:05:54 -07:00
Luis Rojas cfc440ea70 Merge branch 'main' into feature/feeds 2025-12-04 11:45:23 -04:00
Luis Rojas ed9787c393 Tie a model to the a workflow as soon as its submitted 2025-12-01 15:19:07 -04:00
Luis Rojas dbbec28265 Merge with main 2025-11-21 15:49:17 -04:00
Justin Maier 91eca6dfa3 Adjust multi fetching of collection permissions 2025-11-20 18:16:38 -07:00
Briant Diehl 0d320ab932 eslint rules for import resolver 2025-11-20 15:16:40 -07:00
Justin Maier 215fa4376d Add prisma sliming 2025-11-19 19:51:03 -07:00