27 Commits

Author SHA1 Message Date
64c1e0e3ad Merge branch 'feat/allow-on-demand-mirroring'
All checks were successful
deploy / build-web (push) Successful in 1m49s
deploy / build (push) Successful in 9m19s
deploy / deploy (push) Successful in 18s
deploy / deploy-web (push) Successful in 11s
2026-08-31 15:10:38 +03:00
4b359133b9 feat(deploy): mirror on demand for authenticated clients
All checks were successful
ci / web (push) Successful in 1m48s
ci / check (push) Successful in 6m36s
Sets allow_new_repos true in the deployed configuration, so an
authenticated request for a repository the bucket does not hold fetches
it from upstream and stores it on the way past.

This is what makes the registry useful as a drop-in HF_ENDPOINT for a
consumer: helexa's neurons resolve eight catalogued models, none of
which were in the bucket, and pre-seeding each one by hand before it can
be served defeats the purpose of putting a mirror in front of the Hub at
all. With the flag off the service answered exactly as designed --
"forbidden by policy: Qwen/Qwen3-1.7B is not already stored and
policy.allow_new_repos is false" -- which is correct behaviour and the
wrong deployment for this use.

The trade is real and now stated where it will be read rather than
buried: the bucket becomes bounded by auth rather than by policy, so
every issued client token is a licence to store arbitrary upstream
repositories. policy.allowlist is the middle setting that keeps
on-demand mirroring while confining it to expected namespaces, and it
stays empty here only because the consumer's catalogue is expected to
grow.

The agent notes previously asserted that the deployment relied on the
flag being false; that claim would have been quietly wrong from this
commit onward, so it is rewritten rather than left to mislead.
2026-08-31 15:08:36 +03:00
059c781422 fix(conformance): the hf CLI check was silently skipping in CI
All checks were successful
deploy / build-web (push) Successful in 1m42s
deploy / build (push) Successful in 6m37s
deploy / deploy (push) Successful in 18s
deploy / deploy-web (push) Successful in 10s
The suite's first run on a runner reported "10 passed, 0 failed" and
exited 0. Locally it is 11. The difference is the `hf` CLI check, which
did not run: the workflow invokes .venv/bin/python directly without
putting the venv's bin on PATH, so shutil.which("hf") found nothing and
the check skipped.

A skipped check reads exactly like a passing one, and this is the suite
that runs unattended on a weekly timer specifically to notice when a
client changes underneath us -- so a silent skip defeats its purpose.

Exports the venv's bin in the workflow and asserts `hf version` before
the suite, so a missing CLI fails the job outright rather than being
quietly stepped over. Also counts skips and lists them in the summary,
where they cannot be mistaken for passes.

Verified both ways: with the venv off PATH the suite now reports
"10 passed, 0 failed, 1 skipped" and names it; with it on PATH, 11
passed and 0 skipped.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XZG2i4AmfSqE97EJGBVb64
2026-08-31 14:14:15 +03:00
a91aaf3d1c feat(infra-setup): create the public DNS records, idempotently
All checks were successful
deploy / build-web (push) Successful in 1m40s
deploy / build (push) Successful in 6m32s
deploy / deploy (push) Successful in 18s
deploy / deploy-web (push) Successful in 11s
Publishing the name was a block of printed instructions, which put the
knowledge in whoever last read them. It is now a step like every other:
absent records are created, correct ones are reported, and a name that
points somewhere else is left alone with a pointer to public-dns.md §4.
`--skip-dns` opts out.

The apex takes a CNAME to the site indirection, not an A record.
Cloudflare flattens it, and it buys the property §2 is about -- a WAN
address change is one record rather than a hunt across zones. bansko.io
already does this; the claim in public-dns.md §3 that the fleet's apexes
use A records was wrong and is corrected there. Note that `dig` cannot
tell you which a zone uses, because flattening makes both answer with an
A; only the API can.

The script lives in script/publish-dns.sh and is piped to the proxy
rather than embedded as a heredoc. Quoting shell through an unquoted
heredoc silently mangled two earlier versions of this same code -- once
producing `tr -d "'" ")` -- and a file can be linted and run directly.
It executes on the proxy because that is where the Cloudflare token is,
and the token is never copied off that host: it can rewrite DNS for
every zone on the account.

