Skip to content

Worker split - #4

Merged
huelab-byte merged 30 commits into
mainfrom
worker-split
Sep 6, 2026
Merged

huelab-byte merged 30 commits into
mainfrom
worker-split

Conversation

@huelab-byte

Copy link
Copy Markdown
Collaborator

No description provided.

Shakhawat and others added 30 commits September 5, 2026 19:21
Six fixes the console needs before any work leaves this process.

Migrations take a session-level advisory lock on the connection running
them. Two API workers booting together both read `migrations`, both saw
the same migration as un-applied, and the second died on the primary key
at boot. With one replica that never happened, so the race was invisible.

Whisper stops returning "" for six different situations. Switched off,
not installed, timed out, crashed, wrote no file and wrote an empty one
were one silent empty string, and the source was marked ready with no
transcript and nothing said. They are six sentences now, and a timeout
says a shorter clip will finish rather than sending anyone to reinstall.

Ollama is probed at the address it will actually be used at. `ollamaUp`
hardcoded localhost while `viaOllama` read the `ollama-url` secret, so an
Ollama on another box was reported unavailable and refused before the
transport that knew where to find it was ever reached.

Runs record who started them. `created_by`, taken from the session and
never from the body, because a caller who can name the author can name
somebody else.

`CONTENTOS_SECRET` is required in production. The DATABASE_URL fallback
is fine on a laptop and a trap on a server: rotate the password and every
stored key decrypts to nothing, which reads as "the console lost my keys".

`siteCache` is bounded at 200. It held full page text for the life of the
process.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The heavy work moves off the machine that answers HTTP. A hosted server
keeps the UI, the API, Postgres and the frames, and keeps every decision;
a Node worker inside a Tauri app on each teammate's machine claims jobs
over HTTPS and runs the local tools under that person's own login.

Nothing here is built. It is written down first because the interesting
parts are not the code: the claim query, what happens to a job nobody can
run, and which of today's global settings stop being global.

Six questions the design raised are answered in it. The API-key fallback
never fires on its own — a run costing a subscription somebody already
pays for is the whole point, and money spent when nobody is watching is
the failure DEPLOYING.md warns about. Any worker may serve any workspace,
because there are no roles, but the column that would narrow it exists
now so the day it matters is an UPDATE rather than a migration under
pressure. Cancellation ships, because a job waiting on a laptop that is
shut has no other way to stop. Jobs are kept and their prompts are not.
The desktop app holds two credentials on purpose. And transcription
splits into its own job over a 16 kHz WAV, which is a thirtieth of the
video, wants the GPU that is on a desktop rather than the VPS, and is
what whisper.cpp — the only whisper a non-developer can install in one
click — takes as input anyway.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Migration 0015 adds `workers` and `jobs`, the two tables the split needs,
plus `run_sections.wrote_with` and `workspaces.api_fallback` from the
decisions in docs/WORKER.md. It also sweeps `writing` rows back to
`queued` once. That sweep is the last one: on a console where work
outlives the process that started it, doing this at boot stops being a
repair and becomes theft of a laptop's in-flight section.

`repos/jobs.ts` is the queue. Claiming is `FOR UPDATE SKIP LOCKED`, which
is what lets four machines run the same query in the same millisecond and
get four disjoint sets of rows. Three things in the design did not survive
being written:

`bool_and` over zero rows is NULL, not true, so the capability test as
designed made every job with empty `needs` — a brain test, an ingest that
asks for nothing — invisible to every worker forever. It is COALESCEd.

Failing everything at `attempts >= max_attempts` in the reaper's single
pass would have killed a machine three minutes into its final attempt.
The passes are ordered instead: expired leases back to the queue first,
then only queued rows are failed.

The payload sweep had no way to tell a job it had already emptied from one
whose payload was genuinely `{}`, so it rewrote every historical row every
fifteen seconds. `swept_at` and a partial index make it touch each row
once.

`sourceBrief()` stops telling the model the frames are under
`.data/sources/<id>/`. On a worker that is false — they are in a temp
directory on another machine — and it handed the server's layout to a
model running on somebody's desktop. Saying how many stills there are and
at what seconds is the part that was ever useful.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Something in the last commit switched api/tsconfig.json from commonjs to
nodenext on the reasoning that NestJS 12 is ESM-only. Nothing asked for it
and nothing needed it: `npm run api:typecheck` is clean on either setting,
before that change and after reverting it.

