Two root folders built from a BQ2 seed, so that no agent OCX runs shares a
sandbox with the repositories: the public server, which runs Fossil and the
CGI programs and starts no agent, and the client, which runs the ocx2 daemon
and reaches the server only over HTTPS. Both start with serve, and carry the
same hosts.json; the hostname each is started with picks its
entry. The server keeps the machine's name. The client is x86-1.
Repositories, certificates, Git mirrors, passwords and sign-ins are data, not build output: no build puts them in a folder.
Build
Each input is checked against its pin, and everything compiles inside the seed with no network. A step that finished is not repeated.
crystal run build-rootfs.cr -- server OUTPUT BQ2 DOWNLOADS
crystal run build-rootfs.cr -- client OUTPUT BQ2 DOWNLOADS
OUTPUT is the new folder, BQ2 a bq2 built from the seed's own version,
and DOWNLOADS where inputs are kept. It must already hold clones, made over
HTTPS, named ollama.fossil, gp-crystal.fossil and crystal-robots.fossil.
Compare two builds, by kind, mode, link target and file hash:
crystal run compare-rootfs.cr -- FOLDER FOLDER
Start
The server:
bq2 sysroot-namespace --rootfs=SERVER --bind=REPOSITORIES:/var/lib/fossil --bind=MIRRORS:/var/lib/ollama-codex/git-mirror -- /usr/local/libexec/ocx/serve
The client holds its name in its /etc/hostname. A folder can hold the name but
not set it: the hostname belongs to the kernel, and the machine's cannot be
changed from inside. So the start command makes a hostname space of its own and
applies the name there:
bq2 sysroot-namespace --rootfs=CLIENT -- /usr/bin/unshare --uts /bin/sh -c 'hostname -F /etc/hostname && exec /usr/local/libexec/ocx/serve'
The client's daemon ends when the server cannot be reached, and nothing
restarts it (failures, item 20). Running
sessions go on. Start it again with confine daemon in place of serve
above, not by restarting a service whose cgroup holds the sessions.
Test
test-rootfs.cr runs both folders together on copies of
repositories, in a sealed network and process list, with the real settings
and certificate. It checks each page against pages.txt, a push,
an approved login, a run sent from the server and reported back from the
client, and a session across a server restart. Then it rehearses the
release route on two stage runs, as "What shows it works", below, says.
The rehearsal, on the host
A Claude session on the host, outside both root folders, can run it; the maintainer only starts that session. Nothing here touches the live server or client: every write is to copies.
- Two stage runs. From a session on the branch, send
the stage pattern for two pushed check-ins, each
--ofthe check-in's full 64-digit hash (fossil infoprints only 40, and import then refuses the run). Both must be at or after 0388e725f3, whose build does not hold CI's pace limit; earlier ones crossed it on x86-1. Wait for both runs'ended, exit 0. Their shared files stay on the server until someone imports or removes them, so the copies below hold them. - The paths.
ps -eo args | grep [s]ysroot-namespaceshows the two start commands of "Start", above: the program beforesysroot-namespaceis BQ2, the server's--rootfsis SERVER and its two--bindsources are the repositories and the git exports; the other--rootfsis CLIENT. - The copies, into a new folder COPY on the same disk:
cp -a SERVER COPY/server,cp -a CLIENT COPY/client,cp -a MIRRORS COPY/data/mirror, and each repository withsqlite3 REPOSITORIES/NAME.fossil ".backup COPY/data/repos/NAME.fossil", so a repository being written is still copied whole. The run, from a checkout of the branch, in
ocx-rootfs/. The maintainer has no sudo, so the namespace maps the account to root:crystal build test-rootfs.cr -o COPY/test-rootfs unshare --map-root-user --net --pid --fork --mount --mount-proc COPY/test-rootfs COPY/server COPY/client BQ2 COPY/data COPY/work FIRST NEXT
FIRST and NEXT are the two stage runs' full hashes, FIRST the older
check-in. It prints one line for each check, and ends with ALL PASSED
or the checks that failed. Post that output on ticket
8986816c0b.
- Afterwards, remove COPY. The stage runs' shared files stay on the live server until the real import.
It fails one check on copies of today's repositories: a push is not exported
when Fossil's background process follows a request under /ext. That process
holds the CGI environment, so %F git export runs Fossil as a CGI, which
answers "no such file: git". With the hook %F git export --nocgi --if-mirrored -R %R,
that check passes.
Releasing from clients to servers (planned, not built)
Planned on ticket 8986816c0b, for the maintainer's approval, and revised after Codex's review there. Today a release moves from where it is built to where it runs only by hand. The aim: a client builds each next ordinary release of OCX, the public server takes it, tests it, accepts it and can roll it back, and the client then runs what the server accepted.
The first round is OCX alone, on x86_64: the server and x86-1. Other
projects, and aarch64, come after that round trip works (below).
What a project says it releases
A project opts in through its ci.cr. One that says nothing gets nothing.
crystal run ci.crruns the tests, as now, and publishes nothing.crystal run ci.cr -- programsprints, as JSON, the programs it can build: each one's name, source file, whether it is a CGI program or a tool, and the architectures it supports.crystal run ci.cr -- build OUT ARCHruns the same tests and, only if every one passes, builds those programs forARCHinto an emptyOUT, withrelease.txt, each file's SHA-256, andgate.txt, the tests' result. It writes nothing intoOUTwhen a test fails.
ci.cr never publishes. The project does not say where it runs: when a
project is added on a server, project setup asks whether that server serves
its CGI.
Who counts as a builder
A release counts only from a build that ran the known stage pattern, on a clone the server trusts to build:
- The release root's
settings/builders.json, which only its owner writes, lists each builder: the clone's login that acts on stage runs, such asx86-1, its clone's server-code, and its architecture. - It also names the stage pattern by its SHA3-256. A stage run counts only if its content is that pattern's, with only the check-in filled in. A run sent to a builder that copies in some other program and exits 0 is then not a stage run, and stages nothing.
This trusts the reviewed source and the builder's host; no hash can show that hostile source ran honest tests.
The steps
A person runs each step after the build, and can run one again or fix a link by hand if it is cut short; the steps add nothing to make that automatic.
- A client builds. A stage run, sent to a builder, runs
ci.cr -- buildon its check-in in its sandbox, which holds no password, and names the built files inunversioned_filesasreleases/$$run$$/FILE. The builder's watcher postsended, recording each file's SHA-256 inoutputs, then sends the files as the builder's login, which holdsy. - A person imports it. A person with setup runs
import RUN, RUN the stage run named where itsendedwas posted, on the page (a POST) or at the command line. The files are in the server's own repository, so it reads them there, with no sync. It counts the release only when, by its receipts and never by tags or user cards: the run is the stage pattern, compared field by field, addressed to a listed builder and its clone, sent by another user allowed to send it; the builder's first countedstartednames the check-in; its first countedendedfor that run, in its thread, isexitedwith exit 0; and every output is present and matches its hash. Otherwise it refuses and changes nothing. An unwanted release's shared files are removed by hand. - The server tests it. Import copies the files into its own
staged/NAME, keeping the run and hashes it counted beside them, runs each program with no arguments, as staging does today, requiring its usage line, offers it asnexton/ext/ocx2-cgi-next, and removes the shared files. Only import writes the server'sstaged/; the server starts no run. The person tries the release there. - The server accepts it, as it does today: accept copies
nextinto the server's root and movescurrent, keeping the one before asprevious; rolling back moves it back. The release tools take one lock, so two never change the root at once. - The server serves it. The server's page
/ext/ocx2-cgi/releaseanswers what iscurrentandprevious: each one's check-in and each file with its SHA-256, and serves those files by name, over HTTPS, to a signed-in builder or setup login.
The client follows
A person on the client runs ocx2 follow SERVER --root ROOT, inside the
client's root, as the root's owner. It asks the server's /release what is
current. If that is not the client's own current, it downloads each file
it lacks from the server by name, checks every hash, puts the release in its
root, and moves current, keeping the one before as previous. A rollback
is the same: the server names its previous, and the client takes that
release, from its own root if it has it. The daemon ends when current
moves, and the person starts it again with confine daemon, as under
"Start" above. Nothing follows on a timer.
When the server cannot be reached, the client keeps what it runs; to move
it back meanwhile, a person links its current to its previous by hand.
The client downloads only its own architecture's files, once per release,
over a plain HTTPS request; it needs no fossil uv sync, which today sends
every shared file a clone lacks, even for unversioned_get of one file.
Before the first ordinary release
Neither root's installed release can import, serve /release or follow,
and the client's installed confine locks ROOT/node/daemon.lock where
this tree locks ROOT/state/daemon.lock. So the first release that can is
installed once by hand on both roots, with
install-first.cr, on the host, outside both roots, as
their owner, in the maintainer's checkout (decision 22):
crystal build ocx-rootfs/install-first.cr -o install-first, then./install-first SERVER CLIENT REPOSITORIES RUN, the folders as the start commands under "Start" name them, RUN the full hash of an ended stage run. It gathers the release's files from the server'sollama.fossil, builds the host programs, and says what it would change.- Stop the client's daemon and wait until idle (
idle ROOT, below). - Run it again with
--apply. It refuses while the client's daemon holdsROOT/node/daemon.lock, then removes it; it installs the host programs, keeping each one it replaces asPROGRAM.previous, puts the release in both roots and links itcurrent, the one beforeprevious, and writes the server'ssettings/builders.json. - Restart the server if
servewas replaced, and start the client's daemon.
The way back: link current to previous on both roots and rename each
PROGRAM.previous back. test-rootfs.cr rehearses this,
and the ordinary release after it, on copies, when a rehearsal is wanted.
Other projects, after the first round
Then other projects move to the same steps, and build-rootfs.cr stops
building theirs (gp-crystal, crystal-robots, swk-ai), each project's
first install tested as OCX's was. serve and confine do not change,
except that confine learns once to run a project's CGI program named by an
entry in hosts.json, from that project's current, in a sandbox with no
access to the repository file. swk-ai's page reads its repository file and
writes runs and attachments with it, so it moves unsandboxed, as it runs
today, its entry stating that exception, and is sandboxed once its page
works through Fossil's web pages and a clone of its own; that change is
swk-ai's, on a ticket of its own. aarch64 comes with them, for interactor
and social-badge on a BeagleBadge. A server accepts once its own
architecture's build passes; a build of another architecture for the same
check-in is accepted on its own, and /release lists each architecture's
files with the run that built them. Clients compare by file hashes, never by
check-in alone.
What shows it works
test-rootfs.cr, given two stage runs x86-1 built (FIRST and NEXT), first
installs FIRST by hand on both roots with this checkout's host programs, as
"Before the first ordinary release"
says, and writes the server's settings/builders.json. Then it shows that
import refuses a report from a login not listed, a run that is not the stage
pattern, and a changed file, changing nothing; that import offers NEXT and
removes its shared files; that the server accepts it and its page lists NEXT
as current and FIRST as previous; that follow moves the client to NEXT,
its daemon ends, and once started again exactly one daemon runs, on NEXT;
and that both roll back to FIRST the same way. It does not show a client
that missed a release following straight to the current one, which needs a
third release.
Moving sessions to protocol version 2
migrate-sessions.cr carries each open session across
the switch, by its own handoff on the forum. It reads, writes and prints no
password.
- On the client, before the switch,
crystal run migrate-sessions.cr -- list > sessions.jsonlists each run a watcher holds, from the watchers' command lines and their clones, with each acting clone's login (its default user) and server-code. - On the server,
crystal run migrate-sessions.cr -- ask sessions.json /var/lib/fossil LOGIN MINUTES > handoffs.jsonreplies to each session's run, asLOGIN, asking it to commit, push and answer with a handoff by the deadline. It stops each session that answered. One that did not answer, and a run that is not a session, is left running. It names the request, the handoff and the stop by their full hashes. - After the switch,
crystal run migrate-sessions.cr -- links handoffs.json PATTERNprints a link to version 2's create page for each session that handed off and was stopped: a session fromPATTERN, replying to its handoff, addressed to the acting clone'sloginandclone(its server-code), as the same process user, whose password the link resets for the new clone to collect. A run that ended lost after its stop was posted still counts as stopped. Each session it gives no link names what it lacks.
Bringing a root to rest, and clearing it
tidy-root.cr runs on a host, inside its namespaces, as the
root's own account. Without --apply it changes nothing and says what it
would do.
idle ROOT [MINUTES]names what still runs: the daemon, each watcher and its run, each run whose state is notdone, and eachfossilprocess other than a server. It exits 0 only when there is none, waiting up toMINUTESfor that. Stopping the daemon (kill PID) starts no new run and leaves the watchers to end theirs.to-v2 ROOTandto-v1 ROOTmove the folders betweenROOT/node/andROOT/settings/withROOT/state/, only when the root is idle and no target is taken. The passwords files andunconfinedare the owner's, insettings/; the clones, their completions and the logs are instate/; each version's runs go aside, toROOT/v1-runs/orROOT/v2-runs/, and come back on the way back.sweep ROOT [--archives]removes what no release serves: accepted releases neither current nor previous and run by no process, staged releases other thannext, copies a failed accept left, and run workspaces whose checkout and clone hold nothing the server lacks, as a watcher judges it; with--archives, the other version's runs too. It names anything else it finds, and leaves it.
The switch, with both, on each machine in turn:
- Hand off the sessions (above, steps 1 and 2).
- Client: stop the daemon, then
idle ROOT 60until the last task ends. - Server, then client: accept the version 2 release; on the client,
to-v2 ROOT --apply, then start the daemon. - Publish the patterns in version 2's words:
run-claude.mdandrun-codex.mdas the wiki pagesrun/claudeandrun/codex, written as a user holding admin or setup;session.md,codex-task.mdandstage.mdas attachments of a tech-note, each then named by its content-addressed raw URL.session.mdnamescodex-task.mdby that URL, so publish them as they are. - Start the sessions again from their links (above, step 3), giving
session.md's URL asPATTERN.
pattern-v2.cr rewrites a version 1 pattern's keys and
values in version 2's words, keeping its prose, and lists each line still
naming version 1's words for a person to rewrite:
crystal run pattern-v2.cr -- PATTERN.md > PATTERN-v2.md. The patterns in
../patterns/ were made with it, then their prose rewritten; each passes
the branch's create --dry-run, which refuses a version 1 key.
- Once both run well:
sweep ROOT --applyon each, and latersweep ROOT --apply --archives.
The way back is the same in reverse: stop the daemon, idle, rollback,
to-v1 ROOT --apply, start the daemon.
What it leaves out
- Old trunk's programs: chat, docs, agent, preview and
ollama-codexare not built.ocxis not either: a logged-in visit to it starts the old agent daemon. Each answers 404. - Certificate renewal: the seed has no certbot.
- aarch64: these pins are x86_64's. An aarch64 seed exists but comes from another check-in.
- Fossil's checkout wait: the seed's Fossil opens no
.fslckoutforfossil sql --no-repository, so OCX's wait in a checkout waits on nothing (docs/fossil.md, ticket fa798aee06). OCX pins the seed's Crystal, 1.19.1.