Token parsing uses awk rather than a PCRE lookbehind. Widening the
lookbehind to tolerate variable spacing made grep fail outright --
lookbehinds must be fixed-length -- so the fixed-width form would have
broken silently the day someone reformatted the credentials file.

Verified: first run created both records, second reported them ok,
rustingface.com and www both resolve to the site address, and the
Let's Encrypt chain validates against the public trust store.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XZG2i4AmfSqE97EJGBVb64
2026-08-31 13:58:36 +03:00
5244585ae0 ci(deploy): read the nginx logs without sudo
All checks were successful
deploy / build-web (push) Successful in 1m39s
deploy / build (push) Successful in 6m31s
deploy / deploy (push) Successful in 17s
deploy / deploy-web (push) Successful in 11s
The nginx log capture ran `sudo tail`, which is not in the proxy's
scoped sudoers, so deploy-web failed on its very last step -- after the
frontend had shipped, nginx had reloaded and every health check had
passed. The deployment was complete and the job still reported failure.

nginx's logs are 0644 and /var/log/nginx is traversable, so the deploy
account reads them as itself; verified with `sudo -u gitea_ci tail`.
Dropping the sudo is the fix, rather than widening the whitelist for
something that never needed it.

Also captures the public vhost's error log alongside the mesh one, since
there are now two.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XZG2i4AmfSqE97EJGBVb64
2026-08-31 13:46:06 +03:00
29e4b08947 fix(web): pnpm 11 renamed the build allowlist to allowBuilds
Some checks failed
deploy / build-web (push) Successful in 1m36s
deploy / build (push) Successful in 6m26s
deploy / deploy (push) Successful in 18s
deploy / deploy-web (push) Failing after 13s
The runner is pnpm 11.24.0; this workstation is 10.30.3. The key was
renamed across that major: pnpm 10 reads `onlyBuiltDependencies` (a
list), pnpm 11 reads `allowBuilds` (a package -> bool map). Each ignores
the other's key, which is why the runner kept reporting ignored builds
while every local check passed.

The diagnostic step found it in one run, after three failed guesses.
`pnpm config get onlyBuiltDependencies` on the runner returned the list
correctly, which was the misleading part -- the value resolved fine, it
simply is not the key pnpm 11 consults. `pnpm approve-builds @swc/core
esbuild` then wrote the pnpm 11 form and named it outright.

Declares both keys so neither version is left out, rather than pinning
the fleet to one. Verified by reproducing the failure locally under
pnpm 11 (npx pnpm@11.24.0), then confirming install, lint and build pass
under both 11.24.0 and 10.30.3.

Drops the package.json `pnpm` field entirely: pnpm 11 warns it is no
longer read for anything.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XZG2i4AmfSqE97EJGBVb64
2026-08-31 13:32:23 +03:00
78d35cfd29 ci(web): declare the build allowlist in both places, and report pnpm's view
Some checks failed
deploy / build (push) Successful in 6m31s
deploy / deploy (push) Successful in 19s
deploy / build-web (push) Failing after 1m10s
deploy / deploy-web (push) Has been skipped
Third build-web failure with the same ERR_PNPM_IGNORED_BUILDS. The
setting was first in package.json (pnpm 10 here does not read it) and
then in pnpm-workspace.yaml (verified read here, still ignored on the
runner), so the runner's pnpm evidently resolves it differently and I
have been guessing at which.

Declares it in both locations -- four duplicated lines against a failure
that only manifests on CI -- and adds a step printing pnpm --version and
the resolved value, so if this still fails the log says why instead of
costing another round trip.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XZG2i4AmfSqE97EJGBVb64
2026-08-31 13:21:54 +03:00
05c3e56dba fix(web): put the build-script allowlist where pnpm 10 actually reads it
Some checks failed
deploy / build (push) Waiting to run
deploy / build-web (push) Failing after 1m8s
deploy / deploy (push) Has been cancelled
deploy / deploy-web (push) Has been cancelled
The previous attempt set pnpm.onlyBuiltDependencies in package.json.
pnpm 10 does not read it from there -- `pnpm config get
onlyBuiltDependencies` returns undefined -- so the runner failed exactly
as before.

It looked fixed locally because the pnpm store had already built both
packages, so a reinstall printed no warning whether or not the allowlist
was being read. "No warning" was not evidence; `pnpm config get`
returning the values is, and it does now.