Module resolution decides how every import in the API resolves at build
time. It is not a thing to change as a side effect of adding two repos,
and a build that already passes is not evidence for a build-config edit
nobody can point at a failure for.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Somewhere in the last two commits the API's `@/` resolution was replaced
wholesale: tsconfig went to nodenext and lost its baseUrl, a new
register-paths.cjs took over from tsconfig-paths, and package.json and
the Dockerfile were rewired to match. It was internally consistent, which
is why it passed a typecheck and why the previous commit — which claimed
to put module resolution back — actually committed the rest of it.

It goes because none of it holds up. Its stated reason is that TypeScript
7 removes `baseUrl`; this repo is on 5.9.3. `npm run api:typecheck` is
clean before the change and after reverting it, so no failure was being
fixed. register-paths.cjs is `require()`-based and fails the repo's own
eslint rule, which is how it was noticed at all. And it rewrote the
Dockerfile's CMD — the line that starts the API in production — as a side
effect of adding two repo modules.

All four files are back at the state they shipped in. `npm run check` is
clean: typecheck, lint, 36 tests, api typecheck.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`driveRun` was a `for(;;)` that held a process for the length of a run,
awaiting a model that was its own child. It becomes `advance(runId)`:
find every section whose dependencies are written, enqueue a job for each,
return. It runs when Run is pressed and again on every result, so nothing
is lost when a process restarts. `plan()` — exported since the packs
landed and called by nothing — is the scheduler now, so a whole dependency
wave is enqueued at once; one-at-a-time was a property of the model being
one local process, not of the templates.

`isDriving`, the globalThis Set, and `resumeOrphans` are gone.
`requeueOrphans` lost its no-argument form, so the global boot sweep is no
longer a thing the code can express, not merely a thing nothing calls.

The API grows six worker endpoints behind a bearer token and a
`WorkerGuard`: register, claim (long-polled 25s), heartbeat, result, fail,
and frame upload. A worker token satisfies those and nothing else — a
session does not satisfy them either.

Three gaps both agents found and neither could reach across their own file
boundaries, fixed here:

A job that finishes failing now marks its section. `fail()` either
requeues for another attempt or ends the job, and only the ending is news
to the run — but nothing was telling the run, so a terminally failed job
left the row on `writing` with an empty queue behind it. That is the
"Writing for five days" row this design exists to prevent, and both the
worker's fail route and the reaper could produce it.

Frames get their own worker-token route rather than widening the browser's.
A `write_section` payload names frames by that path. Making the existing
session route accept either credential would have let a stolen worker
token walk the content library through the same handler.

`FRAME_FILE` is exported from services/ingest.ts instead of being restated
in the worker controller. It guards a network write now, and two copies of
a security regex is one copy that gets updated.

.vscode/settings.json pins the workspace TypeScript. An agent read the
editor's newer bundled compiler deprecating `baseUrl` as a real build
error and rewrote the API's module resolution over it; this is the note
that stops the next one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`tests/queue.test.ts` covers the claim query and the reaper against a real
Postgres. It is the only database test and the rule it breaks is argued in
its own header and in DECISIONS.md rather than quietly bent.

The claim query is not a pure function. It decides, across machines that
cannot see each other, who writes which section, and it fails silently in
both directions: two laptops writing the same section spend two
subscriptions on one answer, and a job no worker can see leaves a run
reading "Writing" for ever — the same failure `statusOf` is tested for.

Writing it found three defects that had already shipped past a clean
typecheck. `bool_and` over zero rows is NULL rather than true, so a job
needing no tools was invisible to every machine. The reaper ended jobs a
machine was still running. The payload sweep rewrote every historical row
on every fifteen-second tick.

It builds and drops its own database, so it cannot reach the workspace the
dev server is using, and it skips itself when nothing answers on Postgres:
`npm run check` with the container down is still green.

DECISIONS.md also records that "A run survives the API restarting" is
superseded. Its reasoning rests on a `writing` row being dead because the
only thing that could write it was this process — true while the model was
a child of the API, false and inverted once the work runs on a laptop. The
goal stands and is better served: no process holds a run, so it now
survives the API being down entirely, and recovery is a lease expiring on
evidence rather than a boot assuming nothing is running.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Four pieces of the split that had to land together.

**Ingest becomes a job.** `ingest()` enqueues rather than running yt-dlp and
ffmpeg in the API process, and transcription splits off as its own job over
a 16 kHz WAV — a thirtieth of the video, the one step that wants a desktop
GPU, and the input whisper.cpp takes anyway. Uploads stay on the server:
the bytes are already there and posting 400MB back to a laptop to cut eight
stills would double the transfer to learn nothing.

