Repository navigation
Worker split - #4
Merged
Merged
Conversation
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
No description provided.