Moves the allowlist to pnpm-workspace.yaml, which is where pnpm 10 takes
its settings, and drops the inert package.json field rather than leave
two places appearing to configure the same thing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XZG2i4AmfSqE97EJGBVb64
2026-08-31 13:18:08 +03:00
32621f98ad fix(infra-setup): the DNS snippet it printed was not valid shell
Some checks failed
deploy / build (push) Waiting to run
deploy / build-web (push) Waiting to run
deploy / deploy (push) Has been cancelled
deploy / deploy-web (push) Has been cancelled
Building the Cloudflare guidance out of nested-quote `echo` lines
mangled the credential extraction into

    ... | tr -d "'" ")

which is a syntax error if pasted. The whole point of printing it is
that somebody pastes it.

Prints a quoted heredoc instead, so the shell reproduces it verbatim,
and substitutes the three values with a sed pass afterwards -- doing it
inside the heredoc would put the quoting back in play, which is what
broke it. Verified by extracting the rendered snippet and running
`bash -n` over it.

Also states the ordering that matters, since the certificate and vhost
now exist while the name does not resolve: land a deploy that enforces
auth before publishing DNS, because until then an unresolvable name is
the only thing keeping the bucket private. And corrects the closing
advice, which still listed the pre-CLIENT_TOKENS secret set.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XZG2i4AmfSqE97EJGBVb64
2026-08-31 13:16:51 +03:00
8595529051 fix(web): approve the two dependencies that need install scripts
Some checks failed
deploy / build (push) Waiting to run
deploy / build-web (push) Failing after 54s
deploy / deploy (push) Has been cancelled
deploy / deploy-web (push) Has been cancelled
pnpm 10 refuses to silently run a dependency's build script. Locally
that is a warning; on the runner it is ERR_PNPM_IGNORED_BUILDS and the
job fails, so build-web broke on run 13 while everything passed here.

@swc/core and esbuild both fetch a platform-native binary at install
time and do not work without it, so they are allowlisted explicitly via
pnpm.onlyBuiltDependencies. Everything else in the tree stays denied,
which is the point of the feature -- an install script is arbitrary code
from a transitive dependency.

Also sets strict-dep-builds in web/.npmrc so a skipped build script is
an error here too. Same reasoning as the release-age policy added
earlier: a local install that passes where the runner fails is a
difference only discoverable by pushing, and that is a slow way to find
out.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XZG2i4AmfSqE97EJGBVb64
2026-08-31 13:11:31 +03:00
3790f2589c fix(core): the per-key manifest lock did not actually exclude
Some checks failed
deploy / build (push) Waiting to run
deploy / build-web (push) Failing after 55s
deploy / deploy (push) Has been cancelled
deploy / deploy-web (push) Has been cancelled
The lock added alongside compare-and-set removed its map entry inside
the critical section. A queued waiter still held that mutex, so the next
arrival found no entry, created a fresh one, and the two then ran
concurrently -- defeating the lock exactly when there was contention,
which is the only time it does anything.

I previously reported this fix as working on the strength of ten clean
release-mode runs. That was wrong: in debug, where the timing differs,
the guarding test fails 3 times in 12. Retiring the entry only after
dropping our own reference, under the same shard lock `entry` takes,
gives 0 failures in 25 -- and restoring the old behaviour reproduces 3
in 12, so the test really does catch it.

The consequence was the one the test names: a file served to a client
but never recorded, and so invisible the moment the instance is sealed.
Real against the local filesystem store, which has no conditional put
and relies on the verify-after-write retry; MinIO's compare-and-set
covers the same race, which is why the deployed path never showed it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XZG2i4AmfSqE97EJGBVb64
2026-08-31 13:03:55 +03:00
5a94d90b79 fix(ci): restore the format gate and match the runner's package policy
Some checks failed
deploy / build-web (push) Failing after 58s
deploy / build (push) Successful in 6m34s
deploy / deploy (push) Successful in 17s
deploy / deploy-web (push) Has been skipped
Run 12 failed both build jobs, for two unrelated reasons of mine.

cargo fmt: the previous commit ran clippy and the test suite but not
the formatter, and inventory.rs was written by hand. Formatted.