**Three globals stop being global.** `brain` moves to the workspace, beside
`brand_voice`, which is scoped for the same reason — which model writes is
an editorial choice about content, not about hardware. `enabled` and
`cliCanReadFrames` move to the worker, because "yt-dlp is off" is a fact
about one machine and a `Read` grant lands on one filesystem. The console-
wide switch let a teammate grant file access on your desktop.
`workspaces.api_fallback` is honoured: off, the server never spends an API
key on its own.

**The page says where the work is.** A section is queued, waiting on a named
machine that is not switched on, being written, or unroutable because
nothing anywhere has the tool — four states that were one grey QUEUED.
Settings gains Machines: mint a token (shown once, kept as sha256), see what
each machine reported, set the owner's switches, revoke.

Fixed while integrating, none of it reachable from inside one agent's file
boundary:

The reaper only told `write_section` jobs' sections about a failure, so a
reaped `ingest_source` left its source on `fetching` with an empty queue —
the same dead row, on the path a person meets most, because "nobody opened
the laptop with yt-dlp" is an ordinary Tuesday.

`allowFrameRead` travels in the write request instead of being read from a
console-wide setting, and the researcher reads its brain off the source's
workspace.

Three React rules: `Date.now()` during render, and two effects that cleared
state a render later than they should. Both cleared values are derived now,
so a stale run is never handed back for the render in between.

`tests/queue.test.ts` compares against MIGRATIONS rather than naming the
last one — it caught 0016 the same hour it was written, which is a passing
test failing on correct work.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`worker/` is a Node process that registers, claims, runs and reports. It is
what makes any of the previous commits do anything: `advance()` enqueues,
and until now nothing claimed.

    CONTENTOS_API_URL=… CONTENTOS_WORKER_TOKEN=… npm run worker

No build step and no new dependency — Node 24 runs the TypeScript. The same
code Tauri will bundle as a sidecar later, runnable today.

Verified end to end against a throwaway database and API, not by reading it:
the worker registered (seven tools probed off this machine), claimed the
job, ran it in 30ms, and posted a result naming the address it actually
tried. Each job runs in its own process, because `run()` in tools.ts keeps
its child handle private and a `drop` on the heartbeat has to be able to
kill work nobody wants any more.

`GET /runs/:id/jobs` replaces `GET /runs/:id/start`, which was the same
query reduced to a boolean. The screen now reads `state === "unroutable"`
instead of matching a server sentence with a regex, and can finally say
which machine is writing a section. The response is an allowlist, so a
column added to `jobs` later does not start reaching browsers by default —
and the 6-8k prompt is not shipped every 1.5 seconds.

`advance()` pins a job to the machine it is waiting for when exactly one
could take it, which is what makes that name reachable. Not when two could:
pinning would leave a job waiting on one laptop while the other came online,
and the queue's whole job is to hand work to whoever asks first.

Re-registration stops undoing the owner's switches. A worker re-reports
every few minutes, and taking its `enabled` wholesale meant a tool switched
off in Settings → Machines switched itself back on within the tick. Keeping
the stored value wholesale is the opposite failure: install whisper and it
is reported, listed, and never used. So the owner's set is kept and only
ids this report knows about that the last one did not are added. Tested.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`POST /api/sources` stopped being slow and started being a queue, and two
screens still read its answer as the finished article — so every link gave
an empty frame picker. `fetchSourceToEnd` follows the row instead, from one
place, and both screens say what is actually happening: on a machine, or
waiting for one that is shut, or failed because nothing anywhere has yt-dlp.
Uploads are not polled — those bytes never left the server. A transcript
still coming is a note, not an error; the researcher reads the frames either
way.

Run is refused while a fetch is in flight. `sourceId` was `source?.id`, so
pressing it mid-fetch quietly started the whole template against no reel at
all.

`worker/brains.ts` held verbatim copies of the four CLI transports — the
most bug-prone code here, whose comments are the record of what it took to
get each flag right. They now live in `lib/server/brain-transports.ts` with
the table in `lib/server/brain-defs.ts`, imported by both. Neither reaches a
repo or a database, checked by walking the worker's import graph and by
loading it under the real resolve hook. The API transports stayed behind in
`brains.ts` on purpose: that file is one import from a worker, and a
key-spending path should not be within reach of a desktop.

Smaller things found while integrating:

`isRunning` only asked the queue, so an API-fallback section — which has no
job row by design — read as "not running" while the server was four minutes
into writing it, and the page dropped to its settled cadence.

`PUT /api/workers/sources/:id/transcript` is gone. Nothing called it, and it
was the one write on that controller with no ownership test: any valid
worker token could overwrite any source's transcript without holding a job
for it.

The document page follows a source that has not landed yet, instead of
holding the version from before the machine filled it in.

Docs caught up with the code. DEPLOYING said a server needs an API key and
that a run is a loop inside the API; it now says enrol a machine, and how.
The section offering "put only the database online" to dodge the API bill is
gone — the worker split is that idea done properly, without handing everyone
a URL that can drop every table. README said runs are driven from the
browser. Two more DECISIONS entries are marked superseded, including the one
this design is built on: driving from the server is what made moving it off
the server a change of address rather than a rewrite.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`npm run worker:setup` fetches yt-dlp, ffmpeg and whisper.cpp with a model
into `.data/tools`. Pinned versions, a sha256 on every file, the whole plan
printed before anything is fetched, and nothing written outside that folder
— no PATH, no registry, no admin. Uninstalling is deleting a directory.
Probes resolve that folder before PATH, so an installed tool is found
without anything being added to the system.

whisper.cpp is now preferred over openai-whisper. That is the point of the
exercise: `pip install openai-whisper` is Python plus PyTorch plus a CUDA
story, which is not a thing the team this is for can do, while whisper.cpp
is one executable and a `.bin` — with the GPU path, and taking the 16 kHz
mono WAV the ingest already uploads. A machine with the executable and no
model is not a machine that can transcribe, and says so with the command
that fixes it, because reporting it present would route every transcription
job to it and fail all of them.

Testing it found a real bug in `run()`: only arguments were quoted, never
the command, so a tool under `C:\Users\First Last\…` split in two. Every
path this installer produces has the user's name in it.

`start.sh` stops pip-installing whisper. It did so because whisper stood
between a video and a transcript AND ran in this process; neither half is
true now, and a gigabyte of torch to start a web app is a gigabyte spent on
nothing.

`api/Dockerfile` drops yt-dlp and python3. The server only reaches for
ffmpeg now, and only for a browser upload, whose bytes are already here.

`api:smoke` learned the second principal. Four of its lines now assert that
a session cannot claim jobs and a worker token cannot read the library —
both one decorator away from being wrong, neither visible on any screen. It
also says how many machines are enrolled, because zero is why nothing runs.
Fixed while testing it: the script crashed in libuv after printing "all
good", because `process.exit()` raced fetch's open sockets — a script whose
job is to end a deploy with a truthful status was reporting a false one.

Docs caught up. ARCHITECTURE has the second data path, the second principal,
`worker/`, and the tables that were missing. AGENTS gains the rule about
what `worker/` may import. DEVELOPING gains the section whose absence would
have cost somebody an afternoon: a run needs a worker, and `start.sh` does
not start one. WORKER's protocol table stops advertising a route that was
deleted for having no ownership check. Two comment counts corrected —
"six endpoints" is nine, "ten controllers" is thirteen.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`src-tauri/` is the desktop app: a webview onto the hosted console plus the
worker running beside it. It is not a port of the frontend — the UI is
served from the team's server, and duplicating it here would mean shipping a
release to fifteen desktops to change a prompt.

It compiles and runs. `cargo build` produces contentos-desktop.exe; first
run shows a setup window, a configured run opens the console and spawns the
worker, and closing the app kills the child rather than leaving node behind.

The token goes in the Windows Credential Manager, not a file. The console
keeps only its sha256 so a database dump is not a list of live credentials,
and writing the plaintext into %APPDATA% would undo that at the other end —
anything running as that user reads it, and the first profile backup carries
it off the machine. The console URL is not a secret and is a plain file. The
webview never sees the token, only whether one is set.

**A real bug in the worker, found by running it under the app.** `sleep()`
unref'd its timer, and before the first successful registration nothing else
holds Node's event loop open — no heartbeat, no re-register interval yet. So
a worker that could not reach the console printed "Trying again in 10s" and
exited 0 on the spot, having never tried again. The one case it broke is the
ordinary one: a laptop opened before its wifi is up. I had seen this myself
earlier and mis-read it as my own `timeout` killing the process. Now three
retries in twenty-six seconds, measured.

Also recorded: the worker split gets its own entry in DECISIONS.md. That
file held several decisions marked superseded BY the split and no positive
statement OF it, so a reader saw only crossings-out. The entry names the
three parts worth protecting — the server decides and the worker executes,
coordination lives in the database rather than a process, and a job that
cannot run must reach a terminal state visibly.