pnpm: the runner enforces a minimum release age on lockfile entries --
a supply-chain safeguard against a package published minutes ago -- and
rejected electron-to-chromium@1.5.417 and ignore@7.0.7, both published
inside its 24h cutoff. My local install had no such policy, so the
lockfile resolved to them and the failure only surfaced on the runner.

Adds web/.npmrc pinning the same 1440-minute policy locally, so a
lockfile resolved here is one CI will accept, and re-resolves against
it (1.5.416 and 7.0.6). Setting it in the repo rather than fixing the
two versions by hand is what stops this recurring on the next
dependency change.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XZG2i4AmfSqE97EJGBVb64
2026-08-31 12:52:32 +03:00
99e099994d chore(web): stop tracking tsc incremental build state
Some checks failed
deploy / build (push) Failing after 5s
deploy / deploy (push) Has been skipped
deploy / build-web (push) Failing after 57s
deploy / deploy-web (push) Has been skipped
tsconfig.*.tsbuildinfo is a cache tsc regenerates from the sources; it
carries absolute paths from whoever built last and would conflict on
every branch that touches a .ts file.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XZG2i4AmfSqE97EJGBVb64
2026-08-31 12:37:35 +03:00
acd47a31c8 feat(web): add the operator UI and the /v1 inventory API it reads
Three routes, per architecture/generic.md §4 (Vite + React + SWC + TS,
static, served by nginx): / renders the repository readme, /models is a
paged and filterable listing of what the bucket holds, and
/{namespace}/{name} shows one repository -- its pinned refs, the files
actually stored, and its model card.

The API it reads is /v1/, deliberately not /api/. That surface is the
Hub's, recorded and replayed verbatim, and adding routes of our own to
it risks a client mistaking one for the real thing. /v1/ answers a
question the Hub has no equivalent for -- what is in this bucket --
which is inventory, not the model search the spec rules out.

The listing shows only what has actually been fetched, never upstream's
siblings. A repository pulled one file at a time shows one file, which
is the honest answer to "what can I get from here offline".

Model cards are third-party content, so their HTML is parsed and then
sanitised against GitHub's allowlist. The plugin order is load-bearing
and commented as such. Verified against a card crafted with <script>,
onerror, a javascript: href, an <iframe>, an SVG-embedded script and an
inline handler: none execute and ordinary markdown still renders. Card
images are not loaded at all -- fetching them would leak the viewer's
address to a third party and make an offline registry's pages depend on
the internet.

Routing: rustingface's URL space is the Hub's, so /Qwen/Qwen3-0.6B is
both a page and the prefix of a file. The vhost splits them the way the
Hub does -- /resolve/ anywhere in the path, plus /api/ and /v1/, go to
the service; everything else is the app. A repo legitimately named
v1/repos is handed back to the resolve path by the router, and there is
a test for it. The /resolve/ test is on the repo type rather than the
path substring, because a repo may contain a directory called resolve
and /api/models/a/b/tree/main/resolve/f must stay a tree request.

Deployment: the frontend ships to hanzalova:/var/www/rustingface, so
that host now gets its own scoped gitea_ci drop-in -- narrower than the
service host's: a webroot rsync, a relabel, nginx -t and a reload. The
health check probes rf.internal from the proxy rather than from the
runner, because a runner is a plain Fedora container with no internal
root CA (verified: fedora:43 gets 000, the proxy gets 200).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XZG2i4AmfSqE97EJGBVb64
2026-08-31 12:37:26 +03:00
010a8bbc0e fix(bin): make observability.log_format actually take effect
All checks were successful
deploy / build (push) Successful in 6m31s
deploy / deploy (push) Successful in 22s
The bootstrap subscriber installed before the config was loaded won the
race for tracing's global subscriber, which can only be set once. The
configured subscriber's try_init() was therefore always a silent no-op
and observability.log_format was inert -- the deployed service, with
log_format = "json", was writing ANSI-coloured text into the journal.
The escape codes land in journald as literal bytes.

Loads the configuration first and installs the subscriber once, since
the configuration is what decides the format. Nothing is lost: a config
load failure leaves main as an anyhow error with its context on stderr,
which is where it was going anyway.

Also disables ANSI unless stderr is a terminal. tracing_subscriber
colours by default regardless of the writer, so escape codes were going
into the journal and would go into any redirected log file.