eslint stops walking into src-tauri/target, where Tauri generates a JS shim
for the webview.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`cargo build` makes an executable; it does not make the thing a teammate
double-clicks. `npx tauri build` does, and it needs this.

Left out until now for a reason worth keeping: it rewrites package-lock.json,
and an agent working inside `src-tauri/` had no business doing that as a side
effect. Adding it is a decision, so it is its own commit.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`npm run desktop:installer` → `Content OS_0.1.0_x64-setup.exe`, 25 MiB, with
its own Node 24.20.0 and the worker's files inside it. Verified by reading
the generated NSIS script and by running the exact spawn command against the
bundled tree: the loader hook resolved, the types were stripped, the tool
probe ran, and it reached the offline state — the whole worker path, from a
directory that has no repo above it.

Node is pinned and checksummed the same way `worker/setup.ts` pins yt-dlp and
ffmpeg — one convention for fetching a binary, not two. The 93 MB it downloads
is gitignored; nothing over a megabyte is in this commit.

Spawning the system `node` was right for a developer and wrong for everyone
this app is for. "Install Node first" is the exact sentence the desktop app
exists to delete.

**An installed copy would have failed its first job.** `cliHome()` does a
`mkdirSync` under the working directory when `CONTENTOS_DATA_DIR` is unset,
and an installed app's working directory is inside Program Files, where a
normal account cannot create anything — a failure landing on the machine
where nobody is reading a terminal. The app now hands the worker a data and
tools directory under %APPDATA% when it is installed, and deliberately does
not when it is a checkout, where `.data/` is what `npm run worker` and
`npm run worker:setup` already use.

Signing is wired, not done, and says so seven times per build. `sign.ps1`
takes a real certificate through the environment; with none set it prints
one line per binary saying the file is unsigned. No self-signed certificate
was generated, because a self-signed certificate does nothing about
SmartScreen and calling it signed would be a lie in a commit message.

Auto-update is wired and never exercised. The tray item is built only when
an endpoint is configured, so today it does not appear rather than being a
dead button. What is missing is two config values and a keypair — and the
private half of an update-signing key cannot go in a repo, because anyone
with a clone could then sign an "update" that every installed copy runs.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A worker reports every tool it probed, present or missing, because the
Machines page has to be able to say "whisper is not installed on that
computer". `enabled` answers a different question — what may be USED — and
seeding it from the whole report switched on tools the machine does not
have.

Nothing was misrouted: `routeFor` checks presence before it enqueues. But
the claim query matches `needs` against `enabled` alone, so the guard was
one function thinner than it looked, and the page showed a tool as switched
on that could not run. Found by reading a real registration: this machine
has no codex or gemini and both came back enabled.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Both were considered and both declined: this console is an internal tool for
one team. A certificate costs money and a verified identity every year to
remove a warning five people see once each, and updating is being handed a
new installer, which for five people is a message with a file in it.

Written down because the code still carries both hooks, and without this
entry the next person finds a signing script that signs nothing and an
updater with no endpoint and reads it as work abandoned halfway. It is not.
`sign.ps1` takes a real certificate through the environment; the updater's
tray item is built only when an endpoint is configured, so it does not
appear rather than sitting there doing nothing.

The hooks stay for the day this reaches somebody outside the team — a
client, a contractor, a download page — so that day is configuration rather
than a rewrite. And the entry says plainly not to generate a self-signed
certificate to make the warning look handled: nothing trusts it, the warning
stays, and calling the result "signed" is worse than being plainly unsigned.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`GET /runs/:id/start` answered `jobsForRun(id).some(live)` reduced to a
boolean. `GET /runs/:id/jobs` replaced it — same query, and the rest of what
the page needs from it — and the client moved over. Nothing has called this
since.

Found while auditing for leftovers. A route that answers is a route somebody
will use, and this one gives a thinner answer than the endpoint beside it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`./start.sh` now brings up Postgres, the API, the worker and the web app.
Without a worker, pressing Run queues a job that nothing claims — the page
says so honestly, but a dev server that cannot write a section is not one
anybody can work against.

The token is the one thing the script cannot work out for itself: it is
minted in Settings → Machines and only its sha256 is kept. It comes from
`.env`, the way every other script here learns things. Without one, one line
says how to get it and the console still comes up, because signing in is
where you go to fix it. `WORKER=0 ./start.sh` skips it.

The desktop app is deliberately NOT started. It is a client, not a service —
a teammate installs it and it carries its own worker, so launching it here
would put two workers on one machine both claiming jobs, and open a window
every time somebody wanted to iterate on a screen.

**A machine could rename itself, and did.** `registerWorker` took `name` from
the report, so the worker's next check-in a few minutes later replaced it
with whatever the caller sent. Starting the worker from `start.sh` with a
hostname default renamed "Shakhawat's PC" to "Shifa" within one tick — found
by watching it happen. The name is what a person reads in the picker when
they choose where a run happens; it belongs to whoever typed it in Settings →
Machines. Same rule as `can_read_frames`, `max_concurrency` and `enabled`:
what the console sets, the console keeps. Tested.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The setup window asked every teammate for the console address as free text.
That is a typo waiting to happen and knowledge the person installing should
not need: the team has one console, and whoever built the installer already
knows where it is.

Both addresses now resolve the same three ways — the running environment
first (development), then a value saved on this machine (the escape hatch for
pointing one computer somewhere else), then the value compiled in with
`option_env!`. `build.rs` re-runs on either variable changing, or a rebuild
would ship yesterday's address without saying so.

**Two addresses, because there are two hosts.** DEPLOYING puts the web app on
APP_HOST and the API on API_HOST; the browser reaches the API cross-origin and
there is no `/api` under Next. Handing the worker the console's origin gives
it a host that 404s every request — which is what the first pass did, and it
only looked fine because the dev script sets both by hand.

Neither is derived from the other. `api.` in front of the web host is this
repo's compose convention, not a rule, and DEPLOYING says plainly the two may
live anywhere. A guess that is usually right fails on somebody else's DNS long
after whoever guessed has stopped looking.

The window shows each address as text with where it came from, and the fields
fold away. The token stays a field and cannot be baked: each machine gets its
own, minted per machine, kept as a sha256. So installing is one paste.

`desktop:installer` warns loudly — before and after the build — when either
address is missing, and hard-fails on a malformed one. A warning rather than a
refusal because an addressless installer still works, while a refusal is one a
hurried person defeats by typing any string that parses, manufacturing the
worse case: a wrong address, confidently pointing every machine at the wrong
console, with the field that would fix it folded away.

Verified by building with two known strings, running the compiled test binary
with both variables unset, and grepping the stripped release artifact: both
present, and no derived third value.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`PORT="$API_PORT" WEB_ORIGIN="http://localhost:$PORT" npm run dev` reads as
"the API on 4000, the browser on 3333". It is not what bash does. Command
prefix assignments are performed left to right, so PORT became 4000 and only
then was $PORT expanded: the API was told its own address was the web app's.

Every call from the browser was refused by CORS, and because a blocked fetch
is indistinguishable from an unreachable host, the page said "The API is not
running" about an API that was running, answering curl, and correct in every
way except the one origin it would talk to.

The origin is worked out once now, into a variable a command prefix cannot
shadow. Verified: a request from http://localhost:3333 comes back with
`Access-Control-Allow-Origin: http://localhost:3333`, and one from another
origin still comes back with no such header at all.

This is older than the worker split — it arrived with 4c9faa2, when the
backend moved to its own port, and it is in main today. Both the dev and the
prod branch had it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`register` now answers with the machine's name and the sidecar logs that
instead of its own guess. A worker knows only what it was configured with,
usually a hostname; the console knows what a person typed in Settings →
Machines, and that is the name every screen shows.

Without it the log announced "Shifa" while the picker said "Shakhawat's PC" —
one computer with two names, in front of somebody trying to work out which
machine had stopped claiming.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The setup window asked for a worker token minted on a web console. With only
the NestJS API deployed, that console is not there to visit — and pasting a
credential was never a good first impression anyway.

`SessionGuard` now also accepts `Authorization: Session <id>`. Same session
row, same expiry, same revocation through Settings → Team; only the transport
differs, because a cookie is a browser mechanism and the app's webview is on a
local origin against a remote API, which makes the session cookie third-party
— the thing webviews are progressively refusing. The scheme is `Session` and
deliberately not `Bearer`: `WorkerGuard` owns that one, and two credentials
that look alike at a glance is how one ends up pasted where the other belongs.
A test pins that `Bearer <session>` never authenticates as a session.

`POST /auth/login` returns the session id only when the caller asks with
`X-Session-Return`, so the web app's response does not start carrying a
credential it never needed and might log.