Verified: json emits one parseable object per line with no escapes,
text emits plain text, and JOURNAL_STREAM still forces json for a
service started by systemd whatever the file says.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XZG2i4AmfSqE97EJGBVb64
2026-08-31 11:59:13 +03:00
4f72e4bfa6 feat(bin): flag serving gated weights without client auth
All checks were successful
deploy / build (push) Successful in 6m27s
deploy / deploy (push) Successful in 17s
An upstream token is what makes gated repositories fetchable, and a
gated repository's licence is between the operator whose token accepted
the terms and its publisher. Serving those weights to anyone who can
reach the port is redistribution, which most such licences forbid --
and the design rests on the stored copy being for the operator's own
use (spec §7). Nothing said so when upstream.token_file was set and
auth.mode was "none", which is the shipped default.

Warns at startup and reports an advisory from `doctor`. Checks that the
token file has content rather than merely existing, because the deploy
renders it whether or not the secret behind it was set.

Advisories are a new, non-failing tier in `doctor`. It runs in the
deploy, so promoting this to an error would block a deployment on a
question that is the operator's to answer: whether mesh-only reach
already counts as own use is a licensing judgement, not something this
binary should decide.

auth.mode stays explicit rather than being inferred from the presence
of auth.token_file. Inference fails open -- a rendered-but-empty
credential would silently disable authentication and the service would
come up looking healthy -- whereas an explicit mode turns the same slip
into a refusal to start. The redundancy is what makes losing either
half detectable.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XZG2i4AmfSqE97EJGBVb64
2026-08-31 11:45:58 +03:00
2d2dd7152a fix(bin): don't refuse to start over a credential the config never reads
Some checks failed
deploy / build (push) Waiting to run
deploy / deploy (push) Has been cancelled
The deployed config template names every credential path unconditionally
so an operator can see where each one goes. `serve` then read
auth.token_file whether or not bearer auth was on, so a deployment with
auth.mode = "none" -- the shipped default -- failed at startup with
"/etc/rustingface/client-tokens does not exist". The documentation was a
landmine.

Reads a credential only when the running configuration actually consults
it: the client token file only under bearer auth, and the object-store
keys only when there is an S3 endpoint rather than a local path. A
credential that *is* needed and missing still fails loudly and by name;
that is the behaviour worth keeping.

Adds tests/startup.rs, which drives the real binary. The first case
spawns `serve` rather than a subcommand that never touches the file --
running `list` there would have passed either way. Verified the test
catches the bug: reverting the fix reproduces the exact production
error.

Observed on deploy run 8, which got the whole pipeline through sysusers,
rsync, firewalld and restart before the service exited on this.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XZG2i4AmfSqE97EJGBVb64
2026-08-31 11:40:10 +03:00
d3be7937d5 fix(deploy): stop the placeholder guard tripping over its own comment
Some checks failed
deploy / build (push) Successful in 6m22s
deploy / deploy (push) Failing after 17s
The render step guards against shipping a config with an unrendered
{{PLACEHOLDER}} by grepping for a bare "{{". The template's own header
comment explains the substitution syntax and contains that string, so
the guard matched line 3 of every successfully rendered config and
failed the deploy with "unrendered placeholder in the config" -- while
every actual placeholder had been substituted correctly.

Matches the placeholder shape ({{UPPER_SNAKE}}) instead, so prose about
the syntax is not a false positive, and rewores the template comment to
keep it clear of the guard either way.

Also whitelists the post-deploy `doctor` invocation in the scoped
sudoers drop-in. It runs as the service account rather than root -- the
point is to prove the credentials the service will actually use can
reach and write to the bucket -- and `sudo -u rustingface` was not
covered by any existing rule, so the step would have been refused.
Verified with `sudo -l -U gitea_ci`: the rule is listed, and `sudo -u
rustingface /bin/bash` remains denied.

Found by auditing every deploy step against the installed whitelist
after run 7 failed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XZG2i4AmfSqE97EJGBVb64
2026-08-31 11:30:50 +03:00
a27da00b8f ci(deploy): don't let the journal step bury the real failure
Some checks failed
deploy / build (push) Successful in 6m24s
deploy / deploy (push) Failing after 8s
The journal capture runs `if: always()` so a failed service start still
leaves a usable record. When the job failed *before* authenticating --
the missing-secrets preflight, for instance -- it ran anyway, failed
with "Host key verification failed", and that became the last error in
the log. The actual cause was twenty lines up and easy to miss.

Conditions it on the authenticate step having succeeded, so it still
captures every case it was added for and stays quiet for the ones it
cannot help with.

Observed on run 6: the preflight correctly reported all three missing
secrets, then the journal step overwrote that with an SSH error.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XZG2i4AmfSqE97EJGBVb64
2026-08-31 11:18:47 +03:00
ae3a4636af ci(deploy): name the missing secret instead of failing inside ssh
Some checks failed
deploy / build (push) Successful in 6m29s
deploy / deploy (push) Failing after 7s
A missing RSYNC_SSH_KEY first surfaced as ssh failing to parse an empty
identity file, which names neither the secret nor where to set it. Adds
a preflight that lists exactly which secrets are absent, and notes that
HF_TOKEN is optional and only needed for gated repositories.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XZG2i4AmfSqE97EJGBVb64
2026-08-31 11:05:36 +03:00
0aa27dfb25 fix(infra-setup): don't report a cert mismatch mid-reload
The post-reload check compares the certificate on the wire against the
one on disk, because nginx keeps its previous cycle -- and its previous
certificates -- when a reload cannot rebind, while still reporting
success. That check is right and it fired on the first run of this
script.

It was a false positive. Old workers finish their in-flight connections
before exiting, so for a second or two after a reload either cycle may
answer, and the check sampled the old one. A warning that cries wolf on
every first install is worse than no warning, because the real thing
gets ignored.

Retries for up to twenty seconds before reporting a mismatch. Verified
against hanzalova: the first run warned, the serials matched moments
later, and a re-run reports the match immediately.

Also makes the caveman and DNS steps report state rather than always
printing instructions, now that both are provisioned, and points the
bucket guidance at architecture/object-storage.md §3 -- including why
the script refuses to read the MinIO root credentials itself.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XZG2i4AmfSqE97EJGBVb64
2026-08-31 11:05:17 +03:00
8578ca6e0d docs: readme and agent notes
Some checks failed
deploy / build (push) Successful in 6m27s
deploy / deploy (push) Has been cancelled
Covers what the guarantees mean in practice, the two deliberate
departures from architecture/generic.md §1, how to run it locally, the
deployment topology, and how to run both test suites.

CLAUDE.md records the invariants that are easy to break silently --
header replay, redirect handling, write ordering, and completing a
response before its manifest write lands -- since each of those was a
real bug rather than a hypothetical one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XZG2i4AmfSqE97EJGBVb64
2026-08-31 10:58:54 +03:00
aafe132779 fix(core): hold the last chunk until the blob and manifest are durable
Some checks failed
deploy / build (push) Waiting to run
deploy / deploy (push) Has been cancelled
Guarantee 1 is "durably stored in the configured bucket before the
response completes". The tee sent every chunk straight through, so the
client saw the last byte before the multipart upload was completed and
well before the manifest referenced it. A client that read a file and
immediately failed over to a sealed instance could be told the file had
never been seen -- and if either storage phase failed, it had already
received a clean 200 over bytes nothing recorded.

The tee now runs one chunk behind and releases the final chunk only
after the upload completes and the manifest write lands. Content-Length
is already on the wire, so a client cannot treat the transfer as
complete without those bytes; a storage or manifest failure now ends the
body short instead, which is what the client detects and retries on.

Costs one chunk of extra latency at the end of a transfer and nothing
else: the withheld chunk is bounded by the upstream chunk size, and a
disconnected client still has its transfer finished without it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XZG2i4AmfSqE97EJGBVb64
2026-08-31 10:52:40 +03:00
d461d466c2 fix(core): stop concurrent manifest writes from losing a served file
Some checks failed
deploy / build (push) Waiting to run
deploy / deploy (push) Has been cancelled
A snapshot_download fans out across a repo's files, so several transfers
finish at once and each rewrites the same manifest document. The
read-merge-write had no conditional put behind it: two writers that read
the same document both wrote their own version, and the second silently
dropped the first's entry. The verify-after-write retry caught most of
that, but the write happens in the detached transfer task -- after the
client already has its bytes -- so a lost entry surfaced only later, as a
sealed instance claiming never to have seen a file it had served. That
breaks the fetch-once retention guarantee.

Adds a compare-and-set pair to the Store port (get_versioned /
put_if_version, conditional on the etag last read) and uses it for
manifest updates, so a racing write is refused and retried rather than
merged away. The local filesystem store cannot do conditional updates,
so it reports supports_compare_and_set() = false and the caller keeps
the verify-after-write path for it.

Also serialises manifest writes per key within the process. CAS alone is
correct but turns eight parallel workers into eight writers contending
for one document, burning the retry budget on self-inflicted collisions;
the lock makes the common case uncontended and leaves the retry loop for
genuinely concurrent writers -- another instance, or the CLI against the
same bucket.

Regression test fetches twelve files of one repo concurrently and
asserts every one survives into the manifest and replays when sealed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XZG2i4AmfSqE97EJGBVb64
2026-08-31 10:51:16 +03:00
1fe721e651 fix(data): record the Hub's headers, not the CDN's, across a redirect
Some checks failed
deploy / build (push) Waiting to run
deploy / deploy (push) Has been cancelled
The Hub answers a resolve request with a redirect: 307 to its own
resolve-cache for an ordinary file, 302 to a CDN for an LFS-backed one.
X-Repo-Commit, X-Linked-Etag and X-Linked-Size ride on that redirect
response; the CDN it points at carries none of them and sets an ETag of
its own.

reqwest was following the redirect itself, so head_of() only ever saw
the final hop. Every recorded etag was the CDN's, X-Linked-Etag and
X-Linked-Size were lost, and no file was ever recognised as LFS -- which
also meant expected_sha256() never fired, so nothing was verified
against upstream's oid and every transfer took the staging path instead
of writing straight to its content-addressed key.

Follow redirects by hand instead, absorbing replayable metadata only
while still on the Hub's origin and taking the content length from
whichever hop actually serves the bytes. The operator's upstream token
is dropped when a redirect crosses origins, since a presigned CDN URL
needs no authorization of ours and forwarding one leaks a credential.

Also derives the lfs flag from X-Linked-Size rather than the presence of
X-Linked-Etag: the Hub sends a linked etag for every file (a git blob
SHA-1 for ordinary ones), so the etag alone marked everything as LFS.

Found by running the conformance suite against the MinIO on caveman.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XZG2i4AmfSqE97EJGBVb64
2026-08-31 10:49:39 +03:00
cb1ca8b6af feat(rustingface): implement the registry, admin CLI and deployment
Some checks failed
deploy / build (push) Waiting to run
deploy / deploy (push) Has been cancelled
Scaffolds the workspace per architecture/generic.md §1 and implements
phases 0-3 of doc/spec.md §12.

Crates:
  entities  manifest/ref/repo schemas, bucket key layout, config, the
            X-Error-Code taxonomy. No I/O.
  core      resolver, freeze pinning, single-flight, the streaming tee,
            policy, gc/verify/refresh. Defines the Store and Upstream
            ports.
  data      object_store (S3 + local) and reqwest Hub adapters.
  api       the axum surface: resolve, model/dataset info, tree, refs,
            whoami, metrics, bearer auth, range handling.
  bin       one binary: serve plus fetch/pin/refresh/list/show/rm/gc/
            verify/doctor.

Deployment targets bob.hanzalova.internal:20482 (port derived per
architecture/port-allocations.md §3), storing to the MinIO on
caveman.kosherinata.internal, fronted by hanzalova at rf.internal.
Ships the sysusers drop-in, hardened unit, firewalld service, nginx
vhost, config template, infra-setup.sh and the Gitea Actions
ci/deploy/conformance workflows.

Testing: 112 unit and integration tests, including the sovereignty
suite (cold fetch, sealed replay, single-flight, client disconnect,
range resume, freeze stability, gc-after-rm, digest mismatch), plus a
conformance suite driving a pinned huggingface_hub against a real Hub.

Deviations from the spec, all deliberate:
  - one binary with subcommands (spec §9) rather than generic.md's
    separate -api and -cli binaries; the library split is unchanged.
  - a dedicated sysusers account and hardened unit (generic.md §8)
    rather than the spec's illustrative DynamicUser unit.
  - manifests carry an optional repo_tree recorded verbatim, resolving
    spec §13's "record whole, filter on read" question for the tree
    endpoint as well as model-info.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XZG2i4AmfSqE97EJGBVb64
2026-08-31 10:46:25 +03:00
e12457cf27 doc: design specification 2026-08-31 09:47:23 +03:00