The app then mints its own machine token and starts the worker. Re-enrolment
needs all three of: an id recorded here, a token in the credential store, and
the console still listing that id against the person who just signed in — not
the hostname (two DESKTOP-somethings), not the token alone (a revoked row
authenticates as nothing), and the ownership check is what stops a handed-over
laptop claiming jobs as its previous owner.

Proven against a throwaway API: `Session <sid>` 200, `Bearer <sid>` 401, no
credential 401, cookie login unchanged byte-for-byte without the header,
mint-then-register end to end, and logout over the header killing the session.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A teammate signed in twice on first setup: once in the app, which enrols the
machine over `Authorization: Session`, and again in the console webview,
which was a bare navigation carrying no cookie.

Migration 0017 adds `handoffs`. `POST /auth/handoff` mints a code against
whichever session authenticated the request; `GET /auth/adopt` spends it,
sets the cookie with the existing `setSession`, and redirects. The webview
opens through that instead of at the console's own URL, so the cookie lands
the ordinary way and the console is already signed in.

Four things it deliberately is not:

Not a new session. Spending the code hands over the one the app already
holds — two sessions for one sign-in means revoking one leaves the other
alive. Proven: the cookie is byte-identical to the id login returned, and
`/auth/sessions` gains no row.

Not a general redirect. An endpoint that sets a session cookie and then
redirects is worth more than either half, so `next` must match `WEB_ORIGIN`
exactly. `//evil.example` and `javascript:alert(1)` are both 400.

Not replayable. The spend is one UPDATE with `used_at IS NULL` in its WHERE,
so two requests race inside one statement and one wins. A replay redirects
with no Set-Cookie — the same answer as expired, unknown and revoked.

Not stored in the clear. Hashed like `invites.token`, reusing `hashToken`
rather than a second `createHash`.

Every opening goes through it, not only the first — the webview's cookie and
the app's session have different lifetimes, and on a handed-down computer
"sign in as somebody else" would otherwise show the previous person's
console. It falls back to the plain URL whenever the hand-off fails, because
a second sign-in is worse, not broken.

DEPLOYING now says `WEB_ORIGIN` carries a second job. Get it wrong and
nothing breaks loudly; teammates are just quietly asked to sign in twice.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The setup window opened with a title, an explanatory paragraph, a status card
with a disclosure, a sign-in panel with its own paragraph of reassurance, two
addresses each with a provenance sentence, two more disclosures and three
buttons — for a person whose whole job there is to type an email and a
password.

It is now what it is: a mark, a heading, one sentence, two fields, one
button, shaped like the console's own sign-in because the same person meets
both and two different front doors to one product is a reason to wonder which
one is real. 780 tall becomes 560, because the height was measuring content
that is no longer on the screen.

Everything else is behind one line — the worker's output, both addresses and
where they came from, overriding them, the token fallback. And that line is
not drawn at all until it is worth something: signed out with nothing wrong,
there is nothing behind it a person can act on. It appears the moment the
machine is set up, or the moment anything fails, which is when those are the
answer rather than the clutter.

Signed in, the window stops pretending to be a form. Nobody opens it then to
sign in — they came to check this machine or to fix it — so it leads with
what is true and the diagnostics are already open.

`npm run desktop` now serves `src-tauri/ui` on 1421 and Tauri's `devUrl`
points at it, so editing that page is a save rather than a `cargo build`.
Release builds never reach it: `frontendDist` still embeds the directory, and
the reload snippet is injected by the dev server, not written into the file.
A port already in use prints one line and falls back to the embedded copy,
because a busy port is not a reason to refuse to start.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Dokploy runs its own Traefik on :80 and :443. `docker-compose.prod.yml`
ships one too, so deploying that file there is two proxies contending for a
socket. `docker-compose.dokploy.yml` is the same three containers with the
proxy removed and no published ports at all: Dokploy's proxy reaches them
over its own network, and domains are set in its UI rather than in labels
here — two places writing Traefik rules for one service is how a deploy
serves the wrong container.

Postgres stays off the shared network entirely. Only the API talks to it.

DEPLOYING gains the field-by-field setup and one warning worth the space:
`NEXT_PUBLIC_API_URL` is a build argument, not an environment variable. The
browser bundle is compiled with the API's address inside it, and Dokploy's
two fields look alike while only one of them reaches a `next build`.

It also names the setting that breaks silently. `WEB_ORIGIN` is both the CORS
allow-list and the only origin the desktop app's sign-in hand-off may
redirect to, so it has to equal the app's built-in `CONTENTOS_CONSOLE_URL`
exactly. Wrong, and nothing errors: teammates are just asked to sign in a
second time, in the app they already signed in to.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The compose carried a Postgres of its own, which on Dokploy means running a
second database beside the Database service the platform already offers —
two places to look, and backups left to a volume nobody remembers to dump.

It now has two containers and no database. `DATABASE_URL` is the internal
connection string copied whole from Dokploy's database page rather than
assembled here from a host, a user and a password: Dokploy owns those and can
rotate them, and a second copy is a second thing to keep in step.

DEPLOYING gains the three steps in order — the database, the compose and its
five environment lines, then the domains — and keeps the warning that
`NEXT_PUBLIC_API_URL` is a build argument, because the browser bundle is
compiled with the API's address inside it and only one of Dokploy's two
similar-looking fields reaches a `next build`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A deployment can now finish usable, with no human step in the middle:
`CONTENTOS_OWNER_EMAIL` and `CONTENTOS_OWNER_PASSWORD` make the owner as the
API starts.

It is not what makes a fresh install reachable — signing up already works
while `users` is empty, and `createUser` makes the first account the owner.
This is for the deploy that would rather not stop and ask.

**Only while `users` is empty**, never "if this email is missing". The second
rule recreates the account every time somebody removes it, which is a back
door that reappears after being closed. Verified: booting again with a
different email made no second account, and that email cannot sign in.

It goes through `createUser` rather than an INSERT of its own, so it gets the
one definition of what an account is — the address check, the ten-character
minimum, scrypt, and the first-is-owner rule. A seeder writing its own row
would be a second definition, and the two would drift.

**No default password anywhere in the repository.** A password in the code is
a password in git, readable by everyone who can clone it, on the day it is
the password to a production console. Both variables or neither; one alone is
skipped with a line saying which. A bad address warns and carries on rather
than refusing to boot, because fixing a console is done from inside it.

DEPLOYING now uses `portal.` and `api.` under one domain rather than a nested
example, and says what that costs: `COOKIE_DOMAIN` then covers every
subdomain, so a shared middle name keeps the session cookie to this app.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`.github/workflows/check.yml` ran `npm run check` and two builds on every
push. That is the same four commands this team already runs before pushing,
on the machines that also build the desktop app and deploy the server — so it
was a second machine repeating work to produce a signal nobody was blocked
on.

DEVELOPING says so where the checks are documented, including what it costs:
with nothing running them but a person, an unrun check is an unknown one.
`npm run check` before a push and `npm run build:check` before a deploy are
now the whole discipline.

`npm ci` from the lockfile still resolves cleanly, which matters more than the
workflow did — it is what Dokploy runs when it builds the images.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Neither built. `docker compose -f docker-compose.dokploy.yml build` is what
Dokploy runs, and it failed twice before it got anywhere.

**The lockfile was missing its Linux entries.** `npm ci --omit=dev` refused
with `@emnapi/runtime` and `@emnapi/core` missing — optional platform packages
that `npm install` on Windows leaves out and a Linux image needs. Regenerated
inside `node:24-bookworm-slim` so it carries both; `npm ci` still resolves on
Windows.

**`api/Dockerfile` built without the types it compiles against.** The build
stage ran `npm ci --omit=dev`, which looks right for an image and is not: the
type packages for what `lib/server` imports are root devDependencies, so
`@types/pg` was absent and `tsc` produced a screenful of implicit-any on code
that compiles fine on a laptop. It installs everything now and runs
`npm prune --omit=dev` after the build, which is the distinction that was
missing — those packages are needed to build and not to run, and the runtime
stage copies this `node_modules` wholesale.

Both images now build: contentos-app 430MB, contentos-api 1.66GB.

I had said `npm ci` resolved cleanly when the CI was removed. That was true of
the root install with dev dependencies and not of `--omit=dev` on Linux, which
is the one that matters — the failing check was reporting something real.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`npm run check` and `npm run build:check` run here, with these node_modules,
on Windows. Dokploy builds on Linux, from the lockfile, in a clean container,
and that is a different question — one those two cannot answer.

Both times it has been asked it found something that stops a deploy dead: a
lockfile missing the platform packages a Linux image needs, and an image
compiling without the type packages its own code imports. Neither is visible
from a laptop.

So DEVELOPING now names the one command that asks it, with both failures and
their fixes beside it. Still no CI — this is a local command like the others,
run before pushing a branch that Dokploy will build.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@huelab-byte
huelab-byte merged commit b0c4d41 into main Sep 6